diff --git a/CHANGELOG.md b/CHANGELOG.md index b74142411..f42633786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- An open tab now keeps running against the database it was opened on, so changing the database in the sidebar no longer breaks it with a "table doesn't exist" error. (#2026) +- Saving a table structure change no longer moves the sidebar and toolbar to that tab's database. (#2026) +- Row edits, fetch all rows, and multi-statement scripts now write to the database the tab is bound to, not whichever database another tab last used. (#2026) +- Switching between tabs no longer changes the connection's saved default database. (#2026) +- Refreshing after a save no longer reloads windows that are browsing a different database. (#2026) +- Hidden columns are now applied on a tab bound to a database other than the one selected in the sidebar. (#2026) +- Asking the AI chat or an MCP client to list tables or run a query no longer changes the database selected in the app. (#2026) +- Exporting now reads from the database the export was started for, and no longer opens a separate connection for every database on the server when listing them. (#2026) +- Exporting a query's remaining rows while disconnected now reports the error instead of leaving the progress sheet up forever. (#2026) +- Stopping a query now cancels the query itself rather than whichever background metadata read finished last. (#2026) +- Reopening a window no longer loses a table tab's saved sort and page when the connection was still connecting. (#2026) + +### Changed + +- A tab's window subtitle now shows the database it is bound to, for query tabs as well as table tabs. (#2026) +- Changing a tab's database from its toolbar now repoints only that tab and leaves the sidebar where it is. (#2026) +- `describe_table` and `get_table_ddl` now take a `database` argument, in AI chat and over MCP, so a table in another database can be inspected without changing the database selected in the app. `list_schemas` in AI chat takes one too. (#2026) + ## [0.63.0] - 2026-08-05 ### Added diff --git a/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift b/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift index cfe7cd7c4..0876f4d62 100644 --- a/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ConfirmDestructiveOperationChatTool.swift @@ -62,11 +62,15 @@ struct ConfirmDestructiveOperationChatTool: ChatTool { let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } let services = MCPToolServices(connectionBridge: context.bridge, authPolicy: context.authPolicy) + let scope = try await context.bridge.resolveScope( + connectionId: connectionId, + database: nil, + schema: nil + ) let payload = try await ToolQueryExecutor.executeAndLog( services: services, query: query, - connectionId: connectionId, - databaseName: meta.databaseName, + scope: scope, maxRows: 0, timeoutSeconds: MCPLimitResolver.resolveTimeoutSeconds(requested: nil, settings: mcpSettings), principalLabel: String(localized: "AI Chat") diff --git a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift index 28c4ea866..262f93486 100644 --- a/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/DescribeTableChatTool.swift @@ -12,6 +12,10 @@ struct DescribeTableChatTool: ChatTool { properties: [ "connection_id": ChatToolSchemaBuilder.connectionId, "table": ChatToolSchemaBuilder.string(description: "Table or view name"), + "database": ChatToolSchemaBuilder.string( + description: "Database name. Pass null to use current.", + optional: true + ), "schema": ChatToolSchemaBuilder.schemaName ] ) @@ -20,12 +24,15 @@ struct DescribeTableChatTool: ChatTool { func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { let connectionId = try context.resolveConnectionId(input) let table = try ChatToolArgumentDecoder.requireString(input, key: "table") + let database = ChatToolArgumentDecoder.optionalString(input, key: "database") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") - let payload = try await context.bridge.describeTable( + + let scope = try await context.bridge.resolveScope( connectionId: connectionId, - table: table, + database: database, schema: schema ) + let payload = try await context.bridge.describeTable(scope: scope, table: table) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } } diff --git a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift index 510b4e20e..89ad788f5 100644 --- a/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift @@ -25,11 +25,11 @@ struct ExecuteQueryChatTool: ChatTool { optional: true ), "database": ChatToolSchemaBuilder.string( - description: "Switch to this database before executing. Pass null to use current.", + description: "Run against this database. Pass null to use current.", optional: true ), "schema": ChatToolSchemaBuilder.string( - description: "Switch to this schema before executing. Pass null to use current.", + description: "Run against this schema. Pass null to use current.", optional: true ) ] @@ -73,12 +73,11 @@ struct ExecuteQueryChatTool: ChatTool { ) } - if let database { - _ = try await context.bridge.switchDatabase(connectionId: connectionId, database: database) - } - if let schema { - _ = try await context.bridge.switchSchema(connectionId: connectionId, schema: schema) - } + let scope = try await context.bridge.resolveScope( + connectionId: connectionId, + database: database, + schema: schema + ) try await context.authPolicy.checkSafeModeDialog( sql: query, @@ -91,8 +90,7 @@ struct ExecuteQueryChatTool: ChatTool { let payload = try await ToolQueryExecutor.executeAndLog( services: services, query: query, - connectionId: connectionId, - databaseName: meta.databaseName, + scope: scope, maxRows: maxRows, timeoutSeconds: timeoutSeconds, principalLabel: String(localized: "AI Chat") diff --git a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift index 34fb9c192..0d5debbd0 100644 --- a/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/GetTableDDLChatTool.swift @@ -12,6 +12,10 @@ struct GetTableDDLChatTool: ChatTool { properties: [ "connection_id": ChatToolSchemaBuilder.connectionId, "table": ChatToolSchemaBuilder.string(description: "Table name"), + "database": ChatToolSchemaBuilder.string( + description: "Database name. Pass null to use current.", + optional: true + ), "schema": ChatToolSchemaBuilder.schemaName ] ) @@ -20,12 +24,15 @@ struct GetTableDDLChatTool: ChatTool { func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { let connectionId = try context.resolveConnectionId(input) let table = try ChatToolArgumentDecoder.requireString(input, key: "table") + let database = ChatToolArgumentDecoder.optionalString(input, key: "database") let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") - let payload = try await context.bridge.getTableDDL( + + let scope = try await context.bridge.resolveScope( connectionId: connectionId, - table: table, + database: database, schema: schema ) + let payload = try await context.bridge.getTableDDL(scope: scope, table: table) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } } diff --git a/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift index b0e326601..f2e69374f 100644 --- a/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListSchemasChatTool.swift @@ -10,14 +10,24 @@ struct ListSchemasChatTool: ChatTool { let description = String(localized: "List schemas available in the active database of a connection.") let inputSchema: JsonValue = ChatToolSchemaBuilder.object( properties: [ - "connection_id": ChatToolSchemaBuilder.connectionId + "connection_id": ChatToolSchemaBuilder.connectionId, + "database": ChatToolSchemaBuilder.string( + description: "Database name. Pass null to use current.", + optional: true + ) ] ) let mode: ChatToolMode = .readOnly func execute(input: JsonValue, context: ChatToolContext) async throws -> ChatToolResult { let connectionId = try context.resolveConnectionId(input) - let payload = try await context.bridge.listSchemas(connectionId: connectionId) + let database = ChatToolArgumentDecoder.optionalString(input, key: "database") + let scope = try await context.bridge.resolveScope( + connectionId: connectionId, + database: database, + schema: nil + ) + let payload = try await context.bridge.listSchemas(scope: scope) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) } } diff --git a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift index d2043156c..18173856d 100644 --- a/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift +++ b/TablePro/Core/AI/Chat/Tools/ListTablesChatTool.swift @@ -30,15 +30,13 @@ struct ListTablesChatTool: ChatTool { let schema = ChatToolArgumentDecoder.optionalString(input, key: "schema") let includeRowCounts = ChatToolArgumentDecoder.optionalBool(input, key: "include_row_counts", default: false) - if let database { - _ = try await context.bridge.switchDatabase(connectionId: connectionId, database: database) - } - if let schema { - _ = try await context.bridge.switchSchema(connectionId: connectionId, schema: schema) - } - - let payload = try await context.bridge.listTables( + let scope = try await context.bridge.resolveScope( connectionId: connectionId, + database: database, + schema: schema + ) + let payload = try await context.bridge.listTables( + scope: scope, includeRowCounts: includeRowCounts ) return ChatToolResult(content: payload.jsonString(prettyPrinted: true)) diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 1dbfcbee2..f3730e2f6 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -274,7 +274,7 @@ actor SQLSchemaProvider { let capturedConnection = connection let capturedTables = tables let (dbName, idQuote, editorLanguage, queryLanguageName) = await MainActor.run { - let resolvedName = DatabaseManager.shared.activeDatabaseName(for: capturedConnection) + let resolvedName = DatabaseManager.shared.browseDatabaseName(for: capturedConnection) let quote = PluginManager.shared.sqlDialect(for: dbType)?.identifierQuote ?? "\"" let lang = PluginManager.shared.editorLanguage(for: dbType) let langName = PluginManager.shared.queryLanguageName(for: dbType) diff --git a/TablePro/Core/Concurrency/SessionDriverGate.swift b/TablePro/Core/Concurrency/SessionDriverGate.swift new file mode 100644 index 000000000..e9303afc5 --- /dev/null +++ b/TablePro/Core/Concurrency/SessionDriverGate.swift @@ -0,0 +1,95 @@ +// +// SessionDriverGate.swift +// TablePro +// + +import Foundation + +/// Serialises access to a connection's single shared driver. +/// +/// The driver carries one mutable position (its current database and schema), so an +/// operation has to move it before it runs. Without ordering, two windows interleave +/// their moves and each runs against the other's database. +/// +/// The body runs inline in the caller's own task rather than in a detached one, so +/// cancellation still reaches the work. +@MainActor +final class SessionDriverGate { + private struct Waiter { + let ticket: UUID + let continuation: CheckedContinuation + } + + private var holders: Set = [] + private var waiters: [UUID: [Waiter]] = [:] + + func withExclusiveAccess( + _ connectionId: UUID, + _ body: () async throws -> T + ) async throws -> T { + try await acquire(connectionId) + defer { release(connectionId) } + return try await body() + } + + /// Releases a connection that is going away, failing everyone still queued for it. + func drain(connectionId: UUID) { + holders.remove(connectionId) + let pending = waiters.removeValue(forKey: connectionId) ?? [] + for waiter in pending { + waiter.continuation.resume(throwing: CancellationError()) + } + } + + private func acquire(_ connectionId: UUID) async throws { + guard holders.contains(connectionId) else { + holders.insert(connectionId) + return + } + let ticket = UUID() + try await withTaskCancellationHandler( + operation: { try await enqueue(ticket: ticket, connectionId: connectionId) }, + onCancel: { [weak self] in + Task { @MainActor in + self?.failWaiter(ticket: ticket, connectionId: connectionId) + } + } + ) + } + + private func enqueue(ticket: UUID, connectionId: UUID) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return + } + waiters[connectionId, default: []].append( + Waiter(ticket: ticket, continuation: continuation) + ) + } + } + + /// Removes the ticket before resuming it, so a cancellation racing a hand-off + /// can only ever find one of them. + private func failWaiter(ticket: UUID, connectionId: UUID) { + guard var pending = waiters[connectionId], + let index = pending.firstIndex(where: { $0.ticket == ticket }) + else { + return + } + let waiter = pending.remove(at: index) + waiters[connectionId] = pending.isEmpty ? nil : pending + waiter.continuation.resume(throwing: CancellationError()) + } + + private func release(_ connectionId: UUID) { + guard var pending = waiters[connectionId], !pending.isEmpty else { + holders.remove(connectionId) + waiters.removeValue(forKey: connectionId) + return + } + let next = pending.removeFirst() + waiters[connectionId] = pending.isEmpty ? nil : pending + next.continuation.resume() + } +} diff --git a/TablePro/Core/Coordinators/PaginationCoordinator.swift b/TablePro/Core/Coordinators/PaginationCoordinator.swift index 43f2dc917..86806300e 100644 --- a/TablePro/Core/Coordinators/PaginationCoordinator.swift +++ b/TablePro/Core/Coordinators/PaginationCoordinator.swift @@ -155,6 +155,7 @@ final class PaginationCoordinator { !tab.pagination.isCountingExact, let tableName = tab.tableContext.tableName, !tableName.isEmpty else { return } + guard let scope = parent.scope(for: tab) else { return } let tabId = tab.id let schemaName = tab.tableContext.schemaName let filters = tab.filterState.hasAppliedFilters ? tab.filterState.appliedFilters : [] @@ -169,7 +170,7 @@ final class PaginationCoordinator { let capturedGeneration = parent.queryGeneration parent.currentRowCountTask = Task(priority: .userInitiated) { [parent] in let count = await Self.exactRowCount( - connectionId: parent.connectionId, + scope: scope, tableName: tableName, filters: filters, logicMode: logicMode, @@ -189,13 +190,13 @@ final class PaginationCoordinator { } private static func exactRowCount( - connectionId: UUID, + scope: DatabaseScope, tableName: String, filters: [TableFilter], logicMode: FilterLogicMode, countSQL: String? ) async -> Int? { - try? await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try? await DatabaseManager.shared.withMetadataDriver(scope: scope, workload: .bulk) { driver in guard let countSQL else { return try await driver.fetchExactRowCount( table: tableName, filters: filters, logicMode: logicMode @@ -209,6 +210,8 @@ final class PaginationCoordinator { // MARK: - Fetch All Rows + /// The scope is read before the confirmation alert, so a database change made while + /// the alert is open cannot send the tab's own query somewhere else. func fetchAllRows() { guard let (tab, _) = parent.tabManager.selectedTabAndIndex, !tab.pagination.isLoadingMore, @@ -216,6 +219,13 @@ final class PaginationCoordinator { tab.pagination.hasMoreRows, let baseQuery = tab.pagination.baseQueryForMore else { return } + guard let scope = parent.scope(for: tab) else { + parent.tabManager.mutate(tabId: tab.id) { + $0.execution.errorMessage = String(localized: "Not connected to database") + } + return + } + let loadedCount = parent.tabSessionRegistry.tableRows(for: tab.id).rows.count let totalEstimate = tab.pagination.totalRowCount @@ -236,11 +246,13 @@ final class PaginationCoordinator { confirmTitle: String(localized: "Fetch All") ) { [weak self] in guard let self else { return } - performFetchAll(tabId: tab.id, baseQuery: baseQuery) + performFetchAll(tabId: tab.id, baseQuery: baseQuery, scope: scope) } } - private func performFetchAll(tabId: UUID, baseQuery: String) { + /// Only the driver work runs inside the lease. Applying the rows to the tab stays + /// outside it, because the connection's driver gate is not reentrant. + private func performFetchAll(tabId: UUID, baseQuery: String, scope: DatabaseScope) { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return } guard !parent.tabManager.tabs[idx].pagination.isLoadingMore else { return } @@ -250,22 +262,26 @@ final class PaginationCoordinator { parent.tabManager.mutate(at: idx) { $0.pagination.isLoadingMore = true } parent.toolbarState.setExecuting(true) + let route = DatabaseManager.shared.executionRoute(for: scope) + parent.currentQueryTask = Task { [weak self, parent] in guard let self, !parent.isTearingDown else { return } do { - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { - throw DatabaseError.notConnected - } - let start = CFAbsoluteTimeGetCurrent() progressLog.info("[fetchAll] executing full query: \(baseQuery.prefix(100), privacy: .public)") let anyParams: [Any?]? = storedParamValues.map { $0.map { $0 as Any? } } - let result = try await driver.executeUserQuery( - query: baseQuery, - rowCap: nil, - parameters: anyParams - ) + let result = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + tracksCancellation: true + ) { driver in + try await driver.executeUserQuery( + query: baseQuery, + rowCap: nil, + parameters: anyParams + ) + } let fetchTime = CFAbsoluteTimeGetCurrent() - start progressLog.info("[fetchAll] rows=\(result.rows.count) fetchTime=\(String(format: "%.3f", fetchTime))s") @@ -303,9 +319,15 @@ final class PaginationCoordinator { } catch { await MainActor.run { [weak self] in guard let self else { return } - parent.tabManager.mutate(tabId: tabId) { $0.pagination.isLoadingMore = false } + let isStale = capturedGeneration != parent.queryGeneration + let isCancelled = DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled + parent.tabManager.mutate(tabId: tabId) { tab in + tab.pagination.isLoadingMore = false + guard !isStale, !isCancelled else { return } + tab.execution.errorMessage = DatabaseWriteRejectionDiagnosis.formatted(error) + } parent.toolbarState.setExecuting(false) - if capturedGeneration == parent.queryGeneration { + if !isStale { parent.currentQueryTask = nil } MainContentCoordinator.logger.error("Fetch all failed: \(error.localizedDescription, privacy: .public)") diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index fbb70a214..a9fb8ee0f 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -33,6 +33,15 @@ extension QueryExecutionCoordinator { QueryExecutor.parseSchemaMetadata(schema) } + /// History belongs to the database the tab actually ran on, not to wherever the + /// sidebar happens to point when the entry is written. + func historyDatabaseName(tabId: UUID) -> String { + guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { + return parent.browseDatabaseName + } + return parent.scope(for: tab)?.database ?? parent.browseDatabaseName + } + func isMetadataCached(tabId: UUID, tableName: String) -> Bool { guard let idx = parent.tabManager.tabs.firstIndex(where: { $0.id == tabId }) else { return false @@ -208,7 +217,7 @@ extension QueryExecutionCoordinator { QueryHistoryManager.shared.recordQuery( query: historySQL ?? sql, connectionId: conn.id, - databaseName: parent.activeDatabaseName, + databaseName: historyDatabaseName(tabId: tabId), executionTime: executionTime, rowCount: rows.count, wasSuccessful: true, @@ -250,7 +259,7 @@ extension QueryExecutionCoordinator { QueryHistoryManager.shared.recordQuery( query: sql, connectionId: conn.id, - databaseName: parent.activeDatabaseName, + databaseName: historyDatabaseName(tabId: tabId), executionTime: executionTime, rowCount: rowCount, wasSuccessful: true, @@ -412,24 +421,29 @@ extension QueryExecutionCoordinator { guard let self else { return } guard !parent.isTearingDown else { return } - let prepared: (plan: RowCountPlan, sql: String?) = await MainActor.run { - guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { return (.skip, nil) } + let prepared: (plan: RowCountPlan, sql: String?, scope: DatabaseScope?) = await MainActor.run { + guard let tab = parent.tabManager.tabs.first(where: { $0.id == tabId }) else { + return (.skip, nil, nil) + } + let scope = parent.scope(for: tab) let plan = Self.rowCountPlan( isNonSQL: isNonSQL, filterState: tab.filterState, approximateRowCount: tab.pagination.totalRowCount, threshold: AppSettingsManager.shared.dataGrid.countRowsIfEstimateLessThan ) - guard case let .exactCount(filtered) = plan else { return (plan, nil) } + guard case let .exactCount(filtered) = plan else { return (plan, nil, scope) } let sql = parent.queryBuilder.buildFilteredCountQuery( tableName: tableName, schemaName: tab.tableContext.schemaName, filters: filtered ? tab.filterState.appliedFilters : [], logicMode: tab.filterState.filterLogicMode ) - return (plan, sql) + return (plan, sql, scope) } + guard let countScope = prepared.scope else { return } + let outcome: RowCountOutcome? switch prepared.plan { case .skip: @@ -437,7 +451,7 @@ extension QueryExecutionCoordinator { case .clear: outcome = .clear case .approximate: - if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, { driver in + if let count = try? await DatabaseManager.shared.withMetadataDriver(scope: countScope, { driver in try await driver.fetchApproximateRowCount(table: tableName) }) { outcome = .count(count, isApproximate: true) @@ -445,7 +459,7 @@ extension QueryExecutionCoordinator { outcome = nil } case let .filteredNonSQL(filters, logicMode): - if let count = try? await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk, { driver in + if let count = try? await DatabaseManager.shared.withMetadataDriver(scope: countScope, workload: .bulk, { driver in try await driver.fetchFilteredRowCount(table: tableName, filters: filters, logicMode: logicMode) }) { outcome = .count(count, isApproximate: false) @@ -456,7 +470,7 @@ extension QueryExecutionCoordinator { let count: Int? if let sql = prepared.sql { do { - count = try await DatabaseManager.shared.withMetadataDriver(connectionId: parent.connectionId, workload: .bulk) { driver in + count = try await DatabaseManager.shared.withMetadataDriver(scope: countScope, workload: .bulk) { driver in let result = try await driver.execute(query: sql) guard let countStr = result.rows.first?.first?.asText else { return Int?.none } return Int(countStr) @@ -528,37 +542,13 @@ extension QueryExecutionCoordinator { QueryHistoryManager.shared.recordQuery( query: sql, connectionId: conn.id, - databaseName: parent.activeDatabaseName, + databaseName: historyDatabaseName(tabId: tabId), executionTime: 0, rowCount: 0, wasSuccessful: false, errorMessage: error.localizedDescription ) } - - func restoreSchemaAndRunQuery(_ schema: String, trigger: TableLoadTrigger = .userInitiated) async { - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { - parent.pendingLoadTrigger = trigger - return - } - guard let schemaDriver = driver as? SchemaSwitchable, - schemaDriver.currentSchema != nil else { - parent.runQuery(trigger: trigger) - return - } - do { - try await schemaDriver.switchSchema(to: schema) - DatabaseManager.shared.updateSession(parent.connectionId) { session in - session.currentSchema = schema - } - parent.toolbarState.currentSchema = schema - await parent.refreshTables() - } catch { - helpersLogger.warning("Failed to restore schema '\(schema, privacy: .public)': \(error.localizedDescription, privacy: .public)") - return - } - parent.runQuery(trigger: trigger) - } } enum RowCountPlan: Equatable { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift index 741b736c0..da88497ec 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+MultiStatement.swift @@ -56,13 +56,14 @@ extension QueryExecutionCoordinator { sql: String, result: QueryResult, connection: DatabaseConnection, + databaseName: String, parameterValues: [QueryParameter]? = nil ) { let historySQL = sql.hasSuffix(";") ? sql : sql + ";" QueryHistoryManager.shared.recordQuery( query: historySQL, connectionId: connection.id, - databaseName: parent.activeDatabaseName, + databaseName: databaseName, executionTime: result.executionTime, rowCount: result.rows.count, wasSuccessful: true, diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift index b595232fb..d8f151ece 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Parameters.swift @@ -9,6 +9,23 @@ import TableProPluginKit private let paramLog = Logger(subsystem: "com.TablePro", category: "QueryParameters") +/// One statement of a multi-statement run, resolved before the transaction opens so the +/// lease holds nothing but driver work. +private struct PreparedStatement { + let originalSQL: String + let executableSQL: String + let parameterValues: [Any?]? + let rowCap: Int? +} + +/// What a multi-statement transaction left behind. The results travel out of the lease +/// so the tab, the history and the error sheet are updated after the driver is released. +private enum MultiStatementOutcome { + case completed(results: [QueryResult]) + case failed(results: [QueryResult], failedSQL: String?, errorDescription: String) + case cancelled +} + extension QueryExecutionCoordinator { func detectAndReconcileParameters(sql: String, existing: [QueryParameter]) -> [QueryParameter] { QueryExecutor.detectAndReconcileParameters(sql: sql, existing: existing) @@ -50,6 +67,8 @@ extension QueryExecutionCoordinator { ) } + /// The query runs on the tab's own database, not on wherever the connection's shared + /// driver happens to be pointing. func executeQueryInternalParameterized( _ sql: String, parameters: [Any?], @@ -60,10 +79,17 @@ extension QueryExecutionCoordinator { guard let (selectedTab, index) = parent.tabManager.selectedTabAndIndex, !selectedTab.execution.isExecuting else { return } + guard let scope = parent.scope(for: selectedTab) else { + parent.tabManager.mutate(at: index) { + $0.execution.errorMessage = String(localized: "Not connected to database") + } + return + } + if parent.currentQueryTask != nil { parent.currentQueryTask?.cancel() do { - try DatabaseManager.shared.driver(for: parent.connectionId)?.cancelQuery() + try DatabaseManager.shared.cancelRunningQuery(for: parent.connectionId) } catch { paramLog.warning("cancelQuery failed: \(error.localizedDescription, privacy: .public)") } @@ -98,24 +124,30 @@ extension QueryExecutionCoordinator { } else { needsMetadataFetch = false } - let connId = parent.connectionId parent.currentQueryTask = Task { [weak self, parent] in guard let self else { return } let schemaTask: Task? if needsMetadataFetch, let tableName { - schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } + schemaTask = Task { try await QueryExecutor.fetchTableSchema(scope: scope, tableName: tableName) } } else { schemaTask = nil } do { - let fetchResult = try await parent.queryExecutor.executeQuery( - sql: sql, - parameters: parameters, - rowCap: rowCap - ) + let fetchResult = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: DatabaseManager.shared.executionRoute(for: scope), + tracksCancellation: true + ) { [queryExecutor = parent.queryExecutor] driver in + try await queryExecutor.executeQuery( + driver: driver, + sql: sql, + parameters: parameters, + rowCap: rowCap + ) + } guard !Task.isCancelled else { schemaTask?.cancel() @@ -184,6 +216,9 @@ extension QueryExecutionCoordinator { } } + /// Every statement of the run shares one lease on the tab's database, so the + /// transaction and its rollback reach the same handle. Result sets, history and the + /// error sheet are produced afterwards, outside the lease. func executeMultipleStatementsWithParameters( _ statements: [String], parameters: [QueryParameter], @@ -205,6 +240,13 @@ extension QueryExecutionCoordinator { return } + guard let scope = parent.scope(for: selectedTab) else { + parent.tabManager.mutate(at: index) { + $0.execution.errorMessage = String(localized: "Not connected to database") + } + return + } + let style = PluginMetadataRegistry.shared.snapshot( forTypeId: parent.connection.type.pluginTypeId )?.parameterStyle ?? .questionMark @@ -223,127 +265,207 @@ extension QueryExecutionCoordinator { let conn = parent.connection let tabId = parent.tabManager.tabs[index].id let totalCount = statements.count - let tabType = parent.tabManager.tabs[index].tabType + let transactionKind = OperationKind.worst(of: statements, databaseType: conn.type) + let prepared = statements.map { statementSQL in + prepareStatement( + sql: statementSQL, + parameters: parameters, + style: style, + tabType: tabType, + bypassRowLimit: bypassRowLimit + ) + } + parent.currentQueryTask = Task { [weak self, parent] in guard let self else { return } - var cumulativeTime: TimeInterval = 0 - var lastSelectResult: QueryResult? - var lastSelectSQL: String? - var totalRowsAffected = 0 - var executedCount = 0 - var failedSQL: String? - var newResultSets: [ResultSet] = [] - - do { - guard let driver = DatabaseManager.shared.driver(for: conn.id) else { - throw DatabaseError.notConnected - } - - let useTransaction = driver.supportsTransactions - let transactionKind = OperationKind.worst(of: statements, databaseType: conn.type) - if useTransaction { - try await driver.beginTransaction( - mode: transactionKind.declaresWrite ? .readWrite : .serverDefault - ) - } - - @MainActor func rollbackAndResetState() async { - if useTransaction { - do { - try await driver.rollbackTransaction() - } catch { - paramLog.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - parent.tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } - parent.currentQueryTask = nil - parent.toolbarState.setExecuting(false) - } - - for (stmtIndex, stmtSQL) in statements.enumerated() { - guard !Task.isCancelled else { - await rollbackAndResetState() - return - } - guard capturedGeneration == parent.queryGeneration else { - await rollbackAndResetState() - return - } - - let stmtParamNames = parameters.isEmpty - ? [] - : SQLParameterExtractor.extractParameters(from: stmtSQL) - let conversion = stmtParamNames.isEmpty - ? nil - : SQLParameterExtractor.convertToNativeStyle(sql: stmtSQL, parameters: parameters, style: style) - let statementSQL = conversion?.sql ?? stmtSQL - - let rowCap = resolveRowCap(sql: statementSQL, tabType: tabType, bypassLimit: bypassRowLimit) - failedSQL = statementSQL - let result = try await executeStatement( - rowCap: rowCap, - originalSQL: statementSQL, - driver: driver, - parameters: conversion?.values - ) - failedSQL = nil - executedCount = stmtIndex + 1 - cumulativeTime += result.executionTime - totalRowsAffected += result.rowsAffected - - if !result.columns.isEmpty { - lastSelectResult = result - lastSelectSQL = statementSQL - } - - newResultSets.append(makeStatementResultSet( - result: result, - sql: stmtSQL, - index: stmtIndex, - baseQuery: statementSQL, - baseQueryParameterValues: conversion?.values.map { $0 as? String } - )) - recordStatementHistory( - sql: stmtSQL, - result: result, - connection: conn, - parameterValues: stmtParamNames.isEmpty ? nil : parameters - ) - } - - if useTransaction { - try await driver.commitTransaction() - } + let outcome = await runMultiStatementTransaction( + prepared: prepared, + scope: scope, + mode: transactionKind.declaresWrite ? .readWrite : .serverDefault, + capturedGeneration: capturedGeneration + ) - await MainActor.run { - applyMultiStatementResults( - tabId: tabId, - capturedGeneration: capturedGeneration, - cumulativeTime: cumulativeTime, - totalRowsAffected: totalRowsAffected, - lastSelectResult: lastSelectResult, - lastSelectSQL: lastSelectSQL, - newResultSets: newResultSets - ) - } - } catch { + switch outcome { + case .cancelled: + parent.tabManager.mutate(tabId: tabId) { $0.execution.isExecuting = false } + parent.currentQueryTask = nil + parent.toolbarState.setExecuting(false) + case .completed(let results): + let resultSets = applyExecutedStatements( + prepared: prepared, + results: results, + parameters: parameters, + connection: conn, + tabId: tabId + ) + let lastSelectIndex = results.lastIndex { !$0.columns.isEmpty } + applyMultiStatementResults( + tabId: tabId, + capturedGeneration: capturedGeneration, + cumulativeTime: results.reduce(0) { $0 + $1.executionTime }, + totalRowsAffected: results.reduce(0) { $0 + $1.rowsAffected }, + lastSelectResult: lastSelectIndex.map { results[$0] }, + lastSelectSQL: lastSelectIndex.map { prepared[$0].executableSQL }, + newResultSets: resultSets + ) + case .failed(let results, let failedSQL, let errorDescription): + var resultSets = applyExecutedStatements( + prepared: prepared, + results: results, + parameters: parameters, + connection: conn, + tabId: tabId + ) await handleMultiStatementError( - error: error, + errorDescription: errorDescription, connection: conn, tabId: tabId, capturedGeneration: capturedGeneration, statements: statements, - executedCount: executedCount, + executedCount: results.count, totalCount: totalCount, - cumulativeTime: cumulativeTime, + cumulativeTime: results.reduce(0) { $0 + $1.executionTime }, failedSQL: failedSQL, - resultSets: &newResultSets + resultSets: &resultSets + ) + } + } + } + + private func prepareStatement( + sql: String, + parameters: [QueryParameter], + style: ParameterStyle, + tabType: TabType, + bypassRowLimit: Bool + ) -> PreparedStatement { + let parameterNames = parameters.isEmpty ? [] : SQLParameterExtractor.extractParameters(from: sql) + let conversion = parameterNames.isEmpty + ? nil + : SQLParameterExtractor.convertToNativeStyle(sql: sql, parameters: parameters, style: style) + let executableSQL = conversion?.sql ?? sql + return PreparedStatement( + originalSQL: sql, + executableSQL: executableSQL, + parameterValues: conversion?.values, + rowCap: resolveRowCap(sql: executableSQL, tabType: tabType, bypassLimit: bypassRowLimit) + ) + } + + private func runMultiStatementTransaction( + prepared: [PreparedStatement], + scope: DatabaseScope, + mode: PluginTransactionAccessMode, + capturedGeneration: Int + ) async -> MultiStatementOutcome { + do { + return try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: DatabaseManager.shared.executionRoute(for: scope), + tracksCancellation: true + ) { driver in + await self.runPreparedStatements( + prepared, + mode: mode, + capturedGeneration: capturedGeneration, + driver: driver + ) + } + } catch { + if DatabaseCancellationDiagnosis.isCancellation(error) || Task.isCancelled { + return .cancelled + } + return .failed(results: [], failedSQL: nil, errorDescription: error.localizedDescription) + } + } + + private func runPreparedStatements( + _ prepared: [PreparedStatement], + mode: PluginTransactionAccessMode, + capturedGeneration: Int, + driver: DatabaseDriver + ) async -> MultiStatementOutcome { + let useTransaction = driver.supportsTransactions + if useTransaction { + do { + try await driver.beginTransaction(mode: mode) + } catch { + return .failed(results: [], failedSQL: nil, errorDescription: error.localizedDescription) + } + } + + var results: [QueryResult] = [] + for statement in prepared { + guard !Task.isCancelled, capturedGeneration == parent.queryGeneration else { + await rollback(driver: driver, useTransaction: useTransaction) + return .cancelled + } + do { + results.append(try await executeStatement( + rowCap: statement.rowCap, + originalSQL: statement.executableSQL, + driver: driver, + parameters: statement.parameterValues + )) + } catch { + await rollback(driver: driver, useTransaction: useTransaction) + return .failed( + results: results, + failedSQL: statement.executableSQL, + errorDescription: error.localizedDescription ) } } + + if useTransaction { + do { + try await driver.commitTransaction() + } catch { + await rollback(driver: driver, useTransaction: useTransaction) + return .failed(results: results, failedSQL: nil, errorDescription: error.localizedDescription) + } + } + return .completed(results: results) + } + + private func rollback(driver: DatabaseDriver, useTransaction: Bool) async { + guard useTransaction else { return } + do { + try await driver.rollbackTransaction() + } catch { + paramLog.error("Rollback failed: \(error.localizedDescription, privacy: .public)") + } + } + + private func applyExecutedStatements( + prepared: [PreparedStatement], + results: [QueryResult], + parameters: [QueryParameter], + connection: DatabaseConnection, + tabId: UUID + ) -> [ResultSet] { + var resultSets: [ResultSet] = [] + for (index, pair) in zip(prepared, results).enumerated() { + let (statement, result) = pair + resultSets.append(makeStatementResultSet( + result: result, + sql: statement.originalSQL, + index: index, + baseQuery: statement.executableSQL, + baseQueryParameterValues: statement.parameterValues?.map { $0 as? String } + )) + recordStatementHistory( + sql: statement.originalSQL, + result: result, + connection: connection, + databaseName: historyDatabaseName(tabId: tabId), + parameterValues: statement.parameterValues == nil ? nil : parameters + ) + } + return resultSets } func applyParameterizedResult( @@ -400,8 +522,10 @@ extension QueryExecutionCoordinator { } } + /// The transaction was already rolled back inside the lease that ran it, so this + /// only reports the failure: resolving a driver here would reach a released handle. func handleMultiStatementError( - error: Error, + errorDescription: String, connection: DatabaseConnection, tabId: UUID, capturedGeneration: Int, @@ -412,14 +536,6 @@ extension QueryExecutionCoordinator { failedSQL: String?, resultSets: inout [ResultSet] ) async { - if let driver = DatabaseManager.shared.driver(for: connection.id), driver.supportsTransactions { - do { - try await driver.rollbackTransaction() - } catch { - paramLog.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - if capturedGeneration != parent.queryGeneration { await MainActor.run { [weak self] in guard let self else { return } @@ -431,8 +547,7 @@ extension QueryExecutionCoordinator { } let failedStmtIndex = executedCount + 1 - let contextMsg = "Statement \(failedStmtIndex)/\(totalCount) failed: " - + error.localizedDescription + let contextMsg = "Statement \(failedStmtIndex)/\(totalCount) failed: " + errorDescription let errorRS = ResultSet(label: "Error \(failedStmtIndex)") errorRS.errorMessage = contextMsg @@ -459,11 +574,11 @@ extension QueryExecutionCoordinator { QueryHistoryManager.shared.recordQuery( query: recordSQL, connectionId: connection.id, - databaseName: parent.activeDatabaseName, + databaseName: historyDatabaseName(tabId: tabId), executionTime: cumulativeTime, rowCount: 0, wasSuccessful: false, - errorMessage: error.localizedDescription + errorMessage: errorDescription ) } } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift index 60dcc5ebd..73b301d02 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift @@ -5,15 +5,19 @@ import AppKit import Foundation -import os import TableProPluginKit -private let discardLogger = Logger(subsystem: "com.TablePro", category: "RowEditingCoordinator+Discard") - extension RowEditingCoordinator { // MARK: - Sidebar Transaction + /// Edits made in the row inspector belong to the selected tab, so they run on that + /// tab's database. The scope is read before the authorization prompt, which awaits a + /// sheet and Touch ID and gives the selection time to move somewhere else. func executeSidebarChanges(statements: [ParameterizedStatement]) async throws { + guard let scope = parent.selectedTabScope else { + throw DatabaseError.notConnected + } + let sqlPreview = statements.map(\.sql).joined(separator: "\n") let kind = OperationKind.from(QueryClassifier.classifyTier(sqlPreview, databaseType: parent.connection.type)) let decision = await ExecutionGateProvider.shared.authorize( @@ -31,36 +35,13 @@ extension RowEditingCoordinator { throw DatabaseError.queryFailed(decision.deniedReason ?? String(localized: "Operation not permitted")) } - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { - throw DatabaseError.notConnected - } - - let useTransaction = driver.supportsTransactions - - if useTransaction { - try await driver.beginTransaction(mode: kind.declaresWrite ? .readWrite : .serverDefault) - } - - do { - for stmt in statements { - if stmt.parameters.isEmpty { - _ = try await driver.execute(query: stmt.sql) - } else { - _ = try await driver.executeParameterized(query: stmt.sql, parameters: stmt.parameters) - } - } - if useTransaction { - try await driver.commitTransaction() - } - } catch { - if useTransaction { - do { - try await driver.rollbackTransaction() - } catch { - discardLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - throw error + let mode: PluginTransactionAccessMode = kind.declaresWrite ? .readWrite : .serverDefault + _ = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: DatabaseManager.shared.executionRoute(for: scope), + tracksCancellation: true + ) { driver in + try await Self.runStatementsInTransaction(statements, mode: mode, on: driver) } } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index d07bf6a49..3464efdb5 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -11,6 +11,9 @@ import TableProPluginKit private let saveChangesLogger = Logger(subsystem: "com.TablePro", category: "RowEditingCoordinator") extension RowEditingCoordinator { + /// The scope is read once, before the destructive-delete sheet and the authorization + /// prompt, so moving the selection to another database while either is open cannot + /// retarget the statements that were generated for the edited tab. func saveChanges( pendingTruncates: inout Set, pendingDeletes: inout Set, @@ -25,6 +28,11 @@ extension RowEditingCoordinator { return } + guard let scope = parent.selectedTabScope else { + failSave(message: String(localized: "Not connected to database")) + return + } + let allStatements: [ParameterizedStatement] do { allStatements = try parent.assemblePendingStatements( @@ -33,22 +41,12 @@ extension RowEditingCoordinator { tableOperationOptions: tableOperationOptions ) } catch { - if let index = parent.tabManager.selectedTabIndex { - parent.tabManager.mutate(at: index) { $0.execution.errorMessage = error.localizedDescription } - } - parent.saveCompletionContinuation?.resume(returning: false) - parent.saveCompletionContinuation = nil + failSave(message: error.localizedDescription) return } guard !allStatements.isEmpty else { - if let index = parent.tabManager.selectedTabIndex { - parent.tabManager.mutate(at: index) { - $0.execution.errorMessage = String(localized: "Could not generate SQL for changes.") - } - } - parent.saveCompletionContinuation?.resume(returning: false) - parent.saveCompletionContinuation = nil + failSave(message: String(localized: "Could not generate SQL for changes.")) return } @@ -78,13 +76,12 @@ extension RowEditingCoordinator { ) guard confirmed else { if hasPendingTableOps { - DatabaseManager.shared.updateSession(connId) { session in - session.pendingTruncates = snapshotTruncates - session.pendingDeletes = snapshotDeletes - for (table, opts) in snapshotOptions { - session.tableOperationOptions[table] = opts - } - } + restorePendingTableOperations( + connectionId: connId, + truncates: snapshotTruncates, + deletes: snapshotDeletes, + options: snapshotOptions + ) } parent.saveCompletionContinuation?.resume(returning: false) parent.saveCompletionContinuation = nil @@ -110,6 +107,7 @@ extension RowEditingCoordinator { var opts = snapshotOptions executeCommitStatements( allStatements, + scope: scope, clearTableOps: hasPendingTableOps, pendingTruncates: &truncs, pendingDeletes: &dels, @@ -117,25 +115,25 @@ extension RowEditingCoordinator { ) case .denied(let reason): if hasPendingTableOps { - DatabaseManager.shared.updateSession(connId) { session in - session.pendingTruncates = snapshotTruncates - session.pendingDeletes = snapshotDeletes - for (table, opts) in snapshotOptions { - session.tableOperationOptions[table] = opts - } - } - } - if let index = parent.tabManager.selectedTabIndex { - parent.tabManager.mutate(at: index) { $0.execution.errorMessage = reason } + restorePendingTableOperations( + connectionId: connId, + truncates: snapshotTruncates, + deletes: snapshotDeletes, + options: snapshotOptions + ) } - parent.saveCompletionContinuation?.resume(returning: false) - parent.saveCompletionContinuation = nil + failSave(message: reason) } } } + /// Every statement, the rollback and the foreign-key re-enable run inside one + /// `withScopedDriver` lease, so they all reach the same handle on the tab's own + /// database. Everything else stays outside it: the connection's driver gate is not + /// reentrant, so refreshing or re-running a query from inside the body would deadlock. private func executeCommitStatements( _ statements: [ParameterizedStatement], + scope: DatabaseScope, clearTableOps: Bool, pendingTruncates: inout Set, pendingDeletes: inout Set, @@ -157,6 +155,7 @@ extension RowEditingCoordinator { && deletedTables.union(truncatedTables).contains { tableName in tableOperationOptions[tableName]?.ignoreForeignKeys == true } + let foreignKeyEnableStatements = fkWasDisabled ? parent.fkEnableStatements(for: dbType) : [] var capturedOptions: [String: TableOperationOptions] = [:] for table in deletedTables.union(truncatedTables) { @@ -171,61 +170,37 @@ extension RowEditingCoordinator { } } + let route = DatabaseManager.shared.executionRoute(for: scope) + Task { [weak self, parent] in guard let self else { return } let overallStartTime = Date() do { - guard let driver = DatabaseManager.shared.driver(for: parent.connectionId) else { - if let index = parent.tabManager.selectedTabIndex { - parent.tabManager.mutate(at: index) { - $0.execution.errorMessage = String(localized: "Not connected to database") - } - } - throw DatabaseError.notConnected + let executionTimes = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + tracksCancellation: true + ) { driver in + try await Self.runStatementsInTransaction( + validStatements, + mode: .readWrite, + foreignKeyEnableStatements: foreignKeyEnableStatements, + on: driver + ) } - let useTransaction = driver.supportsTransactions - - if useTransaction { - try await driver.beginTransaction(mode: .readWrite) - } - - do { - for statement in validStatements { - let statementStartTime = Date() - if statement.parameters.isEmpty { - _ = try await driver.execute(query: statement.sql) - } else { - _ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters) - } - - let executionTime = Date().timeIntervalSince(statementStartTime) - - let historySQL = statement.sql.trimmingCharacters(in: .whitespacesAndNewlines) - QueryHistoryManager.shared.recordQuery( - query: historySQL.hasSuffix(";") ? historySQL : historySQL + ";", - connectionId: conn.id, - databaseName: parent.activeDatabaseName, - executionTime: executionTime, - rowCount: 0, - wasSuccessful: true, - errorMessage: nil - ) - } - - if useTransaction { - try await driver.commitTransaction() - } - } catch { - if useTransaction { - do { - try await driver.rollbackTransaction() - } catch { - saveChangesLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") - } - } - throw error + for (statement, executionTime) in zip(validStatements, executionTimes) { + let historySQL = statement.sql.trimmingCharacters(in: .whitespacesAndNewlines) + QueryHistoryManager.shared.recordQuery( + query: historySQL.hasSuffix(";") ? historySQL : historySQL + ";", + connectionId: conn.id, + databaseName: scope.database, + executionTime: executionTime, + rowCount: 0, + wasSuccessful: true, + errorMessage: nil + ) } parent.changeManager.clearChangesAndUndoHistory() @@ -272,21 +247,11 @@ extension RowEditingCoordinator { } catch { let executionTime = Date().timeIntervalSince(overallStartTime) - if fkWasDisabled, let driver = DatabaseManager.shared.driver(for: parent.connectionId) { - for statement in parent.fkEnableStatements(for: dbType) { - do { - _ = try await driver.execute(query: statement) - } catch { - saveChangesLogger.warning("Failed to re-enable foreign key checks with statement '\(statement, privacy: .public)': \(error.localizedDescription, privacy: .public)") - } - } - } - let allSQL = validStatements.map { $0.sql }.joined(separator: "; ") QueryHistoryManager.shared.recordQuery( query: allSQL, connectionId: conn.id, - databaseName: parent.activeDatabaseName, + databaseName: scope.database, executionTime: executionTime, rowCount: 0, wasSuccessful: false, @@ -295,15 +260,6 @@ extension RowEditingCoordinator { let diagnosis = DatabaseWriteRejectionDiagnosis.classify(error) - if let index = parent.tabManager.selectedTabIndex { - parent.tabManager.mutate(at: index) { - $0.execution.errorMessage = String( - format: String(localized: "Save failed: %@"), - DatabaseWriteRejectionDiagnosis.formatted(error) - ) - } - } - AlertHelper.showErrorSheet( title: String(localized: "Save Failed"), message: diagnosis?.errorDescription ?? error.localizedDescription, @@ -312,17 +268,92 @@ extension RowEditingCoordinator { ) if clearTableOps { - DatabaseManager.shared.updateSession(conn.id) { session in - session.pendingTruncates = truncatedTables - session.pendingDeletes = deletedTables - for (table, opts) in capturedOptions { - session.tableOperationOptions[table] = opts - } - } + restorePendingTableOperations( + connectionId: conn.id, + truncates: truncatedTables, + deletes: deletedTables, + options: capturedOptions + ) } - parent.saveCompletionContinuation?.resume(returning: false) - parent.saveCompletionContinuation = nil + failSave( + message: String( + format: String(localized: "Save failed: %@"), + DatabaseWriteRejectionDiagnosis.formatted(error) + ) + ) + } + } + } + + /// The rollback and the foreign-key re-enable are part of the same lease as the + /// statements: resolving a driver again afterwards can reach a handle that has + /// already been released, or one sitting on another database. + nonisolated static func runStatementsInTransaction( + _ statements: [ParameterizedStatement], + mode: PluginTransactionAccessMode, + foreignKeyEnableStatements: [String] = [], + on driver: DatabaseDriver + ) async throws -> [TimeInterval] { + let useTransaction = driver.supportsTransactions + if useTransaction { + try await driver.beginTransaction(mode: mode) + } + + var executionTimes: [TimeInterval] = [] + do { + for statement in statements { + let statementStartTime = Date() + if statement.parameters.isEmpty { + _ = try await driver.execute(query: statement.sql) + } else { + _ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters) + } + executionTimes.append(Date().timeIntervalSince(statementStartTime)) + } + + if useTransaction { + try await driver.commitTransaction() + } + } catch { + if useTransaction { + do { + try await driver.rollbackTransaction() + } catch { + saveChangesLogger.error("Rollback failed: \(error.localizedDescription, privacy: .public)") + } + } + for statement in foreignKeyEnableStatements { + do { + _ = try await driver.execute(query: statement) + } catch { + saveChangesLogger.warning("Failed to re-enable foreign key checks with statement '\(statement, privacy: .public)': \(error.localizedDescription, privacy: .public)") + } + } + throw error + } + return executionTimes + } + + private func failSave(message: String) { + if let index = parent.tabManager.selectedTabIndex { + parent.tabManager.mutate(at: index) { $0.execution.errorMessage = message } + } + parent.saveCompletionContinuation?.resume(returning: false) + parent.saveCompletionContinuation = nil + } + + private func restorePendingTableOperations( + connectionId: UUID, + truncates: Set, + deletes: Set, + options: [String: TableOperationOptions] + ) { + DatabaseManager.shared.updateSession(connectionId) { session in + session.pendingTruncates = truncates + session.pendingDeletes = deletes + for (table, opts) in options { + session.tableOperationOptions[table] = opts } } } diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 835b28b12..8f0f4e80e 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -73,7 +73,7 @@ extension DatabaseManager { session.effectiveConnection = result.effectiveConnection session.status = .connected if let schemaDriver = result.driver as? SchemaSwitchable { - session.currentSchema = schemaDriver.currentSchema + session.browseSchema = schemaDriver.currentSchema } if let cachedPassword = result.cachedPassword, !session.connection.usesAWSIAM @@ -164,8 +164,8 @@ extension DatabaseManager { ) await restoreSchemaAndDatabase( on: driver, - savedSchema: session.currentSchema, - savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : session.currentDatabase + savedSchema: session.browseSchema, + savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : session.browseDatabase ) return ReconnectResult( @@ -284,8 +284,8 @@ extension DatabaseManager { ) await restoreSchemaAndDatabase( on: driver, - savedSchema: activeSessions[sessionId]?.currentSchema, - savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : activeSessions[sessionId]?.currentDatabase + savedSchema: activeSessions[sessionId]?.browseSchema, + savedDatabase: databaseSwitchRequiresReconnect(session.connection) ? nil : activeSessions[sessionId]?.browseDatabase ) updateSession(sessionId) { session in @@ -293,7 +293,7 @@ extension DatabaseManager { session.status = .connected session.effectiveConnection = effectiveConnection if let schemaDriver = driver as? SchemaSwitchable { - session.currentSchema = schemaDriver.currentSchema + session.browseSchema = schemaDriver.currentSchema } if let cachedPassword = connectResult.cachedPassword, !session.connection.usesAWSIAM diff --git a/TablePro/Core/Database/DatabaseManager+Metadata.swift b/TablePro/Core/Database/DatabaseManager+Metadata.swift index 866fd1df8..7139ea108 100644 --- a/TablePro/Core/Database/DatabaseManager+Metadata.swift +++ b/TablePro/Core/Database/DatabaseManager+Metadata.swift @@ -5,30 +5,53 @@ import Foundation +/// Every metadata read states which database and schema it is for. There is deliberately +/// no connection-only overload: a connection reaches many databases, so resolving the +/// database from ambient session state is how a tab's read lands on another database. @MainActor -protocol MetadataDriverProviding: AnyObject { +protocol ScopedMetadataProviding: AnyObject { func withMetadataDriver( - connectionId: UUID, + scope: DatabaseScope, workload: MetadataConnectionPool.Workload, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T -} -extension DatabaseManager: MetadataDriverProviding {} + func browseScope(for connectionId: UUID) -> DatabaseScope? +} -extension DatabaseManager { +extension ScopedMetadataProviding { func withMetadataDriver( + scope: DatabaseScope, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await withMetadataDriver(scope: scope, workload: .interactive, body) + } + + /// For reads that belong to the sidebar rather than to a tab: the object list, the + /// database and quick switchers, autocomplete, and the AI schema context. + func withBrowseMetadataDriver( connectionId: UUID, workload: MetadataConnectionPool.Workload = .interactive, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { - guard let session = session(for: connectionId) else { + guard let scope = browseScope(for: connectionId) else { throw DatabaseError.notConnected } - return try await MetadataConnectionPool.shared.withDriver( - connectionId: connectionId, - database: session.activeDatabase, - schema: session.currentSchema, + return try await withMetadataDriver(scope: scope, workload: workload, body) + } +} + +extension DatabaseManager: ScopedMetadataProviding {} + +extension DatabaseManager { + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload = .interactive, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await withScopedDriver( + scope: scope, + route: metadataRoute(for: scope), workload: workload, body ) diff --git a/TablePro/Core/Database/DatabaseManager+Principals.swift b/TablePro/Core/Database/DatabaseManager+Principals.swift index 3d1cbde8e..30220d04b 100644 --- a/TablePro/Core/Database/DatabaseManager+Principals.swift +++ b/TablePro/Core/Database/DatabaseManager+Principals.swift @@ -101,7 +101,7 @@ extension DatabaseManager { ) } - let databaseName = activeSessions[connectionId]?.activeDatabase ?? "" + let databaseName = activeSessions[connectionId]?.resolvedBrowseDatabase ?? "" // Query history is stored unencrypted on disk. A CREATE USER / ALTER USER statement embeds // the plaintext password, so it is never recorded. for statement in statements where !statement.carriesCredentials { diff --git a/TablePro/Core/Database/DatabaseManager+Schema.swift b/TablePro/Core/Database/DatabaseManager+Schema.swift index cd8edb864..a06b59a6f 100644 --- a/TablePro/Core/Database/DatabaseManager+Schema.swift +++ b/TablePro/Core/Database/DatabaseManager+Schema.swift @@ -14,99 +14,71 @@ import TableProPluginKit extension DatabaseManager { /// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction. - /// The connection, database, and schema all come from the caller's own tab, never - /// from ambient session state that another window or tab can move. + /// The connection, database and schema all come from the editing tab's own scope, + /// never from ambient session state that another window or tab can move. + /// + /// Authorization sits between two scoped blocks rather than inside one: it awaits a + /// confirmation sheet and Touch ID, and holding the connection's driver gate across a + /// human prompt would freeze every other tab on that connection. func executeSchemaChanges( tableName: String, changes: [SchemaChange], databaseType: DatabaseType, - databaseName: String, - schemaName: String?, - connectionId: UUID + scope: DatabaseScope ) async throws { - guard let driver = driver(for: connectionId) else { - throw DatabaseError.notConnected - } - - try await trackOperation(sessionId: connectionId) { - try await pinDatabaseBeforeSchemaChange( - databaseName: databaseName, - schemaName: schemaName, - databaseType: databaseType, - connectionId: connectionId - ) + let route = executionRoute(for: scope) - // For PostgreSQL PK modification, query the actual constraint name - let pkConstraintName = await fetchPrimaryKeyConstraintName( + let statements = try await withScopedDriver(scope: scope, route: route) { driver in + let pkConstraintName = await Self.fetchPrimaryKeyConstraintName( tableName: tableName, databaseType: databaseType, changes: changes, driver: driver ) - guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { throw DatabaseError.unsupportedOperation } - let generator = SchemaStatementGenerator( tableName: tableName, primaryKeyConstraintName: pkConstraintName, pluginDriver: resolvedPluginDriver ) - let statements = try generator.generate(changes: changes) + return try generator.generate(changes: changes) + } - let combinedSQL = statements.map(\.sql).joined(separator: "\n") - let schemaKind: OperationKind = - QueryClassifier.classifyTier(combinedSQL, databaseType: databaseType) == .destructive - ? .destructiveQuery : .schemaMutation - let authorization = await ExecutionGateProvider.shared.authorize( - OperationRequest( - connectionId: connectionId, - databaseType: databaseType, - sql: combinedSQL, - kind: schemaKind, - caller: .userInterface, - capabilities: .interactiveUser, - operationDescription: String(localized: "Apply Schema Changes") - ) + let combinedSQL = statements.map(\.sql).joined(separator: "\n") + let schemaKind: OperationKind = + QueryClassifier.classifyTier(combinedSQL, databaseType: databaseType) == .destructive + ? .destructiveQuery : .schemaMutation + let authorization = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: scope.connectionId, + databaseType: databaseType, + sql: combinedSQL, + kind: schemaKind, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Apply Schema Changes") ) - guard case .authorized = authorization else { - throw DatabaseError.queryFailed( - authorization.deniedReason ?? String(localized: "Schema change was not authorized") - ) - } + ) + guard case .authorized = authorization else { + throw DatabaseError.queryFailed( + authorization.deniedReason ?? String(localized: "Schema change was not authorized") + ) + } + try await withScopedDriver(scope: scope, route: route, tracksCancellation: true) { driver in let useTransaction = driver.supportsTransactions - if useTransaction { try await driver.beginTransaction(mode: schemaKind.declaresWrite ? .readWrite : .serverDefault) } - do { for stmt in statements { _ = try await driver.execute(query: stmt.sql) } - if useTransaction { try await driver.commitTransaction() } - - let connId = connectionId - let dbName = self.activeSessions[connectionId]?.activeDatabase ?? "" - for stmt in statements { - QueryHistoryManager.shared.recordQuery( - query: stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";", - connectionId: connId, - databaseName: dbName, - executionTime: 0, - rowCount: 0, - wasSuccessful: true - ) - } - - await MainActor.run { - AppCommands.shared.refreshData.send(connectionId) - } } catch { if useTransaction { do { @@ -118,60 +90,25 @@ extension DatabaseManager { throw DatabaseError.queryFailed("Schema change failed: \(error.localizedDescription)") } } - } - - /// Point the shared session driver at the database and schema the edited table - /// belongs to. Sibling tabs on the same connection move that driver, so a save must - /// re-pin before it writes. A failed pin aborts the save: a misdirected DDL is - /// silent and often irreversible, unlike a misdirected read. - /// - /// Switching database on a schema-grouped engine drops the driver back to the engine's - /// default schema, and those drivers qualify their DDL with whatever schema they are - /// currently on, so the schema has to be restored before any statement is generated. - private func pinDatabaseBeforeSchemaChange( - databaseName: String, - schemaName: String?, - databaseType: DatabaseType, - connectionId: UUID - ) async throws { - guard !databaseName.isEmpty, - !pluginManager.requiresReconnectForDatabaseSwitch(for: databaseType), - databaseName != activeSessions[connectionId]?.activeDatabase - else { - return - } - - let targetSchema = resolvedSchemaName(schemaName, for: connectionId) - do { - try await switchDatabase(to: databaseName, for: connectionId, persist: false) - try await restoreSchemaAfterDatabasePin(targetSchema, for: connectionId) - } catch { - throw DatabaseError.queryFailed( - String( - format: String(localized: "Could not switch to database %@ before applying schema changes: %@"), - databaseName, - error.localizedDescription - ) + for stmt in statements { + QueryHistoryManager.shared.recordQuery( + query: stmt.sql.hasSuffix(";") ? stmt.sql : stmt.sql + ";", + connectionId: scope.connectionId, + databaseName: scope.database, + executionTime: 0, + rowCount: 0, + wasSuccessful: true ) } - } - - private func restoreSchemaAfterDatabasePin(_ targetSchema: String?, for connectionId: UUID) async throws { - guard let targetSchema, !targetSchema.isEmpty, - let schemaDriver = driver(for: connectionId) as? SchemaSwitchable, - schemaDriver.currentSchema != targetSchema - else { - return - } - try await switchSchema(to: targetSchema, for: connectionId) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: scope.connectionId, scope: scope)) } /// Query the actual primary key constraint name for PostgreSQL. /// Returns nil if the database is not PostgreSQL, no PK modification is pending, /// or the query fails (caller falls back to `{table}_pkey` convention). - private func fetchPrimaryKeyConstraintName( + private static func fetchPrimaryKeyConstraintName( tableName: String, databaseType: DatabaseType, changes: [SchemaChange], diff --git a/TablePro/Core/Database/DatabaseManager+Scope.swift b/TablePro/Core/Database/DatabaseManager+Scope.swift new file mode 100644 index 000000000..80b9fe116 --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+Scope.swift @@ -0,0 +1,36 @@ +// +// DatabaseManager+Scope.swift +// TablePro +// + +import Foundation + +extension DatabaseManager { + /// The scope a new tab inherits and the sidebar lists. Never the scope of an + /// operation an existing tab owns. + func browseScope(for connectionId: UUID) -> DatabaseScope? { + guard let session = activeSessions[connectionId] else { return nil } + return DatabaseScope( + connectionId: connectionId, + database: session.resolvedBrowseDatabase, + schema: session.browseSchema + ) + } + + /// Freezes a tab's identity once, at creation, the way `resolvedSchemaName` does one + /// tier down: an explicit database passes through untouched, and only a missing one + /// falls back to where the user happens to be browsing. Re-deriving it later is what + /// lets a tab drift onto another database. + func resolvedScope(database: String?, schema: String?, for connectionId: UUID) -> DatabaseScope? { + let resolvedSchema = resolvedSchemaName(schema, for: connectionId) + if let database, !database.isEmpty { + return DatabaseScope(connectionId: connectionId, database: database, schema: resolvedSchema) + } + guard let session = activeSessions[connectionId] else { return nil } + return DatabaseScope( + connectionId: connectionId, + database: session.resolvedBrowseDatabase, + schema: resolvedSchema + ) + } +} diff --git a/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift new file mode 100644 index 000000000..da1be54c0 --- /dev/null +++ b/TablePro/Core/Database/DatabaseManager+ScopedDriver.swift @@ -0,0 +1,192 @@ +// +// DatabaseManager+ScopedDriver.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Where an operation bound to a `DatabaseScope` runs. +enum ScopedDriverRoute: Equatable { + /// The connection's one shared driver, moved onto the scope first. + case sessionDriver + /// A pooled connection already sitting on the scope's database. + case pooled + case unavailable(String) +} + +extension DatabaseManager { + /// A metadata read needs no transaction, no temp tables and no cancellation handle, + /// so it takes a pooled connection and leaves the shared driver where it is. + func metadataRoute(for scope: DatabaseScope) -> ScopedDriverRoute { + guard let session = activeSessions[scope.connectionId] else { + return .unavailable(String(localized: "Not connected to database")) + } + guard !scope.isServerScoped else { return .sessionDriver } + return canPool(session) ? .pooled : .sessionDriver + } + + /// SQL the user owns stays on the session driver, which holds their transaction, + /// their temp tables and the handle Stop cancels. The pool is the fallback only for + /// engines that cannot change database on a live connection, where the alternative + /// is querying whichever database the connection happens to be on. + func executionRoute(for scope: DatabaseScope) -> ScopedDriverRoute { + guard let session = activeSessions[scope.connectionId] else { + return .unavailable(String(localized: "Not connected to database")) + } + guard !scope.isServerScoped else { return .sessionDriver } + let databaseType = session.connection.type + guard pluginManager.supportsDatabaseSwitching(for: databaseType), + pluginManager.requiresReconnectForDatabaseSwitch(for: databaseType), + scope.database != session.resolvedBrowseDatabase + else { + return .sessionDriver + } + guard canPool(session) else { + return .unavailable( + String( + format: String( + localized: "This tab is on %@. Switch the connection to that database to run it." + ), + scope.database + ) + ) + } + return .pooled + } + + /// `tracksCancellation` registers the leased driver so Stop can reach it. Only user + /// SQL opts in: a metadata read shares the connection but must never become the handle + /// Stop aborts, and must never clear the handle a running query registered. + func withScopedDriver( + scope: DatabaseScope, + route: ScopedDriverRoute, + workload: MetadataConnectionPool.Workload = .interactive, + tracksCancellation: Bool = false, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + let leased: @Sendable (DatabaseDriver) async throws -> T + if tracksCancellation { + let connectionId = scope.connectionId + let token = UUID() + leased = { driver in + await MainActor.run { + DatabaseManager.shared.runningDrivers[connectionId, default: [:]][token] = driver + } + do { + let value = try await body(driver) + await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) } + return value + } catch { + await MainActor.run { DatabaseManager.shared.releaseRunningDriver(token, for: connectionId) } + throw error + } + } + } else { + leased = body + } + + switch route { + case .unavailable(let message): + throw DatabaseError.queryFailed(message) + case .pooled: + return try await MetadataConnectionPool.shared.withDriver( + scope: scope, workload: workload, leased + ) + case .sessionDriver: + return try await withPinnedSessionDriver(scope: scope, leased) + } + } + + internal func releaseRunningDriver(_ token: UUID, for connectionId: UUID) { + runningDrivers[connectionId]?.removeValue(forKey: token) + if runningDrivers[connectionId]?.isEmpty == true { + runningDrivers.removeValue(forKey: connectionId) + } + } + + /// Stop has to reach the handle the query is actually running on, which is no longer + /// always the session driver now that a cross-database tab runs on a pooled connection. + func cancelRunningQuery(for connectionId: UUID) throws { + let running = runningDrivers[connectionId] ?? [:] + guard !running.isEmpty else { + try driver(for: connectionId)?.cancelQuery() + return + } + for driver in running.values { + try driver.cancelQuery() + } + } + + /// A pooled connection is keyed by database, so one that rewrites the connection's + /// database field to reach it would authenticate as a different identity, and one + /// whose database comes from a connection field rather than the database field would + /// silently serve the wrong database entirely. + private func canPool(_ session: ConnectionSession) -> Bool { + guard session.connection.type.supportsConnectionPooling else { return false } + let actions = PluginMetadataRegistry.shared.snapshot( + forTypeId: session.connection.type.pluginTypeId + )?.postConnectActions ?? [] + return !actions.contains { action in + if case .selectDatabaseFromConnectionField = action { return true } + return false + } + } + + private func withPinnedSessionDriver( + scope: DatabaseScope, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await sessionDriverGate.withExclusiveAccess(scope.connectionId) { + try await trackOperation(sessionId: scope.connectionId) { + try Task.checkCancellation() + guard let driver = driver(for: scope.connectionId) else { + throw DatabaseError.notConnected + } + try await pin(driver, to: scope) + return try await body(driver) + } + } + } + + /// Moves the shared driver onto the scope. It writes no session state, so the + /// sidebar and the toolbar do not follow a tab's operation. + /// + /// The database switch is issued every time because nothing tracks where the driver + /// actually is: a reconnect, a Redis SELECT, another window, or a user typing + /// `USE other` all move it. The schema switch asks the driver, which does know. + /// + /// This runs inside the gate, so an engine that cannot move a live connection is + /// re-checked here rather than trusting the route the caller computed before it + /// queued. A failed pin throws before the body runs, so a statement never lands on + /// the wrong database. + private func pin(_ driver: DatabaseDriver, to scope: DatabaseScope) async throws { + guard let session = activeSessions[scope.connectionId] else { + throw DatabaseError.notConnected + } + let databaseType = session.connection.type + if !scope.isServerScoped, pluginManager.supportsDatabaseSwitching(for: databaseType) { + if pluginManager.requiresReconnectForDatabaseSwitch(for: databaseType) { + guard scope.database == session.resolvedBrowseDatabase else { + throw DatabaseError.queryFailed( + String( + format: String( + localized: "This tab is on %@. Switch the connection to that database to run it." + ), + scope.database + ) + ) + } + } else if let adapter = driver as? PluginDriverAdapter { + try await adapter.switchDatabase(to: scope.database) + } + } + guard let schema = scope.schema, + let schemaDriver = driver as? SchemaSwitchable, + schemaDriver.currentSchema != schema + else { + return + } + try await schemaDriver.switchSchema(to: schema) + } +} diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 3a2be2db8..16ff18d16 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -121,7 +121,7 @@ extension DatabaseManager { ) if let schemaDriver = driver as? SchemaSwitchable { - activeSessions[connection.id]?.currentSchema = schemaDriver.currentSchema + activeSessions[connection.id]?.browseSchema = schemaDriver.currentSchema } await executePostConnectActions( @@ -218,7 +218,7 @@ extension DatabaseManager { let savedDb = appSettingsStorage.loadLastDatabase(for: connection.id) { do { try await adapter.switchDatabase(to: savedDb) - activeSessions[connection.id]?.currentDatabase = savedDb + activeSessions[connection.id]?.browseDatabase = savedDb } catch { Self.logger.warning("Failed to restore saved database '\(savedDb, privacy: .public)' for \(connection.id): \(error.localizedDescription, privacy: .public)") } @@ -237,12 +237,12 @@ extension DatabaseManager { if initialDb != 0 { do { try await (driver as? PluginDriverAdapter)?.switchDatabase(to: String(initialDb)) - activeSessions[connection.id]?.currentDatabase = String(initialDb) + activeSessions[connection.id]?.browseDatabase = String(initialDb) } catch { Self.logger.error("Failed to switch to database \(initialDb): \(error.localizedDescription)") } } else { - activeSessions[connection.id]?.currentDatabase = "0" + activeSessions[connection.id]?.browseDatabase = "0" } case .selectSchemaFromLastSession: if let schemaDriver = driver as? SchemaSwitchable, @@ -250,7 +250,7 @@ extension DatabaseManager { savedSchema != schemaDriver.currentSchema { do { try await schemaDriver.switchSchema(to: savedSchema) - activeSessions[connection.id]?.currentSchema = savedSchema + activeSessions[connection.id]?.browseSchema = savedSchema } catch { Self.logger.warning("Failed to restore saved schema '\(savedSchema, privacy: .public)': \(error.localizedDescription, privacy: .public)") } @@ -273,23 +273,25 @@ extension DatabaseManager { if pm?.capabilities.requiresReconnectForDatabaseSwitch == true { updateSession(connectionId) { session in session.connection.database = database - session.currentDatabase = database - session.currentSchema = nil + session.browseDatabase = database + session.browseSchema = nil session.status = .connecting } appSettingsStorage.saveLastSchema(nil, for: connectionId) await SchemaService.shared.invalidate(connectionId: connectionId) await reconnectSession(connectionId) } else if let adapter = driver as? PluginDriverAdapter { - try await adapter.switchDatabase(to: database) let grouping = pm?.schema.databaseGroupingStrategy ?? .byDatabase - if grouping == .bySchema { - await resetSchema(on: adapter, to: pm?.schema.defaultSchemaName) + try await sessionDriverGate.withExclusiveAccess(connectionId) { + try await adapter.switchDatabase(to: database) + if grouping == .bySchema { + await resetSchema(on: adapter, to: pm?.schema.defaultSchemaName) + } } updateSession(connectionId) { session in - session.currentDatabase = database + session.browseDatabase = database if grouping == .bySchema { - session.currentSchema = adapter.currentSchema + session.browseSchema = adapter.currentSchema } } } @@ -320,9 +322,11 @@ extension DatabaseManager { throw DatabaseError.unsupportedOperation } - try await schemaDriver.switchSchema(to: schema) + try await sessionDriverGate.withExclusiveAccess(connectionId) { + try await schemaDriver.switchSchema(to: schema) + } updateSession(connectionId) { session in - session.currentSchema = schema + session.browseSchema = schema } appSettingsStorage.saveLastSchema(schema, for: connectionId) AppEvents.shared.currentSchemaChanged.send(connectionId) diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index 420ee8c5c..45bc9bbb7 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -73,6 +73,15 @@ final class DatabaseManager { /// before touching shared session state and discards its driver when it lost. @ObservationIgnored internal var connectionAttempts = ConnectionAttemptRegistry() + /// Orders operations that move the shared driver, so two windows cannot interleave + /// their pins and each run against the other's database. + @ObservationIgnored internal let sessionDriverGate = SessionDriverGate() + + /// The drivers each connection is currently executing user SQL on, keyed by an + /// operation token so a finishing operation can only release its own handle. Stop + /// reaches the right one even when a cross-database tab runs on a pooled connection. + @ObservationIgnored internal var runningDrivers: [UUID: [UUID: DatabaseDriver]] = [:] + /// Session for `lastActiveSessionId`, subject to the same caveats. var lastActiveSession: ConnectionSession? { guard let sessionId = lastActiveSessionId else { return nil } @@ -89,11 +98,12 @@ final class DatabaseManager { activeSessions[connectionId] } - /// Authoritative active database for this connection. Use for tab payloads, - /// query history, schema cache keys, and AI prompt context. Reading - /// `connection.database` (the saved default) is wrong after Cmd+K. - func activeDatabaseName(for connection: DatabaseConnection) -> String { - activeSessions[connection.id]?.activeDatabase ?? connection.database + /// Where this connection is being browsed. Use it to seed a new tab and to drive + /// the sidebar. Reading `connection.database` (the saved default) is wrong after Cmd+K. + /// It is never the target of an operation an existing tab owns: resolve that through + /// the tab's own `DatabaseScope`. + func browseDatabaseName(for connection: DatabaseConnection) -> String { + activeSessions[connection.id]?.resolvedBrowseDatabase ?? connection.database } /// Authoritative schema for a table identity when the caller has no explicit @@ -103,7 +113,7 @@ final class DatabaseManager { /// object names treat it as "no schema" and emit an unqualified name. func resolvedSchemaName(_ schemaName: String?, for connectionId: UUID) -> String? { if let schemaName, !schemaName.isEmpty { return schemaName } - guard let sessionSchema = activeSessions[connectionId]?.currentSchema, !sessionSchema.isEmpty else { + guard let sessionSchema = activeSessions[connectionId]?.browseSchema, !sessionSchema.isEmpty else { return nil } return sessionSchema diff --git a/TablePro/Core/Database/TriggerEditing.swift b/TablePro/Core/Database/TriggerEditing.swift index a89c7d53a..a37f44169 100644 --- a/TablePro/Core/Database/TriggerEditing.swift +++ b/TablePro/Core/Database/TriggerEditing.swift @@ -90,7 +90,7 @@ enum TriggerEditing { } recordHistory(sql, connection: connection) - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } static func drop(connection: DatabaseConnection, tableName: String, name: String) async throws { @@ -118,7 +118,7 @@ enum TriggerEditing { _ = try await driver.execute(query: dropSQL) recordHistory(dropSQL, connection: connection) - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } static func runInTransaction(driver: DatabaseDriver, dropSQL: String?, sql: String) async throws { @@ -154,7 +154,7 @@ enum TriggerEditing { QueryHistoryManager.shared.recordQuery( query: sql, connectionId: connection.id, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), executionTime: 0, rowCount: 0, wasSuccessful: true diff --git a/TablePro/Core/Events/AppCommands.swift b/TablePro/Core/Events/AppCommands.swift index ed74bd29b..1cfa82b8c 100644 --- a/TablePro/Core/Events/AppCommands.swift +++ b/TablePro/Core/Events/AppCommands.swift @@ -6,13 +6,38 @@ import Combine import Foundation +/// A data-changed signal. `scope` names the database and schema the change landed in, so +/// a window browsing somewhere else does not refetch. A nil scope means the whole +/// connection changed and every window should reload. +struct DataRefreshRequest: Sendable, Equatable { + let connectionId: UUID + let scope: DatabaseScope? + + init(connectionId: UUID, scope: DatabaseScope? = nil) { + self.connectionId = connectionId + self.scope = scope + } + + /// A tab reloads when the change landed in its own scope. Matching on the browse + /// database instead would make a tab skip a refresh of its own data whenever the + /// sidebar is pointing somewhere else. + func reaches(tabScope: DatabaseScope?) -> Bool { + scope == nil || scope == tabScope + } + + /// The object list follows the sidebar, so it reloads only for the browsed database. + func reachesBrowsedDatabase(_ database: String) -> Bool { + scope == nil || scope?.database == database + } +} + @MainActor final class AppCommands { static let shared = AppCommands() // MARK: - Refresh - let refreshData = PassthroughSubject() + let refreshData = PassthroughSubject() let refreshPrincipals = PassthroughSubject() // MARK: - File / Connection Import-Export diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 75eef53aa..8a02942ab 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -26,7 +26,7 @@ public actor MCPConnectionBridge { "type": .string(conn.type.rawValue), "host": .string(conn.host), "port": .int(conn.port), - "database": .string(session?.activeDatabase ?? conn.database), + "database": .string(session?.resolvedBrowseDatabase ?? conn.database), "username": .string(conn.username), "is_connected": .bool(isConnected), "ai_policy": .string(policy.rawValue), @@ -46,8 +46,8 @@ public actor MCPConnectionBridge { if let existing = existingSession, existing.driver != nil { let serverVersion = existing.driver?.serverVersion - let currentDatabase = existing.activeDatabase - let currentSchema = existing.currentSchema + let currentDatabase = existing.resolvedBrowseDatabase + let currentSchema = existing.browseSchema var result: [String: JsonValue] = [ "status": "connected", @@ -68,8 +68,8 @@ public actor MCPConnectionBridge { let session = DatabaseManager.shared.activeSessions[connectionId] return ( session?.driver?.serverVersion, - session?.activeDatabase, - session?.currentSchema + session?.resolvedBrowseDatabase, + session?.browseSchema ) } @@ -103,7 +103,7 @@ public actor MCPConnectionBridge { guard let session = DatabaseManager.shared.activeSessions[connectionId] else { return nil } - return (session.status, session.activeDatabase, session.currentSchema) + return (session.status, session.resolvedBrowseDatabase, session.browseSchema) } guard let core else { @@ -152,13 +152,32 @@ public actor MCPConnectionBridge { return .object(result) } + /// The scope a tool operates on. A tool that names a database gets that database; one + /// that does not gets the connection's browse scope. Neither moves the user's cursor: + /// only `switch_database` does that. + func resolveScope(connectionId: UUID, database: String?, schema: String?) async throws -> DatabaseScope { + try await ensureConnected(connectionId) + return try await MainActor.run { + guard let scope = DatabaseManager.shared.resolvedScope( + database: database, + schema: schema, + for: connectionId + ) else { + throw MCPDataLayerError.invalidArgument( + "No database to run against. Pass a database name." + ) + } + return scope + } + } + func executeQuery( - connectionId: UUID, + scope: DatabaseScope, query: String, maxRows: Int, timeoutSeconds: Int ) async throws -> JsonValue { - let (driver, databaseType) = try await resolveDriver(connectionId) + let databaseType = try await ensureConnected(scope.connectionId) let normalizedQuery = Self.stripTrailingSemicolons(query) let isWrite = QueryClassifier.isWriteQuery(normalizedQuery, databaseType: databaseType) let hasReturning = normalizedQuery.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil @@ -166,9 +185,11 @@ public actor MCPConnectionBridge { let startTime = CFAbsoluteTimeGetCurrent() - let result: QueryResult = try await DatabaseManager.shared.trackOperation( - sessionId: connectionId - ) { + let route = await MainActor.run { DatabaseManager.shared.executionRoute(for: scope) } + let result: QueryResult = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route + ) { driver in try await withThrowingTaskGroup(of: QueryResult.self) { group in group.addTask { if shouldCap { @@ -222,17 +243,19 @@ public actor MCPConnectionBridge { return .object(response) } - func listTables(connectionId: UUID, includeRowCounts: Bool) async throws -> JsonValue { - let cachedTables = await MainActor.run { - SchemaService.shared.tables(for: connectionId) + func listTables(scope: DatabaseScope, includeRowCounts: Bool) async throws -> JsonValue { + try await ensureConnected(scope.connectionId) + + let cachedTables = await MainActor.run { () -> [TableInfo] in + guard DatabaseManager.shared.browseScope(for: scope.connectionId) == scope else { return [] } + return SchemaService.shared.tables(for: scope.connectionId) } let tables: [TableInfo] if !cachedTables.isEmpty { tables = cachedTables } else { - let (driver, _) = try await resolveDriver(connectionId) - tables = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + tables = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in try await driver.fetchTables() } } @@ -251,11 +274,11 @@ public actor MCPConnectionBridge { return .object(["tables": .array(jsonTables)]) } - func describeTable(connectionId: UUID, table: String, schema: String?) async throws -> JsonValue { - let (driver, _) = try await resolveDriver(connectionId) + func describeTable(scope: DatabaseScope, table: String) async throws -> JsonValue { + try await ensureConnected(scope.connectionId) - return try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { - let columns = try await driver.fetchColumns(table: table, schema: schema) + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + let columns = try await driver.fetchColumns(table: table, schema: scope.schema) let indexes = try await driver.fetchIndexes(table: table) let foreignKeys = try await driver.fetchForeignKeys(table: table) let approxRowCount = try await driver.fetchApproximateRowCount(table: table) @@ -323,17 +346,17 @@ public actor MCPConnectionBridge { return .object(["databases": .array(databases.map { .string($0) })]) } - func listSchemas(connectionId: UUID) async throws -> JsonValue { - let (driver, _) = try await resolveDriver(connectionId) - let schemas = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + func listSchemas(scope: DatabaseScope) async throws -> JsonValue { + try await ensureConnected(scope.connectionId) + let schemas = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in try await driver.fetchSchemas() } return .object(["schemas": .array(schemas.map { .string($0) })]) } - func getTableDDL(connectionId: UUID, table: String, schema: String?) async throws -> JsonValue { - let (driver, _) = try await resolveDriver(connectionId) - let ddl = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + func getTableDDL(scope: DatabaseScope, table: String) async throws -> JsonValue { + try await ensureConnected(scope.connectionId) + let ddl = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in try await driver.fetchTableDDL(table: table) } return .object(["ddl": .string(ddl)]) @@ -355,44 +378,52 @@ public actor MCPConnectionBridge { ]) } + /// A resource URI names no database, so the schema resource reports the connection's + /// browse scope. Reading it off the shared driver instead would report whichever + /// database a tab last executed against. func fetchSchemaResource(connectionId: UUID) async throws -> JsonValue { + try await ensureConnected(connectionId) + let cachedTables = await MainActor.run { SchemaService.shared.tables(for: connectionId) } - let (driver, _) = try await resolveDriver(connectionId) - let tables: [TableInfo] if !cachedTables.isEmpty { tables = cachedTables } else { - tables = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + tables = try await DatabaseManager.shared.withBrowseMetadataDriver( + connectionId: connectionId, + workload: .bulk + ) { driver in try await driver.fetchTables() } } let limitedTables = Array(tables.prefix(100)) - var tableSchemas: [JsonValue] = [] - for table in limitedTables { - let columns = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { - try await driver.fetchColumns(table: table.name) - } - - let jsonCols: [JsonValue] = columns.map { col in - .object([ - "name": .string(col.name), - "data_type": .string(col.dataType), - "is_nullable": .bool(col.isNullable), - "is_primary_key": .bool(col.isPrimaryKey) - ]) + let tableSchemas: [JsonValue] = try await DatabaseManager.shared.withBrowseMetadataDriver( + connectionId: connectionId, + workload: .bulk + ) { driver in + var schemas: [JsonValue] = [] + for table in limitedTables { + let columns = try await driver.fetchColumns(table: table.name) + let jsonCols: [JsonValue] = columns.map { col in + .object([ + "name": .string(col.name), + "data_type": .string(col.dataType), + "is_nullable": .bool(col.isNullable), + "is_primary_key": .bool(col.isPrimaryKey) + ]) + } + schemas.append(.object([ + "name": .string(table.name), + "type": .string(table.type.rawValue), + "columns": .array(jsonCols) + ])) } - - tableSchemas.append(.object([ - "name": .string(table.name), - "type": .string(table.type.rawValue), - "columns": .array(jsonCols) - ])) + return schemas } var result: [String: JsonValue] = ["tables": .array(tableSchemas)] @@ -465,6 +496,12 @@ public actor MCPConnectionBridge { } } + @discardableResult + private func ensureConnected(_ connectionId: UUID) async throws -> DatabaseType { + let (_, databaseType) = try await resolveDriver(connectionId) + return databaseType + } + private func connectIfNeeded(_ connection: DatabaseConnection) async throws { try await DatabaseManager.shared.ensureConnected(connection) } diff --git a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift index 650810fd1..27583eacc 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ConfirmDestructiveOperationTool.swift @@ -84,11 +84,15 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation { Self.logger.debug("confirm_destructive_operation invoked for connection \(connectionId.uuidString, privacy: .public)") + let scope = try await services.connectionBridge.resolveScope( + connectionId: connectionId, + database: nil, + schema: nil + ) let result = try await ToolQueryExecutor.executeAndLog( services: services, query: query, - connectionId: connectionId, - databaseName: meta.databaseName, + scope: scope, maxRows: 0, timeoutSeconds: timeoutSeconds, principalLabel: context.principal.metadata.label diff --git a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift index c034c6a57..61fb96489 100644 --- a/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/DescribeTableTool.swift @@ -25,6 +25,10 @@ public struct DescribeTableTool: MCPToolImplementation { "type": .string("string"), "description": .string(String(localized: "Table name")) ]), + "database": .object([ + "type": .string("string"), + "description": .string(String(localized: "Database name (uses current if omitted)")) + ]), "schema": .object([ "type": .string("string"), "description": .string(String(localized: "Schema name (uses current if omitted)")) @@ -42,13 +46,15 @@ public struct DescribeTableTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let table = try MCPArgumentDecoder.requireString(arguments, key: "table") + let database = MCPArgumentDecoder.optionalString(arguments, key: "database") let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") - let payload = try await services.connectionBridge.describeTable( + let scope = try await services.connectionBridge.resolveScope( connectionId: connectionId, - table: table, + database: database, schema: schema ) + let payload = try await services.connectionBridge.describeTable(scope: scope, table: table) return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift index 054fe0fef..da5d6479e 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift @@ -27,11 +27,11 @@ public struct ExecuteQueryTool: MCPToolImplementation { ]), "database": .object([ "type": .string("string"), - "description": .string(String(localized: "Switch to this database before executing")) + "description": .string(String(localized: "Run against this database (uses current if omitted)")) ]), "schema": .object([ "type": .string("string"), - "description": .string(String(localized: "Switch to this schema before executing")) + "description": .string(String(localized: "Run against this schema (uses current if omitted)")) ]) ]), "required": .array([.string("connection_id"), .string("query")]) @@ -84,18 +84,11 @@ public struct ExecuteQueryTool: MCPToolImplementation { ) } - if let database { - _ = try await services.connectionBridge.switchDatabase( - connectionId: connectionId, - database: database - ) - } - if let schema { - _ = try await services.connectionBridge.switchSchema( - connectionId: connectionId, - schema: schema - ) - } + let scope = try await services.connectionBridge.resolveScope( + connectionId: connectionId, + database: database, + schema: schema + ) try await throwIfCancelled(context) await context.progress.emit(progress: 0.2, total: 1.0, message: "Executing") @@ -122,8 +115,7 @@ public struct ExecuteQueryTool: MCPToolImplementation { let result = try await ToolQueryExecutor.executeAndLog( services: services, query: query, - connectionId: connectionId, - databaseName: meta.databaseName, + scope: scope, maxRows: maxRows, timeoutSeconds: timeoutSeconds, principalLabel: context.principal.metadata.label diff --git a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift index 37fa1c534..d579bb84f 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift @@ -123,12 +123,17 @@ public struct ExportDataTool: MCPToolImplementation { var totalRowsExported = 0 var anyTruncated = false + let scope = try await services.connectionBridge.resolveScope( + connectionId: connectionId, + database: nil, + schema: nil + ) + for (label, sql) in queries { let result = try await ToolQueryExecutor.executeAndLog( services: services, query: sql, - connectionId: connectionId, - databaseName: meta.databaseName, + scope: scope, maxRows: fetchLimit, timeoutSeconds: timeoutSeconds, principalLabel: context.principal.metadata.label diff --git a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift index 6f396b7dd..e7ce503f6 100644 --- a/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/GetTableDdlTool.swift @@ -23,6 +23,10 @@ public struct GetTableDdlTool: MCPToolImplementation { "type": .string("string"), "description": .string(String(localized: "Table name")) ]), + "database": .object([ + "type": .string("string"), + "description": .string(String(localized: "Database name (uses current if omitted)")) + ]), "schema": .object([ "type": .string("string"), "description": .string(String(localized: "Schema name (uses current if omitted)")) @@ -40,13 +44,15 @@ public struct GetTableDdlTool: MCPToolImplementation { ) async throws -> MCPToolCallResult { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let table = try MCPArgumentDecoder.requireString(arguments, key: "table") + let database = MCPArgumentDecoder.optionalString(arguments, key: "database") let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") - let payload = try await services.connectionBridge.getTableDDL( + let scope = try await services.connectionBridge.resolveScope( connectionId: connectionId, - table: table, + database: database, schema: schema ) + let payload = try await services.connectionBridge.getTableDDL(scope: scope, table: table) return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift index bb0e98606..2fd2a8663 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListSchemasTool.swift @@ -37,11 +37,12 @@ public struct ListSchemasTool: MCPToolImplementation { let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id") let database = MCPArgumentDecoder.optionalString(arguments, key: "database") - if let database { - _ = try await services.connectionBridge.switchDatabase(connectionId: connectionId, database: database) - } - - let payload = try await services.connectionBridge.listSchemas(connectionId: connectionId) + let scope = try await services.connectionBridge.resolveScope( + connectionId: connectionId, + database: database, + schema: nil + ) + let payload = try await services.connectionBridge.listSchemas(scope: scope) return .structured(payload) } } diff --git a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift index 7dd404e34..dd5fa06ec 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ListTablesTool.swift @@ -47,15 +47,13 @@ public struct ListTablesTool: MCPToolImplementation { let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema") let includeRowCounts = MCPArgumentDecoder.optionalBool(arguments, key: "include_row_counts", default: false) - if let database { - _ = try await services.connectionBridge.switchDatabase(connectionId: connectionId, database: database) - } - if let schema { - _ = try await services.connectionBridge.switchSchema(connectionId: connectionId, schema: schema) - } - - let payload = try await services.connectionBridge.listTables( + let scope = try await services.connectionBridge.resolveScope( connectionId: connectionId, + database: database, + schema: schema + ) + let payload = try await services.connectionBridge.listTables( + scope: scope, includeRowCounts: includeRowCounts ) return .structured(payload) diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift index cbd91945e..aa0d3ed4c 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolConnectionMetadata.swift @@ -12,7 +12,7 @@ struct ToolConnectionMetadata { return ToolConnectionMetadata( databaseType: session.connection.type, safeModeLevel: session.safeModeLevel, - databaseName: session.activeDatabase + databaseName: session.resolvedBrowseDatabase ) case .stored(let conn): return ToolConnectionMetadata( diff --git a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift index 55d399e08..dc9f99977 100644 --- a/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift +++ b/TablePro/Core/MCP/Protocol/Tools/ToolQueryExecutor.swift @@ -4,16 +4,17 @@ enum ToolQueryExecutor { static func executeAndLog( services: MCPToolServices, query: String, - connectionId: UUID, - databaseName: String, + scope: DatabaseScope, maxRows: Int, timeoutSeconds: Int, principalLabel: String? ) async throws -> JsonValue { + let connectionId = scope.connectionId + let databaseName = scope.database let startTime = Date() do { let result = try await services.connectionBridge.executeQuery( - connectionId: connectionId, + scope: scope, query: query, maxRows: maxRows, timeoutSeconds: timeoutSeconds diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 26f473ab0..d3b20cc35 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -20,23 +20,28 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable self.databaseTypeId = databaseType.rawValue } + private var pluginDriver: (any PluginDatabaseDriver)? { + (driver as? PluginDriverAdapter)?.schemaPluginDriver + } + func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { + guard let pluginDriver else { + return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } + } let query: String - if let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver, - let customQuery = pluginDriver.defaultExportQuery(table: table, schema: databaseName.isEmpty ? nil : databaseName) { + if let customQuery = pluginDriver.defaultExportQuery(table: table, schema: exportSchema(for: databaseName)) { query = customQuery } else { - let tableRef = qualifiedTableRef(table: table, databaseName: databaseName) - query = "SELECT * FROM \(tableRef)" - } - guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - return AsyncThrowingStream { $0.finish(throwing: PluginExportError.exportFailed("No plugin driver available")) } + query = "SELECT * FROM \(qualifiedTableRef(table: table, databaseName: databaseName))" } return pluginDriver.streamRows(query: query) } func fetchTableDDL(table: String, databaseName: String) async throws -> String { - try await driver.fetchTableDDL(table: table) + guard let pluginDriver else { + return try await driver.fetchTableDDL(table: table) + } + return try await pluginDriver.fetchTableDDL(table: table, schema: exportSchema(for: databaseName)) } func execute(query: String) async throws -> PluginQueryResult { @@ -53,49 +58,72 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable } func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { - try await driver.fetchApproximateRowCount(table: table) + guard let pluginDriver else { + return try await driver.fetchApproximateRowCount(table: table) + } + return try await pluginDriver.fetchApproximateRowCount( + table: table, + schema: exportSchema(for: databaseName) + ) } func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] { - let sequences = try await driver.fetchDependentSequences(forTable: table) + let sequences: [(name: String, ddl: String)] + if let pluginDriver { + sequences = try await pluginDriver.fetchDependentSequences( + table: table, + schema: exportSchema(for: databaseName) + ) + } else { + sequences = try await driver.fetchDependentSequences(forTable: table) + } return sequences.map { PluginSequenceInfo(name: $0.name, ddl: $0.ddl) } } func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] { - let types = try await driver.fetchDependentTypes(forTable: table) + let types: [(name: String, labels: [String])] + if let pluginDriver { + types = try await pluginDriver.fetchDependentTypes( + table: table, + schema: exportSchema(for: databaseName) + ) + } else { + types = try await driver.fetchDependentTypes(forTable: table) + } return types.map { PluginEnumTypeInfo(name: $0.name, labels: $0.labels) } } func fetchColumns(table: String, databaseName: String) async throws -> [PluginColumnInfo] { - guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - return [] - } - return try await pluginDriver.fetchColumns(table: table, schema: pluginDriver.currentSchema) + guard let pluginDriver else { return [] } + return try await pluginDriver.fetchColumns(table: table, schema: exportSchema(for: databaseName)) } func fetchAllColumns(databaseName: String) async throws -> [String: [PluginColumnInfo]] { - guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - return [:] - } - return try await pluginDriver.fetchAllColumns(schema: pluginDriver.currentSchema) + guard let pluginDriver else { return [:] } + return try await pluginDriver.fetchAllColumns(schema: exportSchema(for: databaseName)) } func fetchForeignKeys(table: String, databaseName: String) async throws -> [PluginForeignKeyInfo] { - guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - return [] - } - return try await pluginDriver.fetchForeignKeys(table: table, schema: pluginDriver.currentSchema) + guard let pluginDriver else { return [] } + return try await pluginDriver.fetchForeignKeys(table: table, schema: exportSchema(for: databaseName)) } func fetchAllForeignKeys(databaseName: String) async throws -> [String: [PluginForeignKeyInfo]] { - guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else { - return [:] - } - return try await pluginDriver.fetchAllForeignKeys(schema: pluginDriver.currentSchema) + guard let pluginDriver else { return [:] } + return try await pluginDriver.fetchAllForeignKeys(schema: exportSchema(for: databaseName)) } // MARK: - Helpers + /// The export tree names every group after a schema on a schema-aware engine and after a + /// database everywhere else, so only a schema-aware driver can read that name as its + /// schema. An empty name means the table sits in the driver's own container. + private func exportSchema(for databaseName: String) -> String? { + guard let pluginDriver else { return nil } + guard pluginDriver.supportsSchemas, !databaseName.isEmpty else { return pluginDriver.currentSchema } + return databaseName + } + private func qualifiedTableRef(table: String, databaseName: String) -> String { if databaseName.isEmpty { return driver.quoteIdentifier(table) diff --git a/TablePro/Core/Services/Export/ImportService.swift b/TablePro/Core/Services/Export/ImportService.swift index 00edec5e4..c82afb55f 100644 --- a/TablePro/Core/Services/Export/ImportService.swift +++ b/TablePro/Core/Services/Export/ImportService.swift @@ -136,7 +136,7 @@ final class ImportService { QueryHistoryManager.shared.recordQuery( query: "-- Import from \(url.lastPathComponent) (\(progress.processedStatements) statements before failure)", connectionId: connection.id, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), executionTime: 0, rowCount: progress.processedStatements, wasSuccessful: false, @@ -154,7 +154,7 @@ final class ImportService { QueryHistoryManager.shared.recordQuery( query: "-- Import from \(url.lastPathComponent) (\(result.executedStatements) statements)", connectionId: connection.id, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), executionTime: result.executionTime, rowCount: result.executedStatements, wasSuccessful: true, diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 734af6c54..abf9bb62b 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -87,7 +87,7 @@ enum SessionStateFactory { toolbarSt.currentDatabase = String(dbIndex) } - let activeDatabaseName = DatabaseManager.shared.activeDatabaseName(for: connection) + let browseDatabaseName = DatabaseManager.shared.browseDatabaseName(for: connection) if let payload { switch payload.intent { @@ -104,7 +104,7 @@ enum SessionStateFactory { try tabMgr.addPreviewTableTab( tableName: tableName, databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName, schemaName: resolvedSchemaName, isView: payload.isView ) @@ -112,7 +112,7 @@ enum SessionStateFactory { try tabMgr.addTableTab( tableName: tableName, databaseType: connection.type, - databaseName: payload.databaseName ?? activeDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName, schemaName: resolvedSchemaName, isView: payload.isView ) @@ -132,7 +132,7 @@ enum SessionStateFactory { } } } else { - tabMgr.addTab(databaseName: payload.databaseName ?? activeDatabaseName) + tabMgr.addTab(databaseName: payload.databaseName ?? browseDatabaseName) } case .query: let hasContent = payload.initialQuery != nil @@ -142,19 +142,19 @@ enum SessionStateFactory { tabMgr.addTab( initialQuery: payload.initialQuery, title: payload.tabTitle, - databaseName: payload.databaseName ?? activeDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName, sourceFileURL: payload.sourceFileURL, claimFocus: true ) } case .createTable: tabMgr.addCreateTableTab( - databaseName: payload.databaseName ?? activeDatabaseName + databaseName: payload.databaseName ?? browseDatabaseName ) case .erDiagram: tabMgr.addERDiagramTab( - schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? activeDatabaseName, - databaseName: payload.databaseName ?? activeDatabaseName + schemaKey: payload.erDiagramSchemaKey ?? payload.databaseName ?? browseDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName ) case .serverDashboard: tabMgr.addServerDashboardTab() @@ -167,7 +167,7 @@ enum SessionStateFactory { tabMgr.addTab( initialQuery: payload.initialQuery, title: title, - databaseName: payload.databaseName ?? activeDatabaseName, + databaseName: payload.databaseName ?? browseDatabaseName, claimFocus: true ) case .restoreOrDefault: diff --git a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift index a3c025d42..4786b03b3 100644 --- a/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift +++ b/TablePro/Core/Services/Infrastructure/TabPersistenceCoordinator.swift @@ -82,7 +82,7 @@ internal final class TabPersistenceCoordinator { private func currentActiveDatabaseAndSchema() -> (database: String?, schema: String?) { guard let session = DatabaseManager.shared.session(for: connectionId) else { return (nil, nil) } - return (session.currentDatabase, session.currentSchema) + return (session.browseDatabase, session.browseSchema) } // MARK: - Clear diff --git a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index c653bb023..5e44ae259 100644 --- a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -46,9 +46,7 @@ enum WindowTitleResolver { } static func resolveSubtitle(payload: EditorTabPayload?, connection: DatabaseConnection) -> String { - tableSubtitle( - isTable: payload?.tabType == .table, - tableName: payload?.tableName, + bindingSubtitle( databaseName: payload?.databaseName ?? "", schemaName: payload?.schemaName, fallback: connection.name @@ -56,9 +54,7 @@ enum WindowTitleResolver { } static func resolveSubtitle(tab: QueryTab?, connection: DatabaseConnection) -> String { - tableSubtitle( - isTable: tab?.tabType == .table, - tableName: tab?.tableContext.tableName, + bindingSubtitle( databaseName: tab?.tableContext.databaseName ?? "", schemaName: tab?.tableContext.schemaName, fallback: connection.name @@ -107,14 +103,15 @@ enum WindowTitleResolver { return fallbackTitle } - private static func tableSubtitle( - isTable: Bool, - tableName: String?, + /// Every tab owns a database for its whole life, so the subtitle names that binding + /// whatever the tab holds. Only a tab with no binding at all falls back to the + /// connection, and a blank value counts as no binding at every tier. + private static func bindingSubtitle( databaseName: String, schemaName: String?, fallback: String ) -> String { - guard isTable, let tableName, !tableName.isBlank, !databaseName.isBlank else { return fallback } + guard !databaseName.isBlank else { return fallback } if let schemaName, !schemaName.isBlank { return "\(databaseName) ยท \(schemaName)" } diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index 72a432b6a..e09e3ac68 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -177,9 +177,9 @@ final class DatabaseTreeMetadataService { do { let list = try await routinesDedup.execute(key: key) { [self] in try await MetadataConnectionPool.shared.withDriver( - connectionId: connectionId, - database: database, - schema: normalizedSchema, + scope: DatabaseScope( + connectionId: connectionId, database: database, schema: normalizedSchema + ), workload: .bulk ) { driver in let procedures = try await driver.fetchProcedures(schema: normalizedSchema) @@ -387,20 +387,20 @@ final class DatabaseTreeMetadataService { DatabaseManager.shared.session(for: connectionId)?.status == .connected } + /// Always routes through a scoped driver. Reusing the session driver when the target + /// looked like the browsed database used to be safe; it is not now that a tab's + /// execution moves that driver without writing session state. private func withDriver( connectionId: UUID, database: String?, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { - let session = DatabaseManager.shared.session(for: connectionId) - let usesPrimary = database == nil || database == session?.activeDatabase - if usesPrimary, let driver = session?.driver, driver.status == .connected { - return try await body(driver) + guard let scope = DatabaseManager.shared.resolvedScope( + database: database, schema: nil, for: connectionId + ) else { + throw DatabaseError.notConnected } - guard let database else { throw DatabaseError.notConnected } - return try await MetadataConnectionPool.shared.withDriver( - connectionId: connectionId, database: database, body - ) + return try await DatabaseManager.shared.withMetadataDriver(scope: scope, body) } private static func objectsKey(connectionId: UUID, database: String, schema: String?) -> ObjectsKey { diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 6787c0929..ab01c27c4 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -16,29 +16,28 @@ final class MetadataConnectionPool { } private struct Key: Hashable, Sendable { - let connectionId: UUID - let database: String - let schema: String? + let scope: DatabaseScope let workload: Workload } @MainActor private final class Entry { let driver: DatabaseDriver - let ownsDriver: Bool var lastUsed: Date var inFlightCount: Int var closeWhenIdle: Bool private var tail: Task = Task {} - init(driver: DatabaseDriver, ownsDriver: Bool = true) { + init(driver: DatabaseDriver) { self.driver = driver - self.ownsDriver = ownsDriver self.lastUsed = Date() self.inFlightCount = 0 self.closeWhenIdle = false } + /// The work runs in its own task so the next caller can queue behind it, so + /// cancelling the caller has to be forwarded explicitly or a stopped query + /// would keep running with nobody waiting on it. func runSerially( _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { @@ -46,10 +45,14 @@ final class MetadataConnectionPool { let driver = self.driver let work = Task { @MainActor () async throws -> T in await previous.value + try Task.checkCancellation() return try await body(driver) } tail = Task { @MainActor in _ = try? await work.value } - return try await work.value + return try await withTaskCancellationHandler( + operation: { try await work.value }, + onCancel: { work.cancel() } + ) } } @@ -61,15 +64,11 @@ final class MetadataConnectionPool { private init() {} func withDriver( - connectionId: UUID, - database: String, - schema: String? = nil, + scope: DatabaseScope, workload: Workload = .interactive, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { - let entry = try await acquireEntry( - connectionId: connectionId, database: database, schema: schema, workload: workload - ) + let entry = try await acquireEntry(scope: scope, workload: workload) entry.inFlightCount += 1 entry.lastUsed = Date() defer { releaseEntry(entry) } @@ -77,25 +76,24 @@ final class MetadataConnectionPool { } func closeAll(connectionId: UUID) { - for key in pending.keys where key.connectionId == connectionId { + for key in pending.keys where key.scope.connectionId == connectionId { pending[key]?.cancel() pending.removeValue(forKey: key) } - for key in entries.keys where key.connectionId == connectionId { + for key in entries.keys where key.scope.connectionId == connectionId { closeOrDeferEntry(forKey: key) } } private func releaseEntry(_ entry: Entry) { entry.inFlightCount -= 1 - if entry.inFlightCount == 0, entry.closeWhenIdle, entry.ownsDriver { + if entry.inFlightCount == 0, entry.closeWhenIdle { entry.driver.disconnect() } } private func closeOrDeferEntry(forKey key: Key) { guard let entry = entries.removeValue(forKey: key) else { return } - guard entry.ownsDriver else { return } if entry.inFlightCount == 0 { entry.driver.disconnect() } else { @@ -103,21 +101,9 @@ final class MetadataConnectionPool { } } - private func acquireEntry( - connectionId: UUID, - database: String, - schema: String?, - workload: Workload - ) async throws -> Entry { - if let session = DatabaseManager.shared.session(for: connectionId), - session.connection.type.supportsConnectionPooling == false { - guard let driver = session.driver, driver.status == .connected else { - throw DatabaseError.notConnected - } - return Entry(driver: driver, ownsDriver: false) - } - - let key = Key(connectionId: connectionId, database: database, schema: schema, workload: workload) + private func acquireEntry(scope: DatabaseScope, workload: Workload) async throws -> Entry { + let connectionId = scope.connectionId + let key = Key(scope: scope, workload: workload) if let entry = entries[key], entry.driver.status == .connected { return entry } @@ -151,13 +137,13 @@ final class MetadataConnectionPool { } private func openEntry(key: Key) async throws -> Entry { - guard let session = DatabaseManager.shared.session(for: key.connectionId) else { + guard let session = DatabaseManager.shared.session(for: key.scope.connectionId) else { throw DatabaseError.notConnected } var connection = session.effectiveConnection ?? session.connection let plan = Self.planConnection( configuredDatabase: connection.database, - targetDatabase: key.database, + targetDatabase: key.scope.database, authenticationIsDatabaseScoped: connection.type.authenticationIsDatabaseScoped ) connection.database = plan.connectDatabase @@ -176,7 +162,7 @@ final class MetadataConnectionPool { if let database = plan.switchDatabase { try await Self.switchDatabase(driver, to: database, timeoutSeconds: operationTimeoutSeconds) } - if let schema = key.schema { + if let schema = key.scope.schema { try await Self.switchSchema(driver, to: schema, timeoutSeconds: operationTimeoutSeconds) } } catch { @@ -256,8 +242,8 @@ final class MetadataConnectionPool { } private func evictIdleIfNeeded(for connectionId: UUID) { - let live = entries.filter { $0.key.connectionId == connectionId } - let pendingCount = pending.keys.filter { $0.connectionId == connectionId }.count + let live = entries.filter { $0.key.scope.connectionId == connectionId } + let pendingCount = pending.keys.filter { $0.scope.connectionId == connectionId }.count guard live.count + pendingCount >= maxPerConnection else { return } let oldestIdle = live .filter { $0.value.inFlightCount == 0 } diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 014c27c36..38ec27c4f 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -41,24 +41,17 @@ final class QueryExecutor { self.connection = connection } - // MARK: - Driver access - - private func resolveDriver() throws -> DatabaseDriver { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected - } - return driver - } - // MARK: - Public orchestrators + /// The driver is supplied by the caller, which resolved it from the tab's scope. + /// Looking it up here would tie every query to whichever database the connection + /// happens to be on. func executeQuery( + driver: DatabaseDriver, sql: String, parameters: [Any?]? = nil, rowCap: Int? ) async throws -> QueryFetchResult { - let driver = try resolveDriver() - if let parameters { return try await Self.fetchQueryDataParameterized( driver: driver, @@ -123,28 +116,27 @@ final class QueryExecutor { // MARK: - Schema fetch + parse - static func fetchTableSchema(connectionId: UUID, tableName: String) async throws -> FetchedTableSchema { - let session = DatabaseManager.shared.session(for: connectionId) + static func fetchTableSchema(scope: DatabaseScope, tableName: String) async throws -> FetchedTableSchema { queryExecutorLog.info( - "[fk] schema fetch start table=\(tableName, privacy: .public) db=\(session?.currentDatabase ?? "default", privacy: .public) schema=\(session?.currentSchema ?? "default", privacy: .public)" + "[fk] schema fetch start table=\(tableName, privacy: .public) db=\(scope.database, privacy: .public) schema=\(scope.schema ?? "default", privacy: .public)" ) let (columns, approximateRowCount) = try await DatabaseManager.shared.withMetadataDriver( - connectionId: connectionId + scope: scope ) { driver in let columns = try await driver.fetchColumns(table: tableName) let approximateRowCount = try? await driver.fetchApproximateRowCount(table: tableName) return (columns, approximateRowCount) } - let foreignKeys = await fetchForeignKeys(connectionId: connectionId, tableName: tableName) + let foreignKeys = await fetchForeignKeys(scope: scope, tableName: tableName) queryExecutorLog.info( "[fk] schema fetch done table=\(tableName, privacy: .public) columns=\(columns.count) fks=\(foreignKeys.map { String($0.count) } ?? "failed", privacy: .public)" ) return FetchedTableSchema(columns: columns, foreignKeys: foreignKeys, approximateRowCount: approximateRowCount) } - private static func fetchForeignKeys(connectionId: UUID, tableName: String) async -> [ForeignKeyInfo]? { + private static func fetchForeignKeys(scope: DatabaseScope, tableName: String) async -> [ForeignKeyInfo]? { do { - return try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + return try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in try await driver.fetchForeignKeys(table: tableName) } } catch { diff --git a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift index e99bc93fd..e0b4c94a2 100644 --- a/TablePro/Core/Services/Query/SchemaProviderRegistry.swift +++ b/TablePro/Core/Services/Query/SchemaProviderRegistry.swift @@ -34,8 +34,8 @@ final class SchemaProviderRegistry { private func subscribeToRefreshSignal() { AppCommands.shared.refreshData - .sink { [weak self] connectionId in - self?.invalidateColumnCache(for: connectionId) + .sink { [weak self] request in + self?.invalidateColumnCache(for: request.connectionId) } .store(in: &cancellables) } @@ -59,7 +59,7 @@ final class SchemaProviderRegistry { } let source = SQLSchemaProvider.ColumnMetadataSource( fetchColumns: { table, schema in - try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in if let schema { return try await driver.fetchColumns(table: table, schema: schema) } @@ -67,12 +67,12 @@ final class SchemaProviderRegistry { } }, fetchAllColumns: { - try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in try await driver.fetchAllColumns() } }, fetchSchemaTables: { schema in - try await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId) { driver in + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchTables(schema: schema) } } diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 2f0685e07..244aa16c9 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -26,7 +26,7 @@ final class SchemaRefreshService { private let treeMetadataService: DatabaseTreeMetadataService private let providerRegistry: SchemaProviderRegistry private let pluginManager: PluginManager - private let metadataDriverProvider: any MetadataDriverProviding + private let metadataDriverProvider: any ScopedMetadataProviding private let databaseManager: DatabaseManager? private var inFlight: [RefreshKey: Task] = [:] @@ -37,7 +37,7 @@ final class SchemaRefreshService { treeMetadataService: DatabaseTreeMetadataService = .shared, providerRegistry: SchemaProviderRegistry = .shared, pluginManager: PluginManager = .shared, - metadataDriverProvider: any MetadataDriverProviding = DatabaseManager.shared, + metadataDriverProvider: any ScopedMetadataProviding = DatabaseManager.shared, databaseManager: DatabaseManager? = .shared ) { self.schemaService = schemaService @@ -69,22 +69,24 @@ final class SchemaRefreshService { inFlight.removeValue(forKey: key) } - /// Push the loaded table list into the autocomplete provider. Called after every - /// refresh and after the initial per-window schema load. + /// Push the loaded table list into the autocomplete provider. + /// + /// The provider caches the driver it is handed and fetches columns from it later, so it + /// must get one scoped to the browsed database rather than the shared session driver, + /// which a tab's execution moves without writing session state. func syncAutocompleteProvider(connectionId: UUID) async { guard case .loaded = schemaService.state(for: connectionId), - let driver = databaseManager?.driver(for: connectionId), - let provider = providerRegistry.provider(for: connectionId) else { return } - let currentDatabase = databaseManager?.session(for: connectionId)?.activeDatabase - await provider.resetForDatabase( - currentDatabase, - tables: schemaService.allLoadedTables(for: connectionId), - driver: driver - ) - await provider.setNamespaces( - schemas: schemaService.schemas(for: connectionId), - databases: currentDatabase.map { [$0] } ?? [] - ) + let provider = providerRegistry.provider(for: connectionId), + let browseDatabase = databaseManager?.browseScope(for: connectionId)?.database + else { + return + } + let tables = schemaService.allLoadedTables(for: connectionId) + let schemas = schemaService.schemas(for: connectionId) + try? await databaseManager?.withBrowseMetadataDriver(connectionId: connectionId) { driver in + await provider.resetForDatabase(browseDatabase, tables: tables, driver: driver) + await provider.setNamespaces(schemas: schemas, databases: [browseDatabase]) + } } private func refreshForSchemaSwitch(connectionId: UUID) async { @@ -100,7 +102,7 @@ final class SchemaRefreshService { } do { - try await metadataDriverProvider.withMetadataDriver( + try await metadataDriverProvider.withBrowseMetadataDriver( connectionId: connectionId, workload: .bulk ) { [schemaService] driver in diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index a867c6965..112ac1c1a 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -26,8 +26,12 @@ struct ConnectionSession: Identifiable { var pendingTruncates: Set = [] var pendingDeletes: Set = [] var tableOperationOptions: [String: TableOperationOptions] = [:] - var currentSchema: String? - var currentDatabase: String? + /// Where the user is browsing: what the sidebar lists and where a new tab opens. + /// It is not where an open tab queries. A tab carries its own database and schema, + /// and resolving an operation through these instead is how a tab ends up running + /// against another database. + var browseSchema: String? + var browseDatabase: String? @MainActor var tables: [TableInfo] { @@ -37,8 +41,8 @@ struct ConnectionSession: Identifiable { /// In-memory password for prompt-for-password connections. Never persisted to disk. var cachedPassword: String? - var activeDatabase: String { - currentDatabase ?? connection.database + var resolvedBrowseDatabase: String { + browseDatabase ?? connection.database } // Metadata @@ -82,8 +86,8 @@ struct ConnectionSession: Identifiable { /// database/schema desired state that `clearCachedData()` preserves for reconnect. mutating func clearAllState() { clearCachedData() - currentDatabase = nil - currentSchema = nil + browseDatabase = nil + browseSchema = nil } /// Compares fields used by ContentView's body to avoid unnecessary SwiftUI re-renders. @@ -97,7 +101,7 @@ struct ConnectionSession: Identifiable { && pendingTruncates == other.pendingTruncates && pendingDeletes == other.pendingDeletes && tableOperationOptions == other.tableOperationOptions - && currentSchema == other.currentSchema - && currentDatabase == other.currentDatabase + && browseSchema == other.browseSchema + && browseDatabase == other.browseDatabase } } diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index 3f75983e4..1811c181c 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -255,7 +255,7 @@ final class ConnectionToolbarState { if PluginManager.shared.connectionMode(for: connection.type) == .fileBased { resolvedDatabase = (connection.database as NSString).lastPathComponent } else if let session = DatabaseManager.shared.session(for: connection.id), - let database = session.currentDatabase { + let database = session.browseDatabase { resolvedDatabase = database } else { resolvedDatabase = connection.database @@ -264,7 +264,7 @@ final class ConnectionToolbarState { currentDatabase = resolvedDatabase } - let resolvedSchema = DatabaseManager.shared.session(for: connection.id)?.currentSchema + let resolvedSchema = DatabaseManager.shared.session(for: connection.id)?.browseSchema if currentSchema != resolvedSchema { currentSchema = resolvedSchema } diff --git a/TablePro/Models/Connection/DatabaseScope.swift b/TablePro/Models/Connection/DatabaseScope.swift new file mode 100644 index 000000000..f0c7e93e8 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseScope.swift @@ -0,0 +1,31 @@ +// +// DatabaseScope.swift +// TablePro +// + +import Foundation + +/// The full target of a database operation: which connection, which database, which schema. +/// A connection id alone cannot address an operation, because one connection reaches many +/// databases and a tab outlives the sidebar's current selection. +struct DatabaseScope: Hashable, Sendable { + let connectionId: UUID + let database: String + let schema: String? + + init(connectionId: UUID, database: String, schema: String?) { + self.connectionId = connectionId + self.database = database + self.schema = schema.flatMap { $0.isEmpty ? nil : $0 } + } + + /// A connection can legitimately have no database selected, so an empty database means + /// "the server", not "unbound". Server-scoped work runs on the session driver with no + /// pin, which is what a connection in that state did before it had a scope at all. + var isServerScoped: Bool { database.isEmpty } + + var qualifiedDescription: String { + guard let schema else { return database } + return "\(database).\(schema)" + } +} diff --git a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift index 8906ad01b..18d214b49 100644 --- a/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift +++ b/TablePro/ViewModels/AIChatViewModel+SchemaContext.swift @@ -34,7 +34,7 @@ extension AIChatViewModel { let task: Task = Task { [weak self] in let columns: [ColumnInfo] do { - columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in + columns = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId) { driver in try await driver.fetchColumns(table: tableName) } } catch { @@ -43,7 +43,7 @@ extension AIChatViewModel { } let fkMap: [String: [ForeignKeyInfo]] do { - fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId) { driver in + fkMap = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId) { driver in try await driver.fetchForeignKeys(forTables: [tableName]) } } catch { @@ -107,7 +107,7 @@ extension AIChatViewModel { let name = table.name group.addTask { do { - let cols = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in + let cols = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId, workload: .bulk) { driver in try await driver.fetchColumns(table: name) } return (name, cols) @@ -127,7 +127,7 @@ extension AIChatViewModel { let needsFKFetch = tablesToFetch.contains { foreignKeysByTable[$0.name] == nil } guard needsFKFetch else { return } do { - let fkMap = try await DatabaseManager.shared.withMetadataDriver(connectionId: connId, workload: .bulk) { driver in + let fkMap = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connId, workload: .bulk) { driver in try await driver.fetchForeignKeys(forTables: tablesToFetch.map(\.name)) } for (name, fks) in fkMap { @@ -142,7 +142,7 @@ extension AIChatViewModel { guard let connection else { return nil } return PromptContext( databaseType: connection.type, - databaseName: services.databaseManager.activeDatabaseName(for: connection), + databaseName: services.databaseManager.browseDatabaseName(for: connection), tables: tables, columnsByTable: columnsByTable, foreignKeys: foreignKeysByTable, diff --git a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index 8c59f6b31..b2541d82a 100644 --- a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -69,7 +69,7 @@ extension AIChatViewModel { let renderingContext = CustomSlashCommandRenderer.Context( query: currentQuery, schema: needsSchema ? renderedSchemaSection() : nil, - database: connection.flatMap { services.databaseManager.activeDatabaseName(for: $0) }, + database: connection.flatMap { services.databaseManager.browseDatabaseName(for: $0) }, body: body ) let prompt = CustomSlashCommandRenderer.render(command, context: renderingContext) diff --git a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift index 5fe3270b4..c029d790a 100644 --- a/TablePro/ViewModels/DatabaseSwitcherViewModel.swift +++ b/TablePro/ViewModels/DatabaseSwitcherViewModel.swift @@ -75,7 +75,7 @@ final class DatabaseSwitcherViewModel { do { let target = switchTarget - let names = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + let names = try await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { driver in switch target { case .database: try await driver.fetchDatabases() case .schema: try await driver.fetchSchemas() @@ -90,7 +90,7 @@ final class DatabaseSwitcherViewModel { isLoading = false guard switchTarget == .database else { return } do { - let metadataList = try await services.databaseManager.withMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in + let metadataList = try await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId, workload: .bulk) { driver in try await driver.fetchAllDatabaseMetadata() } databases = metadataList.sorted { $0.name < $1.name } diff --git a/TablePro/ViewModels/ERDiagramViewModel.swift b/TablePro/ViewModels/ERDiagramViewModel.swift index 464fad38c..f866ac0ef 100644 --- a/TablePro/ViewModels/ERDiagramViewModel.swift +++ b/TablePro/ViewModels/ERDiagramViewModel.swift @@ -13,7 +13,31 @@ final class ERDiagramViewModel { // MARK: - Configuration let connectionId: UUID + let databaseName: String let schemaKey: String + let schemaName: String? + + /// The diagram is bound to the database and the schema its tab was opened on, so moving + /// the sidebar to another database or schema cannot repoint an open diagram. + private var scope: DatabaseScope? { + services.databaseManager.resolvedScope(database: databaseName, schema: schemaName, for: connectionId) + } + + private static let noSchemaMarker = "default" + + /// `schemaKey` is the diagram's identity, written as `database.schema` with + /// `noSchemaMarker` standing in for an engine that has no schemas. It is also the only + /// record of the schema a diagram tab was opened on, because `addERDiagramTab` writes a + /// database into the tab's table context but never a schema. Stripping the database + /// prefix rather than splitting on the separator keeps a database name that contains a + /// dot intact. + static func resolveSchemaName(fromSchemaKey schemaKey: String, databaseName: String) -> String? { + let prefix = databaseName + "." + guard !databaseName.isEmpty, schemaKey.hasPrefix(prefix) else { return nil } + let schema = String(schemaKey.dropFirst(prefix.count)) + guard !schema.isEmpty, schema != noSchemaMarker else { return nil } + return schema + } // MARK: - State @@ -84,9 +108,11 @@ final class ERDiagramViewModel { // MARK: - Initialization - init(connectionId: UUID, schemaKey: String, services: AppServices = .live) { + init(connectionId: UUID, databaseName: String, schemaKey: String, services: AppServices = .live) { self.connectionId = connectionId + self.databaseName = databaseName self.schemaKey = schemaKey + self.schemaName = Self.resolveSchemaName(fromSchemaKey: schemaKey, databaseName: databaseName) self.services = services } @@ -110,9 +136,14 @@ final class ERDiagramViewModel { return } + guard let scope else { + loadState = .failed(String(localized: "This diagram is not bound to a database")) + return + } + do { let (columns, foreignKeys, indexes) = try await services.databaseManager.withMetadataDriver( - connectionId: connectionId, workload: .bulk + scope: scope, workload: .bulk ) { driver in let cols = try await driver.fetchAllColumns() let fks = try await driver.fetchAllForeignKeys() @@ -279,7 +310,7 @@ final class ERDiagramViewModel { let payload = EditorTabPayload( connectionId: connectionId, tabType: .query, - databaseName: services.databaseManager.activeDatabaseName(for: driver.connection), + databaseName: scope?.database ?? services.databaseManager.browseDatabaseName(for: driver.connection), initialQuery: sql, skipAutoExecute: true, tabTitle: String(localized: "Schema SQL") diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4fefe92ca..537a0644a 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -131,7 +131,7 @@ internal final class QuickSwitcherViewModel { let switchTarget = services.pluginManager.containerSwitchTarget(for: databaseType) let databaseFilter = SharedSidebarState.forConnection(connectionId).databaseFilterSelected let activeDatabase = services.databaseManager.session(for: connectionId) - .map { services.databaseManager.activeDatabaseName(for: $0.connection) } + .map { services.databaseManager.browseDatabaseName(for: $0.connection) } let visibleDatabaseNames = switchTarget == .database ? Set( DatabaseTreeVisibility.visible( @@ -142,7 +142,7 @@ internal final class QuickSwitcherViewModel { ) : [] do { - let databases = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + let databases = try await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchDatabases() } let databaseSubtitle = switchTarget == .database @@ -169,7 +169,7 @@ internal final class QuickSwitcherViewModel { if services.pluginManager.supportsSchemaSwitching(for: databaseType) { do { - let schemas = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + let schemas = try await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { driver in try await driver.fetchSchemas() } let schemaSubtitle = switchTarget == .schema diff --git a/TablePro/ViewModels/UsersRolesViewModel.swift b/TablePro/ViewModels/UsersRolesViewModel.swift index 5b0248ef6..f2a59c71c 100644 --- a/TablePro/ViewModels/UsersRolesViewModel.swift +++ b/TablePro/ViewModels/UsersRolesViewModel.swift @@ -221,7 +221,7 @@ final class UsersRolesViewModel { databases: databases, catalog: snapshot.catalog, restrictsBrowsing: capabilities.restrictsBrowsing, - currentDatabase: DatabaseManager.shared.activeSessions[connectionId]?.activeDatabase, + currentDatabase: DatabaseManager.shared.activeSessions[connectionId]?.resolvedBrowseDatabase, loader: loader ) diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift index e6eaacff1..876bccaab 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -11,8 +11,8 @@ struct DatabaseSwitcherPopoverHost: View { let session = DatabaseManager.shared.session(for: connection.id) let switchTarget = PluginManager.shared.containerSwitchTarget(for: connection.type) ?? .database let activeContainer: String? = switch switchTarget { - case .database: session?.currentDatabase ?? connection.database - case .schema: coordinator.toolbarState.currentSchema ?? session?.currentSchema + case .database: session?.browseDatabase ?? connection.database + case .schema: coordinator.toolbarState.currentSchema ?? session?.browseSchema } DatabaseSwitcherPopover( diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index c86110c84..02913d019 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -360,7 +360,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { let capturedSchemaProvider = schemaProvider let capturedDBType = databaseType let dbName = connectionId.flatMap { - DatabaseManager.shared.session(for: $0)?.activeDatabase + DatabaseManager.shared.session(for: $0)?.resolvedBrowseDatabase } ?? "database" Task { diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 7508a6153..1e1e28a4c 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -553,13 +553,17 @@ struct ExportDialog: View { } /// Instantly populate the current database from sidebar tables (no network). + /// + /// The sidebar lists exactly what the export scope already points at, so the rows carry + /// no qualifier. Naming the database here would reach the export data source as a schema + /// on the engines that group by schema, which is a different container. private func populateFromSidebarTables() { guard !sidebarTables.isEmpty else { return } let dbName = connection.database let tableItems = sidebarTables.map { table in ExportTableItem( name: table.name, - databaseName: dbName, + databaseName: "", type: table.type, isSelected: preselectedTables.contains(table.name) ) @@ -605,7 +609,7 @@ struct ExportDialog: View { let grouping = PluginManager.shared.databaseGroupingStrategy(for: dbType) switch grouping { case .bySchema, .hierarchicalSchema: - let schemas = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + let schemas = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in try await driver.fetchSchemas() } let defaultSchema = PluginManager.shared.defaultSchemaName(for: dbType) @@ -645,11 +649,12 @@ struct ExportDialog: View { ) if let dbItem { items.append(dbItem) } case .byDatabase: - let databases = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + let databases = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in try await driver.fetchDatabases() } + let tablesByDatabase = try await fetchTablesGroupedByDatabase() for dbName in databases { - let tables = try await fetchTablesForDatabase(dbName) + let tables = tablesByDatabase[dbName] ?? [] let isCurrentDB = dbName == connection.database let tableItems = tables.map { table in let priorRow = priorRows["\(dbName).\(table.name)"] @@ -703,7 +708,7 @@ struct ExportDialog: View { name: String, priorRows: [String: ExportRowSnapshot] = [:] ) async throws -> ExportDatabaseItem? { - let tables = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + let tables = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in try await driver.fetchTables() } let tableItems = tables.map { table in @@ -721,13 +726,17 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String) async throws -> [TableInfo] { - try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in try await driver.fetchTables(schema: schema) } } - private func fetchTablesForDatabase(_ database: String) async throws -> [TableInfo] { - try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + /// One server-wide read for every database. The query carries no WHERE clause, so a + /// connection per database would return the same rows and only cost a connect, and a + /// database the user can list but not open becomes an empty group instead of an error + /// that fails the whole dialog. + private func fetchTablesGroupedByDatabase() async throws -> [String: [TableInfo]] { + try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in let query = """ SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES @@ -735,17 +744,18 @@ struct ExportDialog: View { """ let result = try await driver.execute(query: query) - return result.rows.compactMap { row -> TableInfo? in + var grouped: [String: [TableInfo]] = [:] + for row in result.rows { guard row.count >= 2, let rowSchema = row[0].asText, - rowSchema == database, let name = row[1].asText else { - return nil + continue } let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) + grouped[rowSchema, default: []].append(TableInfo(name: name, type: type, rowCount: nil)) } + return grouped } } @@ -787,34 +797,41 @@ struct ExportDialog: View { } } + /// The database this dialog exports from. Its connection carries the database the sheet + /// was opened against, and `resolvedScope` falls back to where the user is browsing when + /// that connection has no database of its own. + private var exportScope: DatabaseScope? { + DatabaseManager.shared.resolvedScope(database: connection.database, schema: nil, for: connection.id) + } + + private func showExportError(_ error: Error) { + AlertHelper.showErrorSheet( + title: String(localized: "Export Error"), + message: error.localizedDescription, + window: nil + ) + } + @MainActor private func startExport(to url: URL) async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { - AlertHelper.showErrorSheet( - title: String(localized: "Export Error"), - message: String(localized: "Not connected to database"), - window: nil - ) + guard let scope = exportScope else { + showExportError(ExportError.notConnected) return } + let route = DatabaseManager.shared.executionRoute(for: scope) isExporting = true exportedFileURL = url - - let service = ExportService( - driver: driver, - databaseType: connection.type - ) - exportService = service - showProgressDialog = true do { - try await service.export( - tables: exportableTables, - config: config, - to: url - ) + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + workload: .bulk + ) { driver in + try await runTableExport(on: driver, to: url) + } showProgressDialog = false isExporting = false @@ -831,14 +848,27 @@ struct ExportDialog: View { } catch { showProgressDialog = false isExporting = false - AlertHelper.showErrorSheet( - title: String(localized: "Export Error"), - message: error.localizedDescription, - window: nil - ) + showExportError(error) } } + /// The whole export runs inside the scoped lease, so every statement it issues lands on + /// the database the dialog was opened for rather than wherever the shared driver was + /// last parked by another tab. + @MainActor + private func runTableExport(on driver: DatabaseDriver, to url: URL) async throws { + let service = ExportService(driver: driver, databaseType: connection.type) + exportService = service + try await service.export(tables: exportableTables, config: config, to: url) + } + + @MainActor + private func runStreamingExport(on driver: DatabaseDriver, query: String, to url: URL) async throws { + let service = ExportService(driver: driver, databaseType: connection.type) + exportService = service + try await service.exportStreamingQuery(query: query, config: config, to: url) + } + @MainActor private func startQueryResultsExport(to url: URL) async { isExporting = true @@ -846,18 +876,24 @@ struct ExportDialog: View { showProgressDialog = true do { - let service: ExportService switch mode { case .streamingQuery(_, let query, _): - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - service = ExportService(driver: driver, databaseType: connection.type) - exportService = service - try await service.exportStreamingQuery(query: query, config: config, to: url) + guard let scope = exportScope else { throw ExportError.notConnected } + let route = DatabaseManager.shared.executionRoute(for: scope) + try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + workload: .bulk + ) { driver in + try await runStreamingExport(on: driver, query: query, to: url) + } case .queryResults(_, let tableRows, _): - service = ExportService(databaseType: connection.type) + let service = ExportService(databaseType: connection.type) exportService = service try await service.exportQueryResults(tableRows: tableRows, config: config, to: url) default: + showProgressDialog = false + isExporting = false return } @@ -876,11 +912,7 @@ struct ExportDialog: View { } catch { showProgressDialog = false isExporting = false - AlertHelper.showErrorSheet( - title: String(localized: "Export Error"), - message: error.localizedDescription, - window: nil - ) + showExportError(error) } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index a94a345d9..43cc14a5c 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -117,7 +117,7 @@ struct ImportDialog: View { } .sheet(isPresented: $showSuccessDialog, onDismiss: { isPresented = false - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) }) { ImportSuccessView( result: importResult diff --git a/TablePro/Views/Import/RowImportSheet.swift b/TablePro/Views/Import/RowImportSheet.swift index d7f562f73..0c8ddeb7b 100644 --- a/TablePro/Views/Import/RowImportSheet.swift +++ b/TablePro/Views/Import/RowImportSheet.swift @@ -109,7 +109,7 @@ struct RowImportSheet: View { } .sheet(isPresented: $showSuccessDialog, onDismiss: { isPresented = false - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) }) { ImportSuccessView(result: importResult) { showSuccessDialog = false } } diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 9f318f723..982d46efb 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -281,6 +281,7 @@ struct MainEditorContentView: View { guard erDiagramViewModels[tab.id] == nil else { return } let vm = ERDiagramViewModel( connectionId: connection.id, + databaseName: tab.tableContext.databaseName, schemaKey: tab.display.erDiagramSchemaKey ?? tab.tableContext.databaseName ) erDiagramViewModels[tab.id] = vm @@ -318,19 +319,18 @@ struct MainEditorContentView: View { private func containerName(for tab: QueryTab) -> String { let bound = tab.tableContext.databaseName - return bound.isEmpty ? coordinator.activeDatabaseName : bound + return bound.isEmpty ? coordinator.browseDatabaseName : bound } + /// Rebinding the container is a tab-local edit. The tab owns the new database for the + /// rest of its life and the sidebar's browse cursor stays where the user left it. private func changeContainer(for tab: QueryTab, to name: String) { let tabId = tab.id - let previousBinding = tab.tableContext.databaseName - tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = name } - Task { - let switched = await coordinator.switchDatabase(to: name, persist: false) - if !switched { - tabManager.mutate(tabId: tabId) { $0.tableContext.databaseName = previousBinding } - } - } + guard tab.tableContext.databaseName != name, + tabManager.mutate(tabId: tabId, { $0.tableContext.databaseName = name }) else { return } + tabManager.markTabRenamed(tabId) + guard tabManager.selectedTabId == tabId else { return } + coordinator.runQuery() } // MARK: - Query Tab Content @@ -522,10 +522,8 @@ struct MainEditorContentView: View { } } - private func structureDatabaseName(for tab: QueryTab) -> String { - tab.tableContext.databaseName.isEmpty - ? coordinator.activeDatabaseName - : tab.tableContext.databaseName + private func structureScope(for tab: QueryTab) -> DatabaseScope? { + coordinator.scope(for: tab) } @ViewBuilder @@ -535,16 +533,17 @@ struct MainEditorContentView: View { switch tab.display.resultsViewMode { case .structure: if let tableName = tab.tableContext.tableName { + let scope = structureScope(for: tab) TableStructureView( tableName: tableName, connection: connection, - databaseName: structureDatabaseName(for: tab), - schemaName: tab.tableContext.schemaName, + databaseName: scope?.database ?? "", + schemaName: scope?.schema, toolbarState: coordinator.toolbarState, coordinator: coordinator, selectionState: selectionState ) - .id("\(tab.tableContext.databaseName).\(tableName)") + .id("\(scope?.qualifiedDescription ?? "").\(tableName)") .frame(maxHeight: .infinity) } case .json: diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift index 4fa061389..ca7a7a88e 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnFetchScope.swift @@ -13,7 +13,7 @@ extension MainContentCoordinator { guard tab.tabType == .table, let tableName = tab.tableContext.tableName, !tab.columnLayout.hiddenColumns.isEmpty, - let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)) else { return nil } + let schema = schemaColumns.cached(schemaColumnsKey(tableName, scope: scope(for: tab))) else { return nil } return ColumnFetchScope.selectColumns( schemaColumns: schema.columns, @@ -40,18 +40,19 @@ extension MainContentCoordinator { guard let (tab, tabIndex) = tabManager.selectedTabAndIndex, tab.tabType == .table, let tableName = tab.tableContext.tableName else { return false } - await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) + await loadSchemaColumns(for: tableName, scope: scope(for: tab)) guard !Task.isCancelled, tabIndex < tabManager.tabs.count else { return false } filterCoordinator.rebuildTableQuery(at: tabIndex) return true } - func loadSchemaColumns(for tableName: String, schema: String?) async { - let key = schemaColumnsKey(tableName, schema: schema) - await schemaColumns.load(key) { [services, connectionId] in + func loadSchemaColumns(for tableName: String, scope: DatabaseScope?) async { + guard let scope else { return } + let key = schemaColumnsKey(tableName, scope: scope) + await schemaColumns.load(key) { [services] in do { - let columns = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in - try await driver.fetchColumns(table: tableName, schema: schema) + let columns = try await services.databaseManager.withMetadataDriver(scope: scope) { driver in + try await driver.fetchColumns(table: tableName, schema: scope.schema) } guard !columns.isEmpty else { columnScopeLog.error("loadSchemaColumns: 0 columns for table=\(tableName, privacy: .public); cannot scope") @@ -67,7 +68,7 @@ extension MainContentCoordinator { func columnsForVisibilityPicker(for tab: QueryTab, resultColumns: [String]) -> [String] { guard tab.tabType == .table, let tableName = tab.tableContext.tableName else { return resultColumns } - if let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)), !schema.columns.isEmpty { + if let schema = schemaColumns.cached(schemaColumnsKey(tableName, scope: scope(for: tab))), !schema.columns.isEmpty { return schema.columns } let missingHidden = tab.columnLayout.hiddenColumns.subtracting(resultColumns) @@ -77,21 +78,24 @@ extension MainContentCoordinator { func selectedTabSchemaColumns() -> [String]? { guard let tab = tabManager.selectedTab, let tableName = tab.tableContext.tableName, - let schema = schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)), + let schema = schemaColumns.cached(schemaColumnsKey(tableName, scope: scope(for: tab))), !schema.columns.isEmpty else { return nil } return schema.columns } func cachedSchemaColumns(for tab: QueryTab) -> (columns: [String], primaryKeys: [String])? { guard let tableName = tab.tableContext.tableName else { return nil } - return schemaColumns.cached(schemaColumnsKey(tableName, schema: tab.tableContext.schemaName)) + return schemaColumns.cached(schemaColumnsKey(tableName, scope: scope(for: tab))) } func effectiveResultColumns(for tab: QueryTab) -> [String] { selectColumns(for: tab) ?? cachedSchemaColumns(for: tab)?.columns ?? [] } - func schemaColumnsKey(_ tableName: String, schema: String?) -> String { - "\(connectionId):\(activeDatabaseName):\(schema ?? ""):\(tableName)" + /// Built entirely from the tab's scope. Keying it on where the user is browsing + /// makes two tabs on same-named tables in different databases share one entry. + func schemaColumnsKey(_ tableName: String, scope: DatabaseScope?) -> String { + guard let scope else { return "\(connectionId):::\(tableName)" } + return "\(scope.connectionId):\(scope.database):\(scope.schema ?? ""):\(tableName)" } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift index 1517ff168..07fb856e5 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ColumnVisibility.swift @@ -93,8 +93,7 @@ extension MainContentCoordinator { func rebuildSelectedTableQueryForHiddenColumnsIfNeeded() async { guard let tab = tabManager.selectedTab, - !tab.columnLayout.hiddenColumns.isEmpty, - tab.tableContext.databaseName.isEmpty || tab.tableContext.databaseName == activeDatabaseName else { return } + !tab.columnLayout.hiddenColumns.isEmpty else { return } await rebuildSelectedTableColumnScopedQuery() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift index ad5ec090f..8f04356f4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ERDiagram.swift @@ -12,8 +12,8 @@ extension MainContentCoordinator { /// 3. Otherwise open a new native window tab so the current tab's content /// (unsaved queries, filters, etc.) is preserved. func showERDiagram() { - let dbName = activeDatabaseName - let schemaName = DatabaseManager.shared.session(for: connectionId)?.currentSchema + let dbName = browseDatabaseName + let schemaName = DatabaseManager.shared.session(for: connectionId)?.browseSchema let schemaKey = "\(dbName).\(schemaName ?? "default")" if let existing = Self.coordinator(forConnection: connectionId, tabMatching: { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift index 49011d573..f8a494cff 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+FKNavigation.swift @@ -29,9 +29,13 @@ extension MainContentCoordinator { value: value ) - let currentDatabase = activeDatabaseName + guard let sourceScope = selectedTabScope else { + fkNavigationLogger.error("FK navigate skipped: the source tab is not bound to a database") + return + } - let targetSchema = DatabaseManager.shared.resolvedSchemaName(fkInfo.referencedSchema, for: connectionId) + let currentDatabase = sourceScope.database + let targetSchema = fkInfo.referencedSchema.flatMap { $0.isEmpty ? nil : $0 } ?? sourceScope.schema if !openInNewTab, let current = tabManager.selectedTab, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift index 5b9f9b2c2..e57ebf665 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Favorites.swift @@ -79,7 +79,7 @@ extension MainContentCoordinator { let payload = EditorTabPayload( connectionId: connection.id, tabType: .query, - databaseName: activeDatabaseName, + databaseName: browseDatabaseName, initialQuery: loaded.content, sourceFileURL: favorite.fileURL ) @@ -116,7 +116,7 @@ extension MainContentCoordinator { let payload = EditorTabPayload( connectionId: connection.id, tabType: .query, - databaseName: activeDatabaseName, + databaseName: browseDatabaseName, initialQuery: favorite.query ) WindowManager.shared.openTab(payload: payload) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f5e62bff4..1ea6d634b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -54,7 +54,7 @@ extension MainContentCoordinator { } currentDatabase = String(tableName.dropFirst(2)) } else { - currentDatabase = activeDatabaseName + currentDatabase = browseDatabaseName } let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(schema, for: connectionId) @@ -357,14 +357,14 @@ extension MainContentCoordinator { if editorLang == .javascript { tabManager.addTab( initialQuery: "db.runCommand({\"listCollections\": 1, \"nameOnly\": false})", - databaseName: activeDatabaseName + databaseName: browseDatabaseName ) runQuery() return nil } else if editorLang == .bash { tabManager.addTab( initialQuery: "SCAN 0 MATCH * COUNT 100", - databaseName: activeDatabaseName + databaseName: browseDatabaseName ) runQuery() return nil @@ -378,25 +378,21 @@ extension MainContentCoordinator { // MARK: - Database Switching - /// Switch to a different database (called from database switcher). - /// `persist` records the database as the connection's saved default; pass `false` - /// for transient per-tab switches that must not change the connection default. + /// Moves the browse cursor: what the sidebar lists and which database a new tab + /// opens in. It never retargets an open tab, and an open tab never calls it. + /// `persist` records the database as the connection's saved default. @discardableResult func switchDatabase(to database: String, persist: Bool = true) async -> Bool { - let previousDatabase = toolbarState.currentDatabase - toolbarState.currentDatabase = database - do { try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist) - toolbarState.currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema + toolbarState.currentDatabase = database + toolbarState.currentSchema = DatabaseManager.shared.session(for: connectionId)?.browseSchema - await SchemaService.shared.invalidate(connectionId: connectionId) + await SchemaService.shared.prepareForReload(connectionId: connectionId) await refreshTables(currentDatabaseOnly: true) return true } catch { - toolbarState.currentDatabase = previousDatabase - navigationLogger.error("Failed to switch database: \(error.localizedDescription, privacy: .public)") AlertHelper.showErrorSheet( title: String( @@ -506,7 +502,7 @@ extension MainContentCoordinator { } guard !Task.isCancelled else { return } DatabaseManager.shared.updateSession(connId) { session in - session.currentDatabase = database + session.browseDatabase = database } toolbarState.currentDatabase = database executeTableTabQueryDirectly() diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift index c1943f40e..fd264cd17 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QueryHelpers.swift @@ -13,21 +13,6 @@ extension MainContentCoordinator { aiViewModel?.handleFixError(query: query, error: error) } - func switchDatabaseBeforeExecution(to database: String, connectionId: UUID) async { - do { - try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: false) - await MainActor.run { toolbarState.currentDatabase = database } - Task { [weak self] in - await SchemaService.shared.invalidate(connectionId: connectionId) - await self?.refreshTables(currentDatabaseOnly: true) - } - } catch { - Self.logger.warning( - "Pre-execute switch to \(database, privacy: .public) failed: \(error.localizedDescription, privacy: .public)" - ) - } - } - func resolveRowCap(sql: String, tabType: TabType, bypassLimit: Bool = false) -> Int? { queryExecutionCoordinator.resolveRowCap(sql: sql, tabType: tabType, bypassLimit: bypassLimit) } @@ -119,8 +104,4 @@ extension MainContentCoordinator { connection: conn ) } - - func restoreSchemaAndRunQuery(_ schema: String, trigger: TableLoadTrigger = .userInitiated) async { - await queryExecutionCoordinator.restoreSchemaAndRunQuery(schema, trigger: trigger) - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Scope.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Scope.swift new file mode 100644 index 000000000..22630eb33 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Scope.swift @@ -0,0 +1,44 @@ +// +// MainContentCoordinator+Scope.swift +// TablePro +// + +import Foundation + +extension MainContentCoordinator { + /// A tab's target, read only from the tab and its connection. It deliberately reads + /// no window, toolbar or coordinator state, so moving the tab to another window + /// cannot change what it queries. + /// + /// It resolves before a session exists, because a tab already knows its own database + /// and the connection knows its saved default. Only the browse-cursor fallback, for a + /// tab that never recorded one, needs a live session. + func scope(for tab: QueryTab) -> DatabaseScope? { + if let resolved = services.databaseManager.resolvedScope( + database: tab.tableContext.databaseName, + schema: tab.tableContext.schemaName, + for: connectionId + ) { + return resolved + } + let database = tab.tableContext.databaseName.isEmpty + ? connection.database + : tab.tableContext.databaseName + return DatabaseScope( + connectionId: connectionId, + database: database, + schema: tab.tableContext.schemaName + ) + } + + var selectedTabScope: DatabaseScope? { + guard let tab = tabManager.selectedTab else { return browseScope } + return scope(for: tab) + } + + /// Where the sidebar is pointing. Correct for the object list and for seeding a new + /// tab, never for an operation an open tab owns. + var browseScope: DatabaseScope? { + services.databaseManager.browseScope(for: connectionId) + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift index 0194d4517..40a2a186c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+ServerDashboard.swift @@ -26,7 +26,7 @@ extension MainContentCoordinator { let payload = EditorTabPayload( connectionId: connection.id, tabType: .serverDashboard, - databaseName: activeDatabaseName + databaseName: browseDatabaseName ) WindowManager.shared.openTab(payload: payload) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift index 38ed6ce12..4b39320d2 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SessionContexts.swift @@ -28,7 +28,7 @@ extension MainContentCoordinator { do { try await driver.switchSessionContext(id: id, to: value) await loadSessionContexts() - AppCommands.shared.refreshData.send(connectionId) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connectionId)) } catch { AlertHelper.showErrorSheet( title: String(localized: "Switch Failed"), diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index c7f23e1e3..f307c6bb4 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -86,12 +86,12 @@ extension MainContentCoordinator { guard !safeModeLevel.blocksAllWrites else { return } if tabManager.tabs.isEmpty { - tabManager.addCreateTableTab(databaseName: activeDatabaseName) + tabManager.addCreateTableTab(databaseName: browseDatabaseName) } else { let payload = EditorTabPayload( connectionId: connection.id, tabType: .createTable, - databaseName: activeDatabaseName + databaseName: browseDatabaseName ) WindowManager.shared.openTab(payload: payload) } @@ -109,7 +109,7 @@ extension MainContentCoordinator { let payload = EditorTabPayload( connectionId: connection.id, tabType: .query, - databaseName: activeDatabaseName, + databaseName: browseDatabaseName, initialQuery: template ) WindowManager.shared.openTab(payload: payload) @@ -118,7 +118,7 @@ extension MainContentCoordinator { func editViewDefinition(_ viewName: String) { Task { do { - let definition = try await DatabaseManager.shared.withMetadataDriver(connectionId: self.connection.id) { driver in + let definition = try await DatabaseManager.shared.withBrowseMetadataDriver(connectionId: self.connection.id) { driver in try await driver.fetchViewDefinition(view: viewName) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 74f0fa12b..64c755a26 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -92,23 +92,8 @@ extension MainContentCoordinator { "[switch] handleTabChange phases: saveOutgoing=\(saveMs)ms restoreIncoming=\(restoreMs)ms" ) - if !newTab.tableContext.databaseName.isEmpty { - let currentDatabase = activeDatabaseName - - if newTab.tableContext.databaseName != currentDatabase { - Self.lifecycleLogger.debug( - "[switch] handleTabChange triggering switchDatabase from=\(currentDatabase, privacy: .public) to=\(newTab.tableContext.databaseName, privacy: .public)" - ) - changeManager.reloadVersion += 1 - Task { - await switchDatabase(to: newTab.tableContext.databaseName) - lazyLoadCurrentTabIfNeeded() - } - return - } - } - changeManager.reloadVersion += 1 + lazyLoadCurrentTabIfNeeded() } else { toolbarState.isTableTab = false toolbarState.isResultsCollapsed = false diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift index 3242583cb..d9be51f00 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableFirstLoad.swift @@ -32,7 +32,7 @@ extension MainContentCoordinator { return true } - await loadSchemaColumns(for: tableName, schema: tab.tableContext.schemaName) + await loadSchemaColumns(for: tableName, scope: scope(for: tab)) guard !Task.isCancelled, tabManager.selectedTabId == tabId, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift index f04a3c4be..62a2ea609 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+UsersRoles.swift @@ -18,7 +18,7 @@ extension MainContentCoordinator { let payload = EditorTabPayload( connectionId: connection.id, tabType: .usersRoles, - databaseName: activeDatabaseName + databaseName: browseDatabaseName ) WindowManager.shared.openTab(payload: payload) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift index be8066aa3..62bc475f0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Helpers.swift @@ -29,7 +29,7 @@ extension MainContentView { || (tabManager.selectedTab?.pendingChanges.hasChanges ?? false) if !hasPendingEdits { coordinator.pendingLoadTrigger = nil - consumePendingLoad(trigger: trigger, session: session) + consumePendingLoad(trigger: trigger) } } else { coordinator.lazyLoadCurrentTabIfNeeded() @@ -42,26 +42,12 @@ extension MainContentView { toolbarState.syncFromSession(for: connection) } - private func consumePendingLoad(trigger: TableLoadTrigger, session: ConnectionSession) { + private func consumePendingLoad(trigger: TableLoadTrigger) { if let tabId = tabManager.selectedTab?.id { coordinator.resolveTableTabSchemaIfNeeded(tabId: tabId) } - if let selectedTab = tabManager.selectedTab, - !selectedTab.tableContext.databaseName.isEmpty, - selectedTab.tableContext.databaseName != session.activeDatabase - { - Task { - await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) - coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) - } - } else if let selectedTab = tabManager.selectedTab, - let tabSchema = selectedTab.tableContext.schemaName, - !tabSchema.isEmpty, - tabSchema != session.currentSchema - { - Task { - await coordinator.restoreSchemaAndRunQuery(tabSchema, trigger: trigger) - } + if tabManager.selectedTab?.tabType == .table { + coordinator.lazyLoadCurrentTabIfNeeded(trigger: trigger) } else { coordinator.runQuery(trigger: trigger) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift index ba8d3bbab..75a5cb49c 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Setup.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Setup.swift @@ -63,13 +63,7 @@ extension MainContentView { if let session = DatabaseManager.shared.activeSessions[connection.id], session.isConnected { - if !selectedTab.tableContext.databaseName.isEmpty, - selectedTab.tableContext.databaseName != session.activeDatabase - { - await coordinator.switchDatabase(to: selectedTab.tableContext.databaseName) - } else { - coordinator.lazyLoadCurrentTabIfNeeded() - } + coordinator.lazyLoadCurrentTabIfNeeded() } else { coordinator.pendingLoadTrigger = .userInitiated } @@ -228,15 +222,13 @@ extension MainContentView { return } - let targetDatabase = selected.tabType == .table && !selected.tableContext.databaseName.isEmpty - ? selected.tableContext.databaseName - : activeDatabase.flatMap { $0.isEmpty ? nil : $0 } + let targetDatabase = activeDatabase.flatMap { $0.isEmpty ? nil : $0 } Task { - if let targetDatabase, targetDatabase != session.activeDatabase { + if let targetDatabase, targetDatabase != session.resolvedBrowseDatabase { await coordinator.switchDatabase(to: targetDatabase) } - if let activeSchema, !activeSchema.isEmpty, activeSchema != session.currentSchema { + if let activeSchema, !activeSchema.isEmpty, activeSchema != session.browseSchema { await coordinator.switchSchema(to: activeSchema) } if isTableTab { diff --git a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift index b0155dbcb..1e1ceeb93 100644 --- a/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift +++ b/TablePro/Views/Main/MainContentCommandActions+BulkClose.swift @@ -87,7 +87,7 @@ extension MainContentCommandActions { return TabBatchClosePlanner.planCloseForOtherDatabases( targets: targets, currentWindowId: currentWindowId, - currentDatabaseName: activeDatabaseName + currentDatabaseName: browseDatabaseName ) } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 2e988aebf..426b9e21e 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -291,7 +291,7 @@ final class MainContentCommandActions { var connectionId: UUID { connection.id } - var activeDatabaseName: String { coordinator?.activeDatabaseName ?? "" } + var browseDatabaseName: String { coordinator?.browseDatabaseName ?? "" } var openTabCount: Int { coordinator?.tabManager.tabs.count ?? 0 } @@ -390,7 +390,7 @@ final class MainContentCommandActions { if let coordinator, coordinator.tabManager.tabs.isEmpty { coordinator.tabManager.addTab( initialQuery: initialQuery, - databaseName: coordinator.activeDatabaseName, + databaseName: coordinator.browseDatabaseName, claimFocus: true ) return @@ -1057,14 +1057,18 @@ final class MainContentCommandActions { private func setupDataBroadcastObservers() { AppCommands.shared.refreshData .receive(on: RunLoop.main) - .sink { [weak self] changedConnectionId in - guard let self, changedConnectionId == self.connection.id, + .sink { [weak self] request in + guard let self, request.connectionId == self.connection.id, let coordinator = self.coordinator else { return } - coordinator.reloadActiveTableData( - hasPendingTableOps: self.hasPendingTableOps, - onDiscard: { [weak self] in self?.clearPendingTableOps() } - ) - Task { await coordinator.refreshTables() } + if request.reaches(tabScope: coordinator.selectedTabScope) { + coordinator.reloadActiveTableData( + hasPendingTableOps: self.hasPendingTableOps, + onDiscard: { [weak self] in self?.clearPendingTableOps() } + ) + } + if request.reachesBrowsedDatabase(coordinator.browseDatabaseName) { + Task { await coordinator.refreshTables() } + } } .store(in: &eventCancellables) } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 23090cb68..58de933bd 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -76,8 +76,8 @@ final class MainContentCoordinator { let connection: DatabaseConnection var connectionId: UUID { connection.id } var sqlDialect: SqlDialect { SqlDialect.from(databaseTypeId: connection.type.rawValue) } - var activeDatabaseName: String { - services.databaseManager.activeDatabaseName(for: connection) + var browseDatabaseName: String { + services.databaseManager.browseDatabaseName(for: connection) } var safeModeLevel: SafeModeLevel { toolbarState.safeModeLevel } func setSafeModeLevel(_ level: SafeModeLevel) { @@ -523,7 +523,7 @@ final class MainContentCoordinator { .sink { [weak self] changedConnectionId in guard let self, changedConnectionId == self.connectionId else { return } Task { @MainActor in - if let schema = self.services.databaseManager.session(for: self.connectionId)?.currentSchema { + if let schema = self.services.databaseManager.session(for: self.connectionId)?.browseSchema { self.toolbarState.currentSchema = schema } await self.refreshTables() @@ -628,19 +628,21 @@ final class MainContentCoordinator { schemaColumns.removeAll() await services.schemaRefreshService.refresh( connection: connection, - database: currentDatabaseOnly ? activeDatabaseName : nil + database: currentDatabaseOnly ? browseDatabaseName : nil ) pruneStaleSidebarState() } func refreshProcedures() async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - await services.schemaService.reloadProcedures(connectionId: connectionId, driver: driver) + try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in + await services.schemaService.reloadProcedures(connectionId: connectionId, driver: driver) + } } func refreshFunctions() async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - await services.schemaService.reloadFunctions(connectionId: connectionId, driver: driver) + try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in + await services.schemaService.reloadFunctions(connectionId: connectionId, driver: driver) + } } func showRoutineDDL(_ routine: RoutineInfo) { @@ -840,12 +842,14 @@ final class MainContentCoordinator { // MARK: - Schema Loading func loadSchema() async { - guard let driver = services.databaseManager.driver(for: connectionId) else { return } - await services.schemaService.load( - connectionId: connectionId, - driver: driver, - connection: connection - ) + let connection = connection + try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in + await services.schemaService.load( + connectionId: connectionId, + driver: driver, + connection: connection + ) + } await DatabaseTreeMetadataService.shared.loadDatabases( connectionId: connectionId, databaseType: connection.type @@ -855,8 +859,12 @@ final class MainContentCoordinator { } func loadTableMetadata(tableName: String) async { + guard let scope = selectedTabScope else { + Self.logger.error("Skipped table metadata load: no database bound to the selected tab") + return + } do { - let metadata = try await services.databaseManager.withMetadataDriver(connectionId: connectionId) { driver in + let metadata = try await services.databaseManager.withMetadataDriver(scope: scope) { driver in try await driver.fetchTableMetadata(tableName: tableName) } self.tableMetadata = metadata @@ -991,7 +999,7 @@ final class MainContentCoordinator { $0.hasUserInteraction = true } } else if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) + tabManager.addTab(initialQuery: query, databaseName: browseDatabaseName) } else { let payload = EditorTabPayload( connectionId: connection.id, @@ -1014,7 +1022,7 @@ final class MainContentCoordinator { mutTab.hasUserInteraction = true } } else if tabManager.tabs.isEmpty { - tabManager.addTab(initialQuery: query, databaseName: activeDatabaseName) + tabManager.addTab(initialQuery: query, databaseName: browseDatabaseName) } else { let payload = EditorTabPayload( connectionId: connection.id, @@ -1167,13 +1175,14 @@ final class MainContentCoordinator { "[fk] metadata decision table=\(tableName, privacy: .public) isEditable=\(isEditable) needsFetch=\(needsMetadataFetch)" ) } - let connId = connectionId - let currentDatabase = activeDatabaseName - let targetDatabase = tab.tableContext.databaseName.isEmpty - ? currentDatabase - : tab.tableContext.databaseName - let perTabSwitchAllowed = !services.pluginManager.requiresReconnectForDatabaseSwitch(for: connection.type) - let needsDatabaseSwitch = perTabSwitchAllowed && !targetDatabase.isEmpty && targetDatabase != currentDatabase + guard let scope = scope(for: tab) else { + tabManager.mutate(at: index) { tab in + tab.execution.isExecuting = false + tab.execution.errorMessage = String(localized: "Not connected to database") + } + toolbarState.setExecuting(false) + return + } currentQueryTask = Task { [weak self] in guard let self else { return } @@ -1193,23 +1202,26 @@ final class MainContentCoordinator { } } - if needsDatabaseSwitch { - await switchDatabaseBeforeExecution(to: targetDatabase, connectionId: connId) - } - let schemaTask: Task? if needsMetadataFetch, let tableName { - schemaTask = Task { try await QueryExecutor.fetchTableSchema(connectionId: connId, tableName: tableName) } + schemaTask = Task { try await QueryExecutor.fetchTableSchema(scope: scope, tableName: tableName) } } else { schemaTask = nil } do { - let fetchResult = try await queryExecutor.executeQuery( - sql: sql, - parameters: nil, - rowCap: rowCap - ) + let fetchResult = try await services.databaseManager.withScopedDriver( + scope: scope, + route: services.databaseManager.executionRoute(for: scope), + tracksCancellation: true + ) { [queryExecutor] driver in + try await queryExecutor.executeQuery( + driver: driver, + sql: sql, + parameters: nil, + rowCap: rowCap + ) + } guard !Task.isCancelled else { schemaTask?.cancel() @@ -1304,7 +1316,7 @@ final class MainContentCoordinator { guard currentQueryTask != nil else { return } currentQueryTask?.cancel() do { - try services.databaseManager.driver(for: connectionId)?.cancelQuery() + try services.databaseManager.cancelRunningQuery(for: connectionId) } catch { Self.logger.warning("cancelQuery failed: \(error.localizedDescription, privacy: .public)") } @@ -1352,7 +1364,8 @@ final class MainContentCoordinator { } if result.isEmpty, - let createSQL = try? await DatabaseManager.shared.withMetadataDriver(connectionId: connectionId, { driver in + let scope = selectedTabScope, + let createSQL = try? await services.databaseManager.withMetadataDriver(scope: scope, { driver in try await driver.fetchTableDDL(table: tableName) }) { for col in columnInfo { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index fa8e131c4..96d1a150e 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -155,7 +155,7 @@ struct MainContentView: View { /// so export/import dialogs see the database the user actually switched to. private var connectionWithCurrentDatabase: DatabaseConnection { var conn = connection - if let currentDB = DatabaseManager.shared.session(for: connection.id)?.currentDatabase { + if let currentDB = DatabaseManager.shared.session(for: connection.id)?.browseDatabase { conn.database = currentDB } return conn @@ -260,14 +260,14 @@ struct MainContentView: View { BackupDatabaseFlow( isPresented: dismissBinding, connection: connectionWithCurrentDatabase, - initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase ?? connection.database ) case .restoreDatabase(let fileURL): RestoreDatabaseFlow( isPresented: dismissBinding, connection: connectionWithCurrentDatabase, - initialDatabase: DatabaseManager.shared.session(for: connection.id)?.currentDatabase + initialDatabase: DatabaseManager.shared.session(for: connection.id)?.browseDatabase ?? connection.database, sourceURL: fileURL ) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index e0629e455..9ac05c4a4 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -222,7 +222,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { let visible = DatabaseTreeVisibility.visible( databases: service.databases(for: connectionId), selected: sidebarState?.databaseFilterSelected ?? [], - activeDatabase: mainCoordinator?.activeDatabaseName ?? activeDatabase + activeDatabase: mainCoordinator?.browseDatabaseName ?? activeDatabase ) let matched = searchText.isEmpty ? visible : visible.filter { databaseMatchesSearch($0) } var seen = Set() @@ -234,7 +234,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func recentTableRefs() -> [DatabaseTreeTableRef] { guard let sidebarState, AppSettingsManager.shared.general.showRecentTables else { return [] } - let database = mainCoordinator?.activeDatabaseName ?? activeDatabase ?? "" + let database = mainCoordinator?.browseDatabaseName ?? activeDatabase ?? "" return sidebarState.recentEntries(inDatabase: database).compactMap { entry -> DatabaseTreeTableRef? in if !searchText.isEmpty, !DatabaseTreeFilter.matches(searchText, entry.name) { return nil } return DatabaseTreeTableRef(database: database, schema: entry.schema, table: entry.tableInfo) @@ -511,7 +511,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { /// moves the session schema without touching the toolbar, so comparing against /// the toolbar skips the switch exactly when the session needs it. private var sessionSchema: String? { - DatabaseManager.shared.session(for: connectionId)?.currentSchema + DatabaseManager.shared.session(for: connectionId)?.browseSchema } private func setActiveDatabase(_ database: String) { @@ -570,7 +570,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { self?.sidebarState?.removeRecentTable(database: ref.database, schema: ref.schema, name: ref.table.name) }, clearRecents: { [weak self] in - self?.sidebarState?.clearRecentTables(inDatabase: self?.mainCoordinator?.activeDatabaseName) + self?.sidebarState?.clearRecentTables(inDatabase: self?.mainCoordinator?.browseDatabaseName) } ) } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index d3a5b334d..039b2cec5 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -19,7 +19,7 @@ internal struct FavoritesTabView: View { private var searchText: String { sharedSidebarState.favoritesSearchText } private var activeDatabase: String? { - let name = coordinator?.activeDatabaseName ?? "" + let name = coordinator?.browseDatabaseName ?? "" return name.isEmpty ? nil : name } diff --git a/TablePro/Views/Sidebar/SchemaPickerControl.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift index 65dc11c60..f00e47da0 100644 --- a/TablePro/Views/Sidebar/SchemaPickerControl.swift +++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift @@ -11,7 +11,7 @@ struct SchemaPickerControl: View { @State private var showSystemSchemas = false private var currentSchema: String? { - databaseManager.session(for: connectionId)?.currentSchema + databaseManager.session(for: connectionId)?.browseSchema } private var allSchemas: [String] { diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 4beaead0b..516b9156a 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -17,7 +17,7 @@ struct SidebarTreeView: View { @State private var searchLoadTask: Task? private var activeDatabase: String? { - let name = coordinator?.activeDatabaseName ?? "" + let name = coordinator?.browseDatabaseName ?? "" return name.isEmpty ? nil : name } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 657898727..9cb70d839 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -344,7 +344,7 @@ struct SidebarView: View { } private var activeDatabase: String? { - let name = coordinator?.activeDatabaseName ?? "" + let name = coordinator?.browseDatabaseName ?? "" return name.isEmpty ? nil : name } diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index c0fada3fe..b2aa360c9 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -409,7 +409,7 @@ struct CreateTableView: View { QueryHistoryManager.shared.recordQuery( query: sql, connectionId: connection.id, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), executionTime: 0, rowCount: 0, wasSuccessful: true @@ -419,7 +419,7 @@ struct CreateTableView: View { coordinator.openTableTab(tableName) } - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } catch { Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription diff --git a/TablePro/Views/Structure/TableStructureLoader.swift b/TablePro/Views/Structure/TableStructureLoader.swift new file mode 100644 index 000000000..2ef653bd8 --- /dev/null +++ b/TablePro/Views/Structure/TableStructureLoader.swift @@ -0,0 +1,73 @@ +// +// TableStructureLoader.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Reads a table's structure against the scope its tab is bound to. +/// +/// The tab outlives the sidebar's selection, so the database and schema come from the +/// tab and are fixed for the life of the loader. Resolving them from the session instead +/// is what made a structure tab report that its own table does not exist once the +/// sidebar moved to another database. +@MainActor +struct TableStructureLoader { + let scope: DatabaseScope + let tableName: String + private let provider: any ScopedMetadataProviding + + init( + scope: DatabaseScope, + tableName: String, + provider: any ScopedMetadataProviding = DatabaseManager.shared + ) { + self.scope = scope + self.tableName = tableName + self.provider = provider + } + + struct CoreTabs: Sendable { + let columns: [ColumnInfo] + let indexes: [IndexInfo] + let foreignKeys: [ForeignKeyInfo] + } + + func columns() async throws -> [ColumnInfo] { + let table = tableName + return try await perform { try await $0.fetchColumns(table: table) } + } + + func indexes() async throws -> [IndexInfo] { + let table = tableName + return try await perform { try await $0.fetchIndexes(table: table) } + } + + func foreignKeys() async throws -> [ForeignKeyInfo] { + let table = tableName + return try await perform { try await $0.fetchForeignKeys(table: table) } + } + + func triggers() async throws -> [TriggerInfo] { + let table = tableName + return try await perform { try await $0.fetchTriggers(table: table) } + } + + func coreTabs(includingForeignKeys: Bool) async throws -> CoreTabs { + let table = tableName + return try await perform { driver in + CoreTabs( + columns: try await driver.fetchColumns(table: table), + indexes: try await driver.fetchIndexes(table: table), + foreignKeys: includingForeignKeys ? try await driver.fetchForeignKeys(table: table) : [] + ) + } + } + + func perform( + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + try await provider.withMetadataDriver(scope: scope, workload: .interactive, body) + } +} diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift index 6909bcd47..bfefa00cc 100644 --- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift +++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift @@ -29,9 +29,7 @@ extension TableStructureView { errorMessage = nil do { - columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - try await driver.fetchColumns(table: tableName) - } + columns = try await structureLoader.columns() tabData.markFetched(.columns) } catch { errorMessage = error.localizedDescription @@ -49,22 +47,17 @@ extension TableStructureView { do { switch tab { case .columns: - columns = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - try await driver.fetchColumns(table: tableName) - } + columns = try await structureLoader.columns() case .indexes: - indexes = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - try await driver.fetchIndexes(table: tableName) - } + indexes = try await structureLoader.indexes() case .foreignKeys: - foreignKeys = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - try await driver.fetchForeignKeys(table: tableName) - } + foreignKeys = try await structureLoader.foreignKeys() case .ddl: - ddlStatement = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - let sequences = try await driver.fetchDependentSequences(forTable: tableName) - let enumTypes = try await driver.fetchDependentTypes(forTable: tableName) - let baseDDL = try await driver.fetchTableDDL(table: tableName) + let table = tableName + ddlStatement = try await structureLoader.perform { driver in + let sequences = try await driver.fetchDependentSequences(forTable: table) + let enumTypes = try await driver.fetchDependentTypes(forTable: table) + let baseDDL = try await driver.fetchTableDDL(table: table) if sequences.isEmpty && enumTypes.isEmpty { return baseDDL } @@ -81,9 +74,7 @@ extension TableStructureView { } case .triggers: do { - triggers = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - try await driver.fetchTriggers(table: tableName) - } + triggers = try await structureLoader.triggers() } catch { Self.logger.error("Failed to load triggers: \(error.localizedDescription, privacy: .public)") triggers = [] @@ -193,14 +184,7 @@ extension TableStructureView { let includesForeignKeys = connection.type.supportsForeignKeys do { - let reloaded = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id) { driver in - let fetchedColumns = try await driver.fetchColumns(table: tableName) - let fetchedIndexes = try await driver.fetchIndexes(table: tableName) - let fetchedForeignKeys = includesForeignKeys - ? try await driver.fetchForeignKeys(table: tableName) - : [] - return (columns: fetchedColumns, indexes: fetchedIndexes, foreignKeys: fetchedForeignKeys) - } + let reloaded = try await structureLoader.coreTabs(includingForeignKeys: includesForeignKeys) columns = reloaded.columns indexes = reloaded.indexes diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift index 33da4428a..cd2bdddc0 100644 --- a/TablePro/Views/Structure/TableStructureView+Schema.swift +++ b/TablePro/Views/Structure/TableStructureView+Schema.swift @@ -68,6 +68,8 @@ extension TableStructureView { let changes = structureChangeManager.getChangesArray() guard !changes.isEmpty else { return } + let saveScope = scope + let destructiveChanges = changes.filter { $0.requiresDataMigration } if !destructiveChanges.isEmpty { let descriptions = destructiveChanges.map { $0.description } @@ -94,9 +96,7 @@ extension TableStructureView { tableName: tableName, changes: changes, databaseType: connection.type, - databaseName: databaseName, - schemaName: schemaName, - connectionId: connection.id + scope: saveScope ) tabData.markAllStale() diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index e808ea988..08f9784d4 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -25,6 +25,15 @@ struct TableStructureView: View { let coordinator: MainContentCoordinator? let selectionState: GridSelectionState + /// Derived from the tab's own binding on every render so it can never go stale. + var scope: DatabaseScope { + DatabaseScope(connectionId: connection.id, database: databaseName, schema: schemaName) + } + + var structureLoader: TableStructureLoader { + TableStructureLoader(scope: scope, tableName: tableName) + } + @State var selectedTab: StructureTab = .columns @State var columns: [ColumnInfo] = [] @State var indexes: [IndexInfo] = [] @@ -162,8 +171,9 @@ struct TableStructureView: View { // manager but the grid never displays it. displayVersion += 1 } - .onReceive(AppCommands.shared.refreshData) { changedConnectionId in - guard changedConnectionId == connection.id else { return } + .onReceive(AppCommands.shared.refreshData) { request in + guard request.connectionId == connection.id else { return } + guard request.reaches(tabScope: scope) else { return } onRefreshData() } } @@ -378,7 +388,7 @@ struct TableStructureView: View { QueryHistoryManager.shared.recordQuery( query: executedSQL.hasSuffix(";") ? executedSQL : executedSQL + ";", connectionId: connection.id, - databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + databaseName: DatabaseManager.shared.browseDatabaseName(for: connection), executionTime: 0, rowCount: 0, wasSuccessful: true @@ -388,7 +398,7 @@ struct TableStructureView: View { loadSchemaForEditing() isReloadingAfterSave = false coordinator?.clearColumnLayoutForSelectedTable() - AppCommands.shared.refreshData.send(connection.id) + AppCommands.shared.refreshData.send(DataRefreshRequest(connectionId: connection.id)) } catch { AlertHelper.showErrorSheet( title: String(localized: "Column Reorder Failed"), diff --git a/TableProTests/Core/AI/ChatToolScopeParameterTests.swift b/TableProTests/Core/AI/ChatToolScopeParameterTests.swift new file mode 100644 index 000000000..3698c6b8b --- /dev/null +++ b/TableProTests/Core/AI/ChatToolScopeParameterTests.swift @@ -0,0 +1,47 @@ +// +// ChatToolScopeParameterTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Chat tool scope parameters") +struct ChatToolScopeParameterTests { + private static func nullableTypes(_ schema: JsonValue?, property: String) -> [String] { + let type = schema?["properties"]?[property]?["type"] + return type?.arrayValue?.compactMap(\.stringValue) ?? [] + } + + @Test("Schema reads take an optional database so the chat can reach a non-browse database") + func schemaReadsTakeDatabase() { + let tools: [any ChatTool] = [ + ListTablesChatTool(), + ListSchemasChatTool(), + DescribeTableChatTool(), + GetTableDDLChatTool(), + ExecuteQueryChatTool() + ] + + for tool in tools { + let types = Self.nullableTypes(tool.inputSchema, property: "database") + #expect(types == ["string", "null"], "\(tool.name) must declare a nullable database parameter") + } + } + + @Test("Table reads take an optional schema alongside the database") + func tableReadsTakeSchema() { + let tools: [any ChatTool] = [ + ListTablesChatTool(), + DescribeTableChatTool(), + GetTableDDLChatTool(), + ExecuteQueryChatTool() + ] + + for tool in tools { + let types = Self.nullableTypes(tool.inputSchema, property: "schema") + #expect(types == ["string", "null"], "\(tool.name) must declare a nullable schema parameter") + } + } +} diff --git a/TableProTests/Core/AI/StrictToolSchemaTests.swift b/TableProTests/Core/AI/StrictToolSchemaTests.swift index fdb5734c4..160d2507a 100644 --- a/TableProTests/Core/AI/StrictToolSchemaTests.swift +++ b/TableProTests/Core/AI/StrictToolSchemaTests.swift @@ -13,6 +13,7 @@ struct StrictToolSchemaTests { ListConnectionsChatTool(), ListDatabasesChatTool(), ListTablesChatTool(), + ListSchemasChatTool(), DescribeTableChatTool(), GetTableDDLChatTool(), GetConnectionStatusChatTool(), diff --git a/TableProTests/Core/Concurrency/SessionDriverGateTests.swift b/TableProTests/Core/Concurrency/SessionDriverGateTests.swift new file mode 100644 index 000000000..4bbae3d9a --- /dev/null +++ b/TableProTests/Core/Concurrency/SessionDriverGateTests.swift @@ -0,0 +1,284 @@ +// +// SessionDriverGateTests.swift +// TableProTests +// +// The gate orders every operation that moves a connection's single shared driver. +// Without it two windows interleave their pins and each runs against the other's +// database (#2026). The cancellation tests are the regression guard for #2021: the +// body must run inline in the caller's task, never in a detached `Task { await +// previous.value }`, which severs cancellation from the work. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// One-shot rendezvous so a test can wait for work to reach a known point instead +/// of sleeping. Main-actor isolated because the gate is. +@MainActor +private final class TestSignal { + private var waiters: [CheckedContinuation] = [] + private var isSignalled = false + + func signal() { + guard !isSignalled else { return } + isSignalled = true + let pending = waiters + waiters = [] + for waiter in pending { + waiter.resume() + } + } + + func wait() async { + guard !isSignalled else { return } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } +} + +@MainActor +private final class EventLog { + private(set) var events: [String] = [] + + func record(_ event: String) { + events.append(event) + } +} + +@MainActor +private final class BoolBox { + var value = false +} + +private struct GateBodyError: Error, Equatable {} + +/// Lets queued work reach its next suspension point without sleeping. +@MainActor +private func drainMainActor(_ times: Int = 8) async { + for _ in 0.. (DatabaseConnection, SchemaRoutingDriver) { - let connection = TestFixtures.makeConnection(database: database, type: type) - let pluginDriver = SchemaRoutingDriver(currentSchema: currentSchema) + let connection = TestFixtures.makeConnection(database: savedDatabase, type: type) + let pluginDriver = SchemaRoutingDriver(currentSchema: browseSchema) let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) var session = ConnectionSession(connection: connection, driver: adapter) - session.currentDatabase = currentDatabase ?? database - session.currentSchema = currentSchema + session.browseDatabase = browseDatabase ?? savedDatabase + session.browseSchema = browseSchema DatabaseManager.shared.injectSession(session, for: connection.id) return (connection, pluginDriver) } + private static func makeScope( + _ connection: DatabaseConnection, + database: String, + schema: String? = nil + ) -> DatabaseScope? { + DatabaseScope(connectionId: connection.id, database: database, schema: schema) + } + @Test("Schema changes run on the requested connection, not the last activated one") func schemaChangeUsesRequestedConnection() async throws { - let (connectionA, driverA) = Self.makeSession(database: "alpha") - let (connectionB, driverB) = Self.makeSession(database: "beta") + let (connectionA, driverA) = Self.makeSession(savedDatabase: "alpha") + let (connectionB, driverB) = Self.makeSession(savedDatabase: "beta") DatabaseManager.shared.lastActiveSessionId = connectionA.id defer { DatabaseManager.shared.removeSession(for: connectionA.id) @@ -113,13 +127,12 @@ struct DatabaseManagerSchemaChangeRoutingTests { DatabaseManager.shared.lastActiveSessionId = nil } + let scope = try #require(Self.makeScope(connectionB, database: "beta")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .mysql, - databaseName: "beta", - schemaName: nil, - connectionId: connectionB.id + scope: scope ) #expect(driverB.executedQueries.count == 1) @@ -127,57 +140,65 @@ struct DatabaseManagerSchemaChangeRoutingTests { #expect(driverA.executedQueries.isEmpty) } - @Test("Schema changes pin the editing tab's database before running any DDL") - func schemaChangePinsDatabaseFirst() async throws { - let (connection, driver) = Self.makeSession(database: "orders", currentDatabase: "inventory") + @Test("A save runs on the tab's database without moving the browse cursor") + func schemaChangeRunsOnTheTabsDatabaseWithoutMovingTheBrowseCursor() async throws { + let (connection, driver) = Self.makeSession( + savedDatabase: "analytics", + browseDatabase: "inventory" + ) defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = try #require(Self.makeScope(connection, database: "orders")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .mysql, - databaseName: "orders", - schemaName: nil, - connectionId: connection.id + scope: scope ) - #expect(driver.switchedDatabases == ["orders"]) - #expect(DatabaseManager.shared.session(for: connection.id)?.currentDatabase == "orders") + #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) + #expect(!driver.switchedDatabases.isEmpty) #expect(driver.executedQueries.count == 1) + + let session = DatabaseManager.shared.session(for: connection.id) + #expect(session?.browseDatabase == "inventory") + #expect(session?.connection.database == "analytics") } - @Test("Schema changes skip the switch when the session is already on the tab's database") - func schemaChangeSkipsRedundantSwitch() async throws { - let (connection, driver) = Self.makeSession(database: "orders") + @Test("A save always pins its target database because nothing tracks where the driver is") + func schemaChangeAlwaysPinsItsTargetDatabase() async throws { + let (connection, driver) = Self.makeSession(savedDatabase: "orders") defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = try #require(Self.makeScope(connection, database: "orders")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .mysql, - databaseName: "orders", - schemaName: nil, - connectionId: connection.id + scope: scope ) - #expect(driver.switchedDatabases.isEmpty) + #expect(!driver.switchedDatabases.isEmpty) + #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) #expect(driver.executedQueries.count == 1) } @Test("A failed database pin aborts the save before any DDL runs") func failedDatabasePinAbortsSave() async throws { - let (connection, driver) = Self.makeSession(database: "orders", currentDatabase: "inventory") + let (connection, driver) = Self.makeSession( + savedDatabase: "orders", + browseDatabase: "inventory" + ) driver.switchDatabaseError = DatabaseError.queryFailed("unknown database") defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = try #require(Self.makeScope(connection, database: "orders")) await #expect(throws: DatabaseError.self) { try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .mysql, - databaseName: "orders", - schemaName: nil, - connectionId: connection.id + scope: scope ) } @@ -188,18 +209,16 @@ struct DatabaseManagerSchemaChangeRoutingTests { func reconnectRequiredEngineIsNotSwitched() async throws { let (connection, driver) = Self.makeSession( type: .postgresql, - database: "orders", - currentDatabase: "inventory" + savedDatabase: "orders" ) defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = try #require(Self.makeScope(connection, database: "orders")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .postgresql, - databaseName: "orders", - schemaName: nil, - connectionId: connection.id + scope: scope ) #expect(driver.switchedDatabases.isEmpty) @@ -210,23 +229,60 @@ struct DatabaseManagerSchemaChangeRoutingTests { func schemaGroupedEngineKeepsTableSchema() async throws { let (connection, driver) = Self.makeSession( type: .mssql, - database: "orders", - currentDatabase: "inventory", - currentSchema: "sales" + savedDatabase: "orders", + browseDatabase: "inventory", + browseSchema: "sales" ) defer { DatabaseManager.shared.removeSession(for: connection.id) } + let scope = try #require(Self.makeScope(connection, database: "orders", schema: "sales")) try await DatabaseManager.shared.executeSchemaChanges( tableName: "orders", changes: [Self.makeAddColumnChange()], databaseType: .mssql, - databaseName: "orders", - schemaName: "sales", - connectionId: connection.id + scope: scope ) - #expect(driver.switchedDatabases == ["orders"]) + #expect(!driver.switchedDatabases.isEmpty) + #expect(driver.switchedDatabases.allSatisfy { $0 == "orders" }) #expect(driver.currentSchema == "sales") #expect(driver.executedQueries.first?.contains("`sales`.`orders`") == true) } + + @Test("A save broadcasts a refresh scoped to the edited tab, not to the browse cursor") + func schemaChangeBroadcastsTheEditedScope() async throws { + let (connection, _) = Self.makeSession( + savedDatabase: "analytics", + browseDatabase: "inventory" + ) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let recorder = RefreshRequestRecorder() + let cancellable = AppCommands.shared.refreshData.sink { request in + recorder.record(request) + } + defer { cancellable.cancel() } + + let scope = try #require(Self.makeScope(connection, database: "orders")) + try await DatabaseManager.shared.executeSchemaChanges( + tableName: "orders", + changes: [Self.makeAddColumnChange()], + databaseType: .mysql, + scope: scope + ) + + let broadcast = recorder.requests.filter { $0.connectionId == connection.id } + #expect(broadcast.count == 1) + #expect(broadcast.first?.scope == scope) + #expect(broadcast.first?.scope?.database == "orders") + } +} + +@MainActor +private final class RefreshRequestRecorder { + private(set) var requests: [DataRefreshRequest] = [] + + func record(_ request: DataRefreshRequest) { + requests.append(request) + } } diff --git a/TableProTests/Core/Database/DatabaseManagerTests.swift b/TableProTests/Core/Database/DatabaseManagerTests.swift index 783db0bef..6c32eeefe 100644 --- a/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -36,7 +36,7 @@ struct DatabaseManagerSessionTests { func resolvedSchemaNameKeepsExplicitSchema() { let connection = TestFixtures.makeConnection() var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -47,7 +47,7 @@ struct DatabaseManagerSessionTests { func resolvedSchemaNameFallsBackToSessionSchema() { let connection = TestFixtures.makeConnection() var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -72,7 +72,7 @@ struct DatabaseManagerSessionTests { func resolvedSchemaNameTreatsBlankExplicitSchemaAsAbsent() { let connection = TestFixtures.makeConnection() var session = ConnectionSession(connection: connection) - session.currentSchema = "custom" + session.browseSchema = "custom" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -83,7 +83,7 @@ struct DatabaseManagerSessionTests { func resolvedSchemaNameRejectsBlankSessionSchema() { let connection = TestFixtures.makeConnection() var session = ConnectionSession(connection: connection) - session.currentSchema = "" + session.browseSchema = "" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -149,7 +149,7 @@ struct DatabaseManagerDatabaseSwitchTests { let pluginDriver = DatabaseSwitchingDriver(currentSchema: "sales") let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) var session = ConnectionSession(connection: connection, driver: adapter) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -157,8 +157,8 @@ struct DatabaseManagerDatabaseSwitchTests { let updated = DatabaseManager.shared.session(for: connection.id) #expect(pluginDriver.switchedDatabases == ["other_db"]) - #expect(updated?.currentDatabase == "other_db") - #expect(updated?.currentSchema == "dbo") + #expect(updated?.browseDatabase == "other_db") + #expect(updated?.browseSchema == "dbo") #expect(pluginDriver.currentSchema == "dbo") } @@ -168,12 +168,12 @@ struct DatabaseManagerDatabaseSwitchTests { let pluginDriver = DatabaseSwitchingDriver(currentSchema: "custom") let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver) var session = ConnectionSession(connection: connection, driver: adapter) - session.currentSchema = "custom" + session.browseSchema = "custom" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } try await DatabaseManager.shared.switchDatabase(to: "other_db", for: connection.id, persist: false) - #expect(DatabaseManager.shared.session(for: connection.id)?.currentSchema == adapter.currentSchema) + #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == adapter.currentSchema) } } diff --git a/TableProTests/Core/Database/ScopedDriverRoutingTests.swift b/TableProTests/Core/Database/ScopedDriverRoutingTests.swift new file mode 100644 index 000000000..a8e2b15c4 --- /dev/null +++ b/TableProTests/Core/Database/ScopedDriverRoutingTests.swift @@ -0,0 +1,154 @@ +// +// ScopedDriverRoutingTests.swift +// TableProTests +// +// Where a scoped operation runs. The route is decided from plugin capabilities, never +// from a database-type list, because `DatabaseType` is open (#2026). +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Scoped driver routing", .serialized) +@MainActor +struct ScopedDriverRoutingTests { + private static func makeSession( + type: DatabaseType, + browseDatabase: String + ) -> DatabaseConnection { + let connection = TestFixtures.makeConnection(database: browseDatabase, type: type) + var session = ConnectionSession(connection: connection) + session.browseDatabase = browseDatabase + DatabaseManager.shared.injectSession(session, for: connection.id) + return connection + } + + private static func scope(_ connection: DatabaseConnection, database: String) -> DatabaseScope { + DatabaseScope(connectionId: connection.id, database: database, schema: nil) + } + + @Test("A pin-capable engine keeps the user's SQL on the session driver") + func pinCapableEngineUsesTheSessionDriver() throws { + let connection = Self.makeSession(type: .mysql, browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let foreign = Self.scope(connection, database: "orders") + + #expect(DatabaseManager.shared.executionRoute(for: foreign) == .sessionDriver) + } + + @Test("A reconnect-required engine stays on the session driver for its own database") + func reconnectRequiredEngineStaysOnItsOwnDatabase() throws { + let connection = Self.makeSession(type: .postgresql, browseDatabase: "orders") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let own = Self.scope(connection, database: "orders") + + #expect(DatabaseManager.shared.executionRoute(for: own) == .sessionDriver) + } + + @Test("A connection with no database selected still runs on the session driver") + func serverScopedWorkStaysOnTheSessionDriver() { + let connection = Self.makeSession(type: .mysql, browseDatabase: "") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let serverScoped = Self.scope(connection, database: "") + + #expect(serverScoped.isServerScoped) + #expect(DatabaseManager.shared.executionRoute(for: serverScoped) == .sessionDriver) + #expect(DatabaseManager.shared.metadataRoute(for: serverScoped) == .sessionDriver) + } + + @Test("A reconnect-required engine runs a foreign database on a pooled connection") + func reconnectRequiredEngineOnAForeignDatabasePools() throws { + let connection = Self.makeSession(type: .postgresql, browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let foreign = Self.scope(connection, database: "orders") + + #expect(DatabaseManager.shared.executionRoute(for: foreign) == .pooled) + } + + @Test("An engine that can neither pin nor pool reports the tab's database instead of guessing") + func engineThatCanNeitherPinNorPoolIsUnavailable() throws { + let connection = Self.makeSession(type: .pglite, browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + #expect(DatabaseType.pglite.supportsConnectionPooling == false) + #expect(PluginManager.shared.requiresReconnectForDatabaseSwitch(for: .pglite)) + + let foreign = Self.scope(connection, database: "orders") + + guard case .unavailable(let message) = DatabaseManager.shared.executionRoute(for: foreign) else { + Issue.record("Expected .unavailable for an engine that can neither pin nor pool") + return + } + #expect(message.contains("orders")) + } + + @Test("A single-database engine never leaves the session driver") + func singleDatabaseEnginesNeverLeaveTheSessionDriver() throws { + for type in [DatabaseType.sqlite, DatabaseType.duckdb] { + let connection = Self.makeSession(type: type, browseDatabase: "main") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + #expect(PluginManager.shared.supportsDatabaseSwitching(for: type) == false) + + let own = Self.scope(connection, database: "main") + let foreign = Self.scope(connection, database: "another_file") + + #expect(DatabaseManager.shared.executionRoute(for: own) == .sessionDriver) + #expect( + DatabaseManager.shared.executionRoute(for: foreign) == .sessionDriver, + "A second read-write handle on the same file would fight the session driver's lock" + ) + } + } + + @Test("A metadata read on a poolable engine leaves the shared driver where it is") + func metadataReadsPoolWhenTheEngineCan() throws { + let connection = Self.makeSession(type: .mysql, browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let foreign = Self.scope(connection, database: "orders") + + #expect(DatabaseManager.shared.metadataRoute(for: foreign) == .pooled) + } + + @Test("A metadata read falls back to the session driver when the engine cannot pool") + func metadataReadsFallBackToTheSessionDriver() throws { + let connection = Self.makeSession(type: .pglite, browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = Self.scope(connection, database: "orders") + + #expect(DatabaseManager.shared.metadataRoute(for: scope) == .sessionDriver) + } + + @Test("An engine that selects its database from a connection field is never pooled") + func connectionFieldScopedEngineIsNeverPooled() throws { + let connection = Self.makeSession(type: .redis, browseDatabase: "0") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = Self.scope(connection, database: "3") + + #expect(DatabaseManager.shared.metadataRoute(for: scope) == .sessionDriver) + #expect(DatabaseManager.shared.executionRoute(for: scope) == .sessionDriver) + } + + @Test("Without a session every route is unavailable") + func noSessionIsAlwaysUnavailable() throws { + let orphan = DatabaseScope(connectionId: UUID(), database: "orders", schema: nil) + + guard case .unavailable = DatabaseManager.shared.executionRoute(for: orphan) else { + Issue.record("Expected .unavailable execution route without a session") + return + } + guard case .unavailable = DatabaseManager.shared.metadataRoute(for: orphan) else { + Issue.record("Expected .unavailable metadata route without a session") + return + } + } +} diff --git a/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift index a76224819..4c44da0b4 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/DescribeTableToolTests.swift @@ -15,6 +15,13 @@ struct DescribeTableToolTests { #expect(required == ["connection_id", "table"]) } + @Test("Tool accepts an explicit database and schema") + func declaresScopeParameters() { + let properties = DescribeTableTool.inputSchema["properties"] + #expect(properties?["database"]?["type"]?.stringValue == "string") + #expect(properties?["schema"]?["type"]?.stringValue == "string") + } + @Test("Missing connection_id returns invalidParams") func missingConnectionId() async throws { let tool = DescribeTableTool() diff --git a/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift index 6b6c0eb1d..5eab046b4 100644 --- a/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift +++ b/TableProTests/Core/MCP/Protocol/Tools/GetTableDdlToolTests.swift @@ -15,6 +15,13 @@ struct GetTableDdlToolTests { #expect(required == ["connection_id", "table"]) } + @Test("Tool accepts an explicit database and schema") + func declaresScopeParameters() { + let properties = GetTableDdlTool.inputSchema["properties"] + #expect(properties?["database"]?["type"]?.stringValue == "string") + #expect(properties?["schema"]?["type"]?.stringValue == "string") + } + @Test("Missing connection_id returns invalidParams") func missingConnectionId() async throws { let tool = GetTableDdlTool() diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index c2042bccb..cbbb8e35a 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -55,12 +55,15 @@ struct DatabaseTreeMetadataServiceTests { } } +/// Uses PGlite because it is the one engine that cannot open a pooled connection, so a +/// metadata read stays on the injected session driver instead of trying to dial a real +/// server. Every other engine now reaches the tree through the pool. @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 connection = TestFixtures.makeConnection(type: .pglite) let driver = MockDatabaseDriver(connection: connection) driver.schemaTablesToReturn = ["public": [TestFixtures.makeTableInfo(name: "users")]] @@ -87,7 +90,7 @@ struct DatabaseTreeMetadataServiceRefreshTests { @Test("reload refetches every loaded schema, not just the one that changed") func refreshReloadsAllLoadedSchemas() async { - let connection = TestFixtures.makeConnection() + let connection = TestFixtures.makeConnection(type: .pglite) let driver = MockDatabaseDriver(connection: connection) driver.schemaTablesToReturn = [ "public": [TestFixtures.makeTableInfo(name: "users")], diff --git a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift index 007f9274b..7a562ccff 100644 --- a/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift +++ b/TableProTests/Core/Services/Query/MetadataConnectionPoolTests.swift @@ -68,6 +68,24 @@ struct MetadataConnectionPoolTests { try await MetadataConnectionPool.switchDatabase(driver, to: "shop", timeoutSeconds: 1) } } + + @Test("withDriver refuses a scope whose connection has no live session") + func withDriverRequiresALiveSession() async throws { + let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + let ranBody = PoolBodyFlag() + + await #expect(throws: DatabaseError.self) { + try await MetadataConnectionPool.shared.withDriver(scope: scope) { _ in + ranBody.value = true + } + } + + #expect(!ranBody.value) + } +} + +private final class PoolBodyFlag: @unchecked Sendable { + var value = false } @Suite("MetadataConnectionPool connection plan") diff --git a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift index 89213f5ff..8c9d0c7c4 100644 --- a/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift +++ b/TableProTests/Core/Services/Query/SchemaRefreshServiceTests.swift @@ -3,7 +3,9 @@ // TableProTests // // Tests that a connection's schema refresh runs once no matter how many -// windows request it (#1946). +// windows request it (#1946), and that it reads the browse scope rather than +// any open tab's scope (#2026): the sidebar object list is the one thing that +// genuinely follows the user's database selection. // import Foundation @@ -12,26 +14,36 @@ import TableProPluginKit import Testing @MainActor -private final class FakeMetadataDriverProvider: MetadataDriverProviding { +private final class FakeScopedMetadataProvider: ScopedMetadataProviding { let driver: MockDatabaseDriver var acquisitionCount = 0 var errorToThrow: Error? + var browseDatabase = "testdb" + var browseSchema: String? + private(set) var requestedScopes: [DatabaseScope] = [] + private(set) var requestedWorkloads: [MetadataConnectionPool.Workload] = [] init(driver: MockDatabaseDriver) { self.driver = driver } func withMetadataDriver( - connectionId: UUID, + scope: DatabaseScope, workload: MetadataConnectionPool.Workload, _ body: @Sendable @escaping (DatabaseDriver) async throws -> T ) async throws -> T { acquisitionCount += 1 + requestedScopes.append(scope) + requestedWorkloads.append(workload) if let errorToThrow { throw errorToThrow } return try await body(driver) } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { + DatabaseScope(connectionId: connectionId, database: browseDatabase, schema: browseSchema) + } } @Suite("SchemaRefreshService") @@ -39,7 +51,7 @@ private final class FakeMetadataDriverProvider: MetadataDriverProviding { struct SchemaRefreshServiceTests { private func makeService( schemaService: SchemaService, - provider: FakeMetadataDriverProvider + provider: FakeScopedMetadataProvider ) -> SchemaRefreshService { SchemaRefreshService( schemaService: schemaService, @@ -52,7 +64,7 @@ struct SchemaRefreshServiceTests { func concurrentRefreshesRunOneLoad() async { let driver = MockDatabaseDriver() driver.tablesToReturn = [TableInfo(name: "orders", type: .table, rowCount: 0, schema: nil)] - let provider = FakeMetadataDriverProvider(driver: driver) + let provider = FakeScopedMetadataProvider(driver: driver) let schemaService = SchemaService() let service = makeService(schemaService: schemaService, provider: provider) let connection = TestFixtures.makeConnection() @@ -67,10 +79,50 @@ struct SchemaRefreshServiceTests { #expect(schemaService.state(for: connection.id) == .loaded(driver.tablesToReturn)) } + @Test("the sidebar refresh asks for the browse scope, not a tab's scope") + func refreshAsksForTheBrowseScope() async throws { + let driver = MockDatabaseDriver() + let provider = FakeScopedMetadataProvider(driver: driver) + provider.browseDatabase = "inventory" + provider.browseSchema = "dbo" + let schemaService = SchemaService() + let service = makeService(schemaService: schemaService, provider: provider) + let connection = TestFixtures.makeConnection(database: "saved_default") + + await service.refresh(connection: connection) + + #expect(provider.requestedScopes.count == 1) + let scope = try #require(provider.requestedScopes.first) + #expect(scope.connectionId == connection.id) + #expect(scope.database == "inventory") + #expect(scope.schema == "dbo") + #expect(provider.requestedWorkloads == [.bulk]) + } + + @Test("a connection with no browse scope fails the refresh instead of guessing") + func refreshWithoutABrowseScopeFails() async { + let driver = MockDatabaseDriver() + let provider = FakeScopedMetadataProvider(driver: driver) + provider.browseDatabase = "" + let schemaService = SchemaService() + let service = makeService(schemaService: schemaService, provider: provider) + let connection = TestFixtures.makeConnection() + + await service.refresh(connection: connection) + + #expect(provider.requestedScopes.isEmpty) + #expect(driver.fetchTablesCallCount == 0) + var isFailed = false + if case .failed = schemaService.state(for: connection.id) { + isFailed = true + } + #expect(isFailed) + } + @Test("a refresh requested after the previous one finished loads again") func sequentialRefreshesReload() async { let driver = MockDatabaseDriver() - let provider = FakeMetadataDriverProvider(driver: driver) + let provider = FakeScopedMetadataProvider(driver: driver) let schemaService = SchemaService() let service = makeService(schemaService: schemaService, provider: provider) let connection = TestFixtures.makeConnection() @@ -84,7 +136,7 @@ struct SchemaRefreshServiceTests { @Test("refreshes scoped to different databases do not join each other") func differentDatabaseScopesDoNotJoin() async { let driver = MockDatabaseDriver() - let provider = FakeMetadataDriverProvider(driver: driver) + let provider = FakeScopedMetadataProvider(driver: driver) let schemaService = SchemaService() let service = makeService(schemaService: schemaService, provider: provider) let connection = TestFixtures.makeConnection() @@ -99,7 +151,7 @@ struct SchemaRefreshServiceTests { @Test("a metadata connection failure surfaces a failed schema state") func metadataFailureSurfacesFailedState() async { let driver = MockDatabaseDriver() - let provider = FakeMetadataDriverProvider(driver: driver) + let provider = FakeScopedMetadataProvider(driver: driver) provider.errorToThrow = DatabaseError.connectionFailed("pool exhausted") let schemaService = SchemaService() let service = makeService(schemaService: schemaService, provider: provider) diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index 2755233b3..a49e68901 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -89,14 +89,14 @@ struct ConnectionSessionEquivalenceTests { #expect(!a.isContentViewEquivalent(to: b)) } - @Test("Returns false when currentSchema changes") - func falseWhenCurrentSchemaChanges() { + @Test("Returns false when browseSchema changes") + func falseWhenBrowseSchemaChanges() { let id = UUID() var a = makeSession(id: id) var b = makeSession(id: id) - a.currentSchema = "public" - b.currentSchema = "private" + a.browseSchema = "public" + b.browseSchema = "private" #expect(!a.isContentViewEquivalent(to: b)) } diff --git a/TableProTests/Models/DatabaseScopeTests.swift b/TableProTests/Models/DatabaseScopeTests.swift new file mode 100644 index 000000000..ab826a901 --- /dev/null +++ b/TableProTests/Models/DatabaseScopeTests.swift @@ -0,0 +1,167 @@ +// +// DatabaseScopeTests.swift +// TableProTests +// +// Pins the tab-binding half of #2026: a scope names the database and schema an +// operation runs against, it is resolved once, and it never re-derives itself from +// wherever the sidebar happens to be browsing. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("DatabaseScope") +struct DatabaseScopeTests { + @Test("A blank database is server scoped, not unbound") + func blankDatabaseIsServerScoped() { + let connectionId = UUID() + + let serverScoped = DatabaseScope(connectionId: connectionId, database: "", schema: "public") + #expect(serverScoped.isServerScoped) + #expect(serverScoped.database.isEmpty) + + let bound = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + #expect(!bound.isServerScoped) + } + + @Test("A blank schema normalises to nil") + func blankSchemaNormalisesToNil() throws { + let connectionId = UUID() + + let blank = DatabaseScope(connectionId: connectionId, database: "shop", schema: "") + #expect(blank.schema == nil) + + let missing = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + #expect(missing.schema == nil) + + let present = DatabaseScope(connectionId: connectionId, database: "shop", schema: "sales") + #expect(present.schema == "sales") + } + + @Test("qualifiedDescription names the schema only when there is one") + func qualifiedDescription() throws { + let connectionId = UUID() + + let flat = DatabaseScope(connectionId: connectionId, database: "shop", schema: nil) + #expect(flat.qualifiedDescription == "shop") + + let nested = DatabaseScope(connectionId: connectionId, database: "shop", schema: "sales") + #expect(nested.qualifiedDescription == "shop.sales") + } + + @Test("Two scopes on different databases are never equal") + func scopesOnDifferentDatabasesDiffer() throws { + let connectionId = UUID() + + let alpha = DatabaseScope(connectionId: connectionId, database: "alpha", schema: nil) + let beta = DatabaseScope(connectionId: connectionId, database: "beta", schema: nil) + + #expect(alpha != beta) + } +} + +@Suite("DatabaseManager scope resolution", .serialized) +@MainActor +struct DatabaseManagerScopeResolutionTests { + private static func makeSession( + browseDatabase: String?, + browseSchema: String? = nil, + savedDatabase: String = "saved_default" + ) -> DatabaseConnection { + let connection = TestFixtures.makeConnection(database: savedDatabase) + var session = ConnectionSession(connection: connection) + session.browseDatabase = browseDatabase + session.browseSchema = browseSchema + DatabaseManager.shared.injectSession(session, for: connection.id) + return connection + } + + @Test("An explicit database passes through untouched while the sidebar browses elsewhere") + func explicitDatabaseIsNotRewritten() throws { + let connection = Self.makeSession(browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = try #require( + DatabaseManager.shared.resolvedScope(database: "orders", schema: nil, for: connection.id) + ) + + #expect(scope.database == "orders") + #expect(scope.connectionId == connection.id) + } + + @Test("Only a blank database falls back to the browse cursor") + func blankDatabaseFallsBackToBrowseCursor() throws { + let connection = Self.makeSession(browseDatabase: "inventory") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let blank = try #require( + DatabaseManager.shared.resolvedScope(database: "", schema: nil, for: connection.id) + ) + #expect(blank.database == "inventory") + + let missing = try #require( + DatabaseManager.shared.resolvedScope(database: nil, schema: nil, for: connection.id) + ) + #expect(missing.database == "inventory") + } + + @Test("A blank browse cursor falls back to the connection's saved default database") + func blankBrowseCursorUsesSavedDefault() throws { + let connection = Self.makeSession(browseDatabase: nil, savedDatabase: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = try #require( + DatabaseManager.shared.resolvedScope(database: nil, schema: nil, for: connection.id) + ) + + #expect(scope.database == "saved_default") + } + + @Test("A resolved scope keeps its database after the browse cursor moves") + func resolvedScopeSurvivesABrowseCursorMove() throws { + let connection = Self.makeSession(browseDatabase: "alpha") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let scope = try #require( + DatabaseManager.shared.resolvedScope(database: nil, schema: nil, for: connection.id) + ) + #expect(scope.database == "alpha") + + var moved = try #require(DatabaseManager.shared.session(for: connection.id)) + moved.browseDatabase = "beta" + DatabaseManager.shared.injectSession(moved, for: connection.id) + + #expect(scope.database == "alpha") + #expect(DatabaseManager.shared.browseScope(for: connection.id)?.database == "beta") + } + + @Test("An explicit schema passes through and a blank one resolves to the browse schema") + func schemaResolution() throws { + let connection = Self.makeSession(browseDatabase: "orders", browseSchema: "dbo") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let explicit = try #require( + DatabaseManager.shared.resolvedScope(database: "orders", schema: "sales", for: connection.id) + ) + #expect(explicit.schema == "sales") + + let inherited = try #require( + DatabaseManager.shared.resolvedScope(database: "orders", schema: "", for: connection.id) + ) + #expect(inherited.schema == "dbo") + } + + @Test("Without a session there is neither a browse scope nor an inferred scope") + func noSessionYieldsNoScope() { + let connectionId = UUID() + + #expect(DatabaseManager.shared.browseScope(for: connectionId) == nil) + #expect(DatabaseManager.shared.resolvedScope(database: nil, schema: nil, for: connectionId) == nil) + #expect( + DatabaseManager.shared.resolvedScope(database: "orders", schema: nil, for: connectionId)?.database + == "orders" + ) + } +} diff --git a/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift b/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift new file mode 100644 index 000000000..e767d355f --- /dev/null +++ b/TableProTests/Models/Query/TabScopeIsWindowIndependentTests.swift @@ -0,0 +1,99 @@ +// +// TabScopeIsWindowIndependentTests.swift +// TableProTests +// +// A macOS window tab is a full NSWindow the user can drag out at any time, so +// "Move Tab to New Window" has to be semantically a no-op. That holds only while a +// tab's target is a pure function of the tab and its connection. #2026 was what +// happened when it was not: the tab read the window's sidebar selection instead. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("A tab's scope is independent of the window showing it", .serialized) +@MainActor +struct TabScopeIsWindowIndependentTests { + private static func makeCoordinator( + connection: DatabaseConnection, + tab: QueryTab + ) -> MainContentCoordinator { + let tabManager = QueryTabManager() + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + return MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + } + + private static func makeTab(database: String, schema: String?) -> QueryTab { + var tab = QueryTab(title: "orders", query: "SELECT 1", tabType: .table, tableName: "orders") + tab.tableContext.databaseName = database + tab.tableContext.schemaName = schema + return tab + } + + @Test("The same tab resolves the same scope under two windows browsing different databases") + func scopeDoesNotFollowTheWindow() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tab = Self.makeTab(database: "orders", schema: nil) + + var browsingOrders = ConnectionSession(connection: connection) + browsingOrders.browseDatabase = "orders" + DatabaseManager.shared.injectSession(browsingOrders, for: connection.id) + let windowOnOrders = Self.makeCoordinator(connection: connection, tab: tab) + let scopeFromOrders = try #require(windowOnOrders.scope(for: tab)) + + var browsingInventory = ConnectionSession(connection: connection) + browsingInventory.browseDatabase = "inventory" + DatabaseManager.shared.injectSession(browsingInventory, for: connection.id) + let windowOnInventory = Self.makeCoordinator(connection: connection, tab: tab) + let scopeFromInventory = try #require(windowOnInventory.scope(for: tab)) + + #expect(scopeFromOrders == scopeFromInventory) + #expect(scopeFromOrders.database == "orders") + } + + @Test("A bound tab keeps its schema when the browse schema moves") + func schemaDoesNotFollowTheBrowseCursor() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tab = Self.makeTab(database: "orders", schema: "sales") + + var session = ConnectionSession(connection: connection) + session.browseDatabase = "inventory" + session.browseSchema = "dbo" + DatabaseManager.shared.injectSession(session, for: connection.id) + + let coordinator = Self.makeCoordinator(connection: connection, tab: tab) + let scope = try #require(coordinator.scope(for: tab)) + + #expect(scope.database == "orders") + #expect(scope.schema == "sales") + } + + @Test("An unbound tab is seeded from the browse cursor, then stays put") + func anUnboundTabIsSeededOnceFromTheCursor() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tab = Self.makeTab(database: "", schema: nil) + + var session = ConnectionSession(connection: connection) + session.browseDatabase = "inventory" + DatabaseManager.shared.injectSession(session, for: connection.id) + + let coordinator = Self.makeCoordinator(connection: connection, tab: tab) + let seeded = try #require(coordinator.scope(for: tab)) + + #expect(seeded.database == "inventory") + } +} diff --git a/TableProTests/Services/WindowTitleResolverTests.swift b/TableProTests/Services/WindowTitleResolverTests.swift index 4e03d6f20..57b90124e 100644 --- a/TableProTests/Services/WindowTitleResolverTests.swift +++ b/TableProTests/Services/WindowTitleResolverTests.swift @@ -344,15 +344,24 @@ struct WindowTitleResolverTabSubtitleTests { #expect(subtitle == connection.name) } - @Test("Table tab with no table name falls back to the connection name") + @Test("Table tab with no table name still shows its bound database") func tableTabWithNilTableName() { var tab = QueryTab(id: UUID(), title: "x", query: "SELECT 1", tabType: .table) tab.tableContext.databaseName = "myapp" let subtitle = WindowTitleResolver.resolveSubtitle(tab: tab, connection: connection) - #expect(subtitle == connection.name) + #expect(subtitle == "myapp") } - @Test("Query tab never shows a table subtitle even with a resolved table name") + @Test("Query tab shows its bound database") + func queryTabShowsBoundDatabase() { + var tab = QueryTab(id: UUID(), title: "q", query: "SELECT 1", tabType: .query) + tab.tableContext.databaseName = "myapp" + tab.tableContext.schemaName = "public" + let subtitle = WindowTitleResolver.resolveSubtitle(tab: tab, connection: connection) + #expect(subtitle == "myapp ยท public") + } + + @Test("Unbound query tab falls back to the connection name") func queryTabReturnsConnectionName() { let tab = QueryTab(id: UUID(), title: "q", query: "SELECT 1", tabType: .query, tableName: "users") let subtitle = WindowTitleResolver.resolveSubtitle(tab: tab, connection: connection) @@ -417,12 +426,23 @@ struct WindowTitleResolverPayloadSubtitleTests { #expect(subtitle == connection.name) } - @Test("Query payload falls back to the connection name") + @Test("Unbound query payload falls back to the connection name") func queryPayloadReturnsConnectionName() { let payload = EditorTabPayload(connectionId: UUID(), tabType: .query, tableName: "users") let subtitle = WindowTitleResolver.resolveSubtitle(payload: payload, connection: connection) #expect(subtitle == connection.name) } + + @Test("Query payload with a database shows that database") + func queryPayloadWithDatabase() { + let payload = EditorTabPayload( + connectionId: UUID(), + tabType: .query, + databaseName: "myapp" + ) + let subtitle = WindowTitleResolver.resolveSubtitle(payload: payload, connection: connection) + #expect(subtitle == "myapp") + } } @Suite("QueryTab.fileDisplayTitle") diff --git a/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift b/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift new file mode 100644 index 000000000..8e28237e4 --- /dev/null +++ b/TableProTests/ViewModels/ERDiagramSchemaKeyTests.swift @@ -0,0 +1,69 @@ +// +// ERDiagramSchemaKeyTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ER diagram schema key") +@MainActor +struct ERDiagramSchemaKeyTests { + @Test("A schema key carries the schema the diagram was opened on") + func readsSchemaFromKey() { + let schema = ERDiagramViewModel.resolveSchemaName( + fromSchemaKey: "app.reporting", + databaseName: "app" + ) + #expect(schema == "reporting") + } + + @Test("An engine without schemas resolves to no schema") + func defaultMarkerMeansNoSchema() { + let schema = ERDiagramViewModel.resolveSchemaName( + fromSchemaKey: "app.default", + databaseName: "app" + ) + #expect(schema == nil) + } + + @Test("A database name containing a dot keeps its schema intact") + func dottedDatabaseName() { + let schema = ERDiagramViewModel.resolveSchemaName( + fromSchemaKey: "my.app.public", + databaseName: "my.app" + ) + #expect(schema == "public") + } + + @Test("A key that is only a database name resolves to no schema") + func keyWithoutSchemaComponent() { + #expect(ERDiagramViewModel.resolveSchemaName(fromSchemaKey: "app", databaseName: "app") == nil) + #expect(ERDiagramViewModel.resolveSchemaName(fromSchemaKey: "app.", databaseName: "app") == nil) + } + + @Test("A key for another database resolves to no schema") + func keyForAnotherDatabase() { + let schema = ERDiagramViewModel.resolveSchemaName( + fromSchemaKey: "other.public", + databaseName: "app" + ) + #expect(schema == nil) + } + + @Test("An empty database name resolves to no schema") + func emptyDatabaseName() { + #expect(ERDiagramViewModel.resolveSchemaName(fromSchemaKey: ".public", databaseName: "") == nil) + } + + @Test("The view model binds to the schema its key names") + func viewModelBindsSchema() { + let viewModel = ERDiagramViewModel( + connectionId: UUID(), + databaseName: "app", + schemaKey: "app.reporting" + ) + #expect(viewModel.schemaName == "reporting") + } +} diff --git a/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift b/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift index c5bc76a94..c27ab9896 100644 --- a/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift +++ b/TableProTests/Views/ERDiagram/ERDiagramCanvasContainerViewTests.swift @@ -8,7 +8,7 @@ import Testing @MainActor struct ERDiagramCanvasContainerViewTests { private func makeContainer() -> (ERDiagramCanvasContainerView, ERDiagramViewModel) { - let viewModel = ERDiagramViewModel(connectionId: UUID(), schemaKey: "test") + let viewModel = ERDiagramViewModel(connectionId: UUID(), databaseName: "test", schemaKey: "test") let view = ERDiagramCanvasContainerView(rootView: Color.clear, viewModel: viewModel) return (view, viewModel) } diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index 5d77a7009..0484a2c63 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -84,7 +84,7 @@ struct CommandActionsBulkCloseTests { current.coordinator.tabManager.addTab(initialQuery: "SELECT 1", databaseName: "db_a") sibling.coordinator.tabManager.addTab(initialQuery: "SELECT 2", databaseName: "db_b") - #expect(current.actions.activeDatabaseName == "db_a") + #expect(current.actions.browseDatabaseName == "db_a") #expect(current.actions.canCloseTabsForOtherDatabases) } diff --git a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift index 06cb42a0a..0ff731c0a 100644 --- a/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift +++ b/TableProTests/Views/Main/CoordinatorColumnVisibilityTests.swift @@ -151,7 +151,7 @@ struct CoordinatorColumnVisibilityTests { coordinator.hideAllColumns(["a", "b", "c", "d"]) coordinator.schemaColumns.store( (columns: ["b", "d", "e"], primaryKeys: []), - for: coordinator.schemaColumnsKey("users", schema: nil) + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) coordinator.pruneHiddenColumns(currentColumns: ["b", "d", "e"]) @@ -209,7 +209,7 @@ struct CoordinatorColumnVisibilityTests { FileColumnLayoutPersister.shared.saveHiddenColumns(["email"], for: key) coordinator.schemaColumns.store( (columns: ["id", "name", "email"], primaryKeys: ["id"]), - for: coordinator.schemaColumnsKey("users", schema: nil) + for: coordinator.schemaColumnsKey("users", scope: coordinator.scope(for: createdTab)) ) coordinator.restoreLastHiddenColumnsForTable() diff --git a/TableProTests/Views/Main/DataRefreshScopeTests.swift b/TableProTests/Views/Main/DataRefreshScopeTests.swift new file mode 100644 index 000000000..9a46a0e07 --- /dev/null +++ b/TableProTests/Views/Main/DataRefreshScopeTests.swift @@ -0,0 +1,138 @@ +// +// DataRefreshScopeTests.swift +// TableProTests +// +// A data-changed broadcast reaches every window of a connection. #2026 symptom 2 was +// the whole window following a save: the refresh carried no scope, so every window +// refetched against whatever database the save had pinned. The request now names the +// scope the change landed in, and each receiver matches it against the scope it owns: +// the sidebar against the browse cursor, an open tab against the tab's own scope. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("Data refresh scoping", .serialized) +@MainActor +struct DataRefreshScopeTests { + private static func makeCoordinator( + connection: DatabaseConnection, + browseDatabase: String, + tabDatabase: String? = nil + ) -> (MainContentCoordinator, QueryTabManager) { + var session = ConnectionSession(connection: connection) + session.browseDatabase = browseDatabase + DatabaseManager.shared.injectSession(session, for: connection.id) + + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + + if let tabDatabase { + var tab = QueryTab(title: "orders", query: "SELECT 1", tabType: .table, tableName: "orders") + tab.tableContext.databaseName = tabDatabase + tabManager.tabs.append(tab) + tabManager.selectedTabId = tab.id + } + + return (coordinator, tabManager) + } + + @Test("A scoped refresh is ignored by a window browsing another database") + func scopedRefreshSkipsAWindowBrowsingElsewhere() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, _) = Self.makeCoordinator( + connection: connection, + browseDatabase: "inventory", + tabDatabase: "inventory" + ) + + let elsewhere = DatabaseScope(connectionId: connection.id, database: "orders", schema: nil) + let request = DataRefreshRequest(connectionId: connection.id, scope: elsewhere) + + #expect(request.scope != coordinator.selectedTabScope) + #expect(request.scope?.database != coordinator.browseDatabaseName) + } + + @Test("An unscoped refresh still reaches every window") + func unscopedRefreshReachesEveryone() { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, _) = Self.makeCoordinator( + connection: connection, + browseDatabase: "inventory", + tabDatabase: "orders" + ) + + let request = DataRefreshRequest(connectionId: connection.id) + + #expect(request.scope == nil) + #expect(coordinator.selectedTabScope != nil) + #expect(coordinator.browseScope != nil) + } + + @Test("A refresh scoped to a tab's own scope reaches that tab even when the sidebar moved away") + func scopedRefreshReachesItsOwnTab() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, _) = Self.makeCoordinator( + connection: connection, + browseDatabase: "inventory", + tabDatabase: "orders" + ) + + let tabScope = try #require(coordinator.selectedTabScope) + #expect(tabScope.database == "orders") + + let request = DataRefreshRequest(connectionId: connection.id, scope: tabScope) + + #expect(request.scope == tabScope) + #expect( + request.scope?.database != coordinator.browseDatabaseName, + "Matching a structure tab on the browse database would drop its own post-save reload" + ) + } + + @Test("The browse cursor and an open tab's scope are independent") + func browseScopeAndTabScopeAreIndependent() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, _) = Self.makeCoordinator( + connection: connection, + browseDatabase: "inventory", + tabDatabase: "orders" + ) + + let browseScope = try #require(coordinator.browseScope) + let tabScope = try #require(coordinator.selectedTabScope) + + #expect(browseScope.database == "inventory") + #expect(tabScope.database == "orders") + #expect(browseScope != tabScope) + } + + @Test("A refresh for another connection never matches this window") + func refreshForAnotherConnectionIsIgnored() throws { + let connection = TestFixtures.makeConnection(database: "saved_default") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + let (coordinator, _) = Self.makeCoordinator( + connection: connection, + browseDatabase: "orders", + tabDatabase: "orders" + ) + + let otherConnectionId = UUID() + let otherScope = DatabaseScope(connectionId: otherConnectionId, database: "orders", schema: nil) + let request = DataRefreshRequest(connectionId: otherConnectionId, scope: otherScope) + + #expect(request.connectionId != coordinator.connectionId) + #expect(request.scope != coordinator.selectedTabScope) + } +} diff --git a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift index 9c5e00259..d98b194a3 100644 --- a/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift +++ b/TableProTests/Views/Main/DefaultSortInitialQueryTests.swift @@ -38,7 +38,7 @@ struct DefaultSortInitialQueryTests { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( (columns: ["id", "name", "email"], primaryKeys: ["id"]), - for: coordinator.schemaColumnsKey("users", schema: nil) + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) await withDefaultSortBehavior(.primaryKey) { @@ -57,7 +57,7 @@ struct DefaultSortInitialQueryTests { let (coordinator, tabManager, index) = makeCoordinator(tableName: "invoices") coordinator.schemaColumns.store( (columns: ["customer_uid", "order_uid", "total"], primaryKeys: ["customer_uid", "order_uid"]), - for: coordinator.schemaColumnsKey("invoices", schema: nil) + for: coordinator.schemaColumnsKey("invoices", scope: coordinator.selectedTabScope) ) await withDefaultSortBehavior(.primaryKey) { @@ -77,7 +77,7 @@ struct DefaultSortInitialQueryTests { let (coordinator, tabManager, index) = makeCoordinator(tableName: "logs") coordinator.schemaColumns.store( (columns: ["message", "level"], primaryKeys: []), - for: coordinator.schemaColumnsKey("logs", schema: nil) + for: coordinator.schemaColumnsKey("logs", scope: coordinator.selectedTabScope) ) let originalQuery = tabManager.tabs[index].content.query @@ -154,7 +154,7 @@ struct DefaultSortInitialQueryTests { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( (columns: ["id", "name"], primaryKeys: ["id"]), - for: coordinator.schemaColumnsKey("users", schema: nil) + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) let userSort = SortState(columns: [SortColumn(columnIndex: 1, direction: .descending)], source: .user) tabManager.mutate(at: index) { $0.sortState = userSort } @@ -171,7 +171,7 @@ struct DefaultSortInitialQueryTests { let (coordinator, tabManager, index) = makeCoordinator(tableName: "users") coordinator.schemaColumns.store( (columns: ["a", "id", "name"], primaryKeys: ["id"]), - for: coordinator.schemaColumnsKey("users", schema: nil) + for: coordinator.schemaColumnsKey("users", scope: coordinator.selectedTabScope) ) tabManager.mutate(at: index) { $0.columnLayout.hiddenColumns = ["a"] } diff --git a/TableProTests/Views/Main/FKNavigationTests.swift b/TableProTests/Views/Main/FKNavigationTests.swift index a4cc36a06..86f7b7788 100644 --- a/TableProTests/Views/Main/FKNavigationTests.swift +++ b/TableProTests/Views/Main/FKNavigationTests.swift @@ -52,7 +52,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) #expect(tabManager.tabs.count == 1) @@ -79,7 +79,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "users", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) let tabId = tabManager.selectedTab?.id #expect(tabManager.tabs.count == 1) @@ -97,7 +97,7 @@ struct FKNavigationTests { func nilReferencedSchemaResolvesActiveSchema() throws { let connection = TestFixtures.makeConnection(database: "db_a", type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -113,7 +113,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) let fkInfo = TestFixtures.makeForeignKeyInfo(referencedTable: "users", referencedColumn: "id") @@ -136,7 +136,7 @@ struct FKNavigationTests { ) defer { coordinator.teardown() } - tabManager.addTab(initialQuery: "SELECT * FROM orders", databaseName: coordinator.activeDatabaseName) + tabManager.addTab(initialQuery: "SELECT * FROM orders", databaseName: coordinator.browseDatabaseName) tabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } let originalTabId = tabManager.selectedTab?.id @@ -169,7 +169,7 @@ struct FKNavigationTests { ) defer { coordinator.teardown() } - tabManager.addTab(initialQuery: "SELECT 1", databaseName: coordinator.activeDatabaseName) + tabManager.addTab(initialQuery: "SELECT 1", databaseName: coordinator.browseDatabaseName) let originalTabId = tabManager.selectedTab?.id var opened: [EditorTabPayload] = [] @@ -201,7 +201,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) coordinator.changeManager.hasChanges = true @@ -246,14 +246,14 @@ struct FKNavigationTests { originTabManager.addTab( initialQuery: "SELECT * FROM orders", - databaseName: originCoordinator.activeDatabaseName + databaseName: originCoordinator.browseDatabaseName ) originTabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } try targetTabManager.addTableTab( tableName: "users", databaseType: connection.type, - databaseName: targetCoordinator.activeDatabaseName + databaseName: targetCoordinator.browseDatabaseName ) targetTabManager.mutate(at: 0) { $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] @@ -303,14 +303,14 @@ struct FKNavigationTests { originTabManager.addTab( initialQuery: "SELECT * FROM orders", - databaseName: originCoordinator.activeDatabaseName + databaseName: originCoordinator.browseDatabaseName ) originTabManager.mutate(at: 0) { $0.execution.lastExecutedAt = Date() } try targetTabManager.addTableTab( tableName: "users", databaseType: connection.type, - databaseName: targetCoordinator.activeDatabaseName + databaseName: targetCoordinator.browseDatabaseName ) targetTabManager.mutate(at: 0) { $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] @@ -346,7 +346,7 @@ struct FKNavigationTests { try originTabManager.addTableTab( tableName: "users", databaseType: connection.type, - databaseName: originCoordinator.activeDatabaseName + databaseName: originCoordinator.browseDatabaseName ) originTabManager.mutate(at: 0) { $0.filterState.filters = [TableFilter(columnName: "id", filterOperator: .equal, value: "42")] @@ -355,7 +355,7 @@ struct FKNavigationTests { try originTabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: originCoordinator.activeDatabaseName + databaseName: originCoordinator.browseDatabaseName ) var opened: [EditorTabPayload] = [] @@ -383,13 +383,13 @@ struct FKNavigationTests { let ordersKey = ColumnLayoutTableKey( connectionId: connection.id, - databaseName: coordinator.activeDatabaseName, + databaseName: coordinator.browseDatabaseName, schemaName: nil, tableName: "orders" ) let usersKey = ColumnLayoutTableKey( connectionId: connection.id, - databaseName: coordinator.activeDatabaseName, + databaseName: coordinator.browseDatabaseName, schemaName: nil, tableName: "users" ) @@ -399,7 +399,7 @@ struct FKNavigationTests { FilterSettingsStorage.shared.clearLastFilters( for: "orders", connectionId: connection.id, - databaseName: coordinator.activeDatabaseName, + databaseName: coordinator.browseDatabaseName, schemaName: nil ) } @@ -408,7 +408,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) let outgoingFilter = TableFilter(columnName: "status", filterOperator: .equal, value: "open") tabManager.mutate(at: 0) { @@ -425,7 +425,7 @@ struct FKNavigationTests { let savedForOrders = FilterSettingsStorage.shared.loadLastFilters( for: "orders", connectionId: connection.id, - databaseName: coordinator.activeDatabaseName, + databaseName: coordinator.browseDatabaseName, schemaName: nil ) #expect(savedForOrders.contains { $0.columnName == "status" && $0.value == "open" }) @@ -447,7 +447,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) guard let tabId = tabManager.selectedTab?.id else { Issue.record("expected a selected tab") @@ -484,7 +484,7 @@ struct FKNavigationTests { try tabManager.addTableTab( tableName: "orders", databaseType: connection.type, - databaseName: coordinator.activeDatabaseName + databaseName: coordinator.browseDatabaseName ) let tabId = tabManager.tabs[0].id tabManager.mutate(at: 0) { $0.tableContext.primaryKeyColumns = ["id"] } diff --git a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift index f80d51dba..bac32b6c1 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorLazyLoadTests.swift @@ -217,20 +217,6 @@ struct MainContentCoordinatorLazyLoadTests { #expect(coordinator.pendingLoadTrigger == .restore) } - @Test("restoreSchemaAndRunQuery defers via pendingLoadTrigger instead of running a query when the driver is not ready") - func restoreSchemaDefersWhenDriverNil() async { - let (coordinator, tabManager) = makeCoordinator() - let tabId = addTableTab(to: tabManager) - coordinator.pendingLoadTrigger = nil - - await coordinator.restoreSchemaAndRunQuery("public") - - #expect(coordinator.pendingLoadTrigger == .userInitiated) - if let idx = tabManager.tabs.firstIndex(where: { $0.id == tabId }) { - #expect(tabManager.tabs[idx].execution.isExecuting == false) - } - } - // MARK: - Idempotency @Test("Idempotent: repeated calls with the same loaded state are no-ops") diff --git a/TableProTests/Views/Main/OpenTableTabTests.swift b/TableProTests/Views/Main/OpenTableTabTests.swift index 6b327e9ef..557f3440c 100644 --- a/TableProTests/Views/Main/OpenTableTabTests.swift +++ b/TableProTests/Views/Main/OpenTableTabTests.swift @@ -136,7 +136,7 @@ struct OpenTableTabTests { func bareTableNameResolvesActiveSchema() { let connection = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -160,7 +160,7 @@ struct OpenTableTabTests { func explicitSchemaWinsOverActiveSchema() { let connection = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift index 4d782f89a..799101cf7 100644 --- a/TableProTests/Views/Main/SessionStateFactoryTests.swift +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -120,7 +120,7 @@ struct SessionStateFactoryTests { func tablePayloadWithoutSchema_resolvesActiveSchema() { let conn = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: conn) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: conn.id) defer { DatabaseManager.shared.removeSession(for: conn.id) } @@ -135,7 +135,7 @@ struct SessionStateFactoryTests { func tablePayloadWithExplicitSchema_keepsIt() { let conn = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: conn) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: conn.id) defer { DatabaseManager.shared.removeSession(for: conn.id) } diff --git a/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift b/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift index 026d35061..cf6067b07 100644 --- a/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift +++ b/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift @@ -33,7 +33,7 @@ struct TableTabSchemaResolutionTests { func stampsSchemaAndRebuildsQuery() throws { let connection = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -61,7 +61,7 @@ struct TableTabSchemaResolutionTests { func leavesResolvedSchemaUntouched() throws { let connection = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -109,7 +109,7 @@ struct TableTabSchemaResolutionTests { func noOpForQueryTab() throws { let connection = TestFixtures.makeConnection(type: .postgresql) var session = ConnectionSession(connection: connection) - session.currentSchema = "sales" + session.browseSchema = "sales" DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } @@ -139,7 +139,7 @@ struct TableTabListingSchemaTests { let connection = TestFixtures.makeConnection(database: "AppDb", type: .mssql) let driver = MockDatabaseDriver(connection: connection) var session = ConnectionSession(connection: connection, driver: driver) - session.currentSchema = sessionSchema + session.browseSchema = sessionSchema DatabaseManager.shared.injectSession(session, for: connection.id) defer { DatabaseManager.shared.removeSession(for: connection.id) } diff --git a/TableProTests/Views/Structure/TableStructureLoaderScopeTests.swift b/TableProTests/Views/Structure/TableStructureLoaderScopeTests.swift new file mode 100644 index 000000000..bb490301c --- /dev/null +++ b/TableProTests/Views/Structure/TableStructureLoaderScopeTests.swift @@ -0,0 +1,154 @@ +// +// TableStructureLoaderScopeTests.swift +// TableProTests +// +// The test that would have caught #2026 symptom 1. A structure tab is bound to the +// database it was opened on. Once the sidebar moved to another database, every +// structure read resolved its database from ambient session state instead, so +// `SHOW FULL COLUMNS FROM `t`` ran on the sidebar's database and the tab reported +// `Table 'B.t' doesn't exist` about its own table. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +/// Captures every scope the loader hands to the metadata layer. A recorded scope that +/// is not the loader's own is the bug. +@MainActor +private final class RecordingMetadataProvider: ScopedMetadataProviding { + private(set) var requestedScopes: [DatabaseScope] = [] + private(set) var requestedWorkloads: [MetadataConnectionPool.Workload] = [] + private(set) var browseScopeCallCount = 0 + var browseScopeToReturn: DatabaseScope? + + let driver: MockDatabaseDriver + + init(driver: MockDatabaseDriver = MockDatabaseDriver()) { + self.driver = driver + } + + func withMetadataDriver( + scope: DatabaseScope, + workload: MetadataConnectionPool.Workload, + _ body: @Sendable @escaping (DatabaseDriver) async throws -> T + ) async throws -> T { + requestedScopes.append(scope) + requestedWorkloads.append(workload) + return try await body(driver) + } + + func browseScope(for connectionId: UUID) -> DatabaseScope? { + browseScopeCallCount += 1 + return browseScopeToReturn + } +} + +@Suite("TableStructureLoader scope binding", .serialized) +@MainActor +struct TableStructureLoaderScopeTests { + /// Moves the sidebar to `browseDatabase` so any ambient fallback is visibly wrong. + private static func makeBrowsingSession( + browseDatabase: String, + browseSchema: String? = nil, + type: DatabaseType = .mysql + ) -> DatabaseConnection { + let connection = TestFixtures.makeConnection(database: "saved_default", type: type) + var session = ConnectionSession(connection: connection) + session.browseDatabase = browseDatabase + session.browseSchema = browseSchema + DatabaseManager.shared.injectSession(session, for: connection.id) + return connection + } + + private static func exerciseEveryRead(_ loader: TableStructureLoader) async throws { + _ = try await loader.columns() + _ = try await loader.indexes() + _ = try await loader.foreignKeys() + _ = try await loader.triggers() + _ = try await loader.coreTabs(includingForeignKeys: true) + _ = try await loader.perform { try await $0.fetchTableDDL(table: "t") } + } + + @Test("Every structure read runs on the tab's database, never on the browsed one") + func everyReadUsesTheTabsDatabase() async throws { + let connection = Self.makeBrowsingSession(browseDatabase: "B") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let browseScope = try #require(DatabaseManager.shared.browseScope(for: connection.id)) + let provider = RecordingMetadataProvider() + provider.browseScopeToReturn = browseScope + + let tabScope = DatabaseScope(connectionId: connection.id, database: "A", schema: nil) + let loader = TableStructureLoader(scope: tabScope, tableName: "t", provider: provider) + + try await Self.exerciseEveryRead(loader) + + #expect(provider.requestedScopes.count == 6) + #expect(provider.requestedScopes.allSatisfy { $0 == tabScope }) + #expect(provider.requestedScopes.allSatisfy { $0.database == "A" }) + #expect(provider.requestedScopes.allSatisfy { $0.schema == nil }) + #expect(provider.requestedScopes.allSatisfy { $0 != browseScope }) + #expect(provider.browseScopeCallCount == 0) + #expect(provider.requestedWorkloads.allSatisfy { $0 == .interactive }) + } + + @Test("The tab's schema is carried too, not the schema the sidebar is on") + func everyReadUsesTheTabsSchema() async throws { + let connection = Self.makeBrowsingSession( + browseDatabase: "reporting", + browseSchema: "dbo", + type: .mssql + ) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let browseScope = try #require(DatabaseManager.shared.browseScope(for: connection.id)) + #expect(browseScope.schema == "dbo") + + let provider = RecordingMetadataProvider() + provider.browseScopeToReturn = browseScope + + let tabScope = DatabaseScope(connectionId: connection.id, database: "orders", schema: "sales") + let loader = TableStructureLoader(scope: tabScope, tableName: "t", provider: provider) + + try await Self.exerciseEveryRead(loader) + + #expect(provider.requestedScopes.count == 6) + #expect(provider.requestedScopes.allSatisfy { $0.database == "orders" }) + #expect(provider.requestedScopes.allSatisfy { $0.schema == "sales" }) + #expect(provider.requestedScopes.allSatisfy { $0 != browseScope }) + } + + @Test("The loader reads the table it was built for on every call") + func everyReadTargetsTheLoadersTable() async throws { + let connection = Self.makeBrowsingSession(browseDatabase: "B") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let provider = RecordingMetadataProvider() + let tabScope = DatabaseScope(connectionId: connection.id, database: "A", schema: nil) + let loader = TableStructureLoader(scope: tabScope, tableName: "orders", provider: provider) + + _ = try await loader.columns() + _ = try await loader.coreTabs(includingForeignKeys: false) + + #expect(provider.driver.fetchColumnsCalls == ["orders", "orders"]) + } + + @Test("A server-scoped loader passes its own scope through, never the browsed one") + func serverScopedLoaderNeverFallsBackToTheBrowsedDatabase() async throws { + let connection = Self.makeBrowsingSession(browseDatabase: "B") + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let provider = RecordingMetadataProvider() + provider.browseScopeToReturn = DatabaseManager.shared.browseScope(for: connection.id) + let serverScoped = DatabaseScope(connectionId: connection.id, database: "", schema: nil) + let loader = TableStructureLoader(scope: serverScoped, tableName: "t", provider: provider) + + _ = try await loader.columns() + + #expect(serverScoped.isServerScoped) + #expect(provider.requestedScopes == [serverScoped]) + #expect(provider.browseScopeCallCount == 0) + } +} diff --git a/TableProTests/Views/SwitchContainerTests.swift b/TableProTests/Views/SwitchContainerTests.swift index 6dab18d4e..04bff7bdf 100644 --- a/TableProTests/Views/SwitchContainerTests.swift +++ b/TableProTests/Views/SwitchContainerTests.swift @@ -37,6 +37,6 @@ struct SwitchContainerTests { #expect(driver.switchSchemaCallCount == 1) #expect(driver.currentSchema == "HR") #expect(coordinator.toolbarState.currentSchema == "HR") - #expect(DatabaseManager.shared.session(for: connection.id)?.currentSchema == "HR") + #expect(DatabaseManager.shared.session(for: connection.id)?.browseSchema == "HR") } } diff --git a/docs/databases/pglite.mdx b/docs/databases/pglite.mdx index 970911179..101daf0bb 100644 --- a/docs/databases/pglite.mdx +++ b/docs/databases/pglite.mdx @@ -48,7 +48,7 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. ## Limitations -- **Single connection.** PGlite serves one connection at a time. TablePro is built for this and keeps to one connection, but other tools that open connections in parallel will fail against the same server. +- **Single connection.** PGlite serves one connection at a time. TablePro is built for this and keeps to one connection, but other tools that open connections in parallel will fail against the same server. This also means PGlite cannot back a [cross-database tab](/databases/postgresql#cross-database-tabs) the way PostgreSQL, Redshift, and CockroachDB do: a PGlite tab bound to a database the connection is not on reports an error naming that database instead of running the query against the wrong one. - **No TLS.** The socket server rejects SSL, so SSL Mode is fixed to Disabled and the SSH, Cloudflare Tunnel, and SOCKS panes do not apply. - **Cancel does nothing.** PGlite has no backend process to signal, so cancelling a running query has no effect at the protocol level. Let long queries finish. diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 10cf2d9d3..41d9a1b3c 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -51,6 +51,14 @@ Connect to RDS or Aurora with your AWS identity instead of a static password: se **Backup & Restore**: **Backup Dump** and **Restore Dump** run `pg_dump` and `pg_restore`. Among TablePro's databases this is available for PostgreSQL and Redshift only. See [Backup & Restore](/features/backup-restore). +## Cross-Database Tabs + +A tab stays on the database and schema it was opened with for its whole life, even if the sidebar or another tab switches to a different one. See [Tabs](/features/tabs#database-binding). PostgreSQL, like Redshift and CockroachDB, changes database only by reconnecting: there is no in-place `USE` statement. So a tab bound to a database other than the connection's active one runs on a separate connection for that database instead. + +That separate connection means the tab does not share temp tables, session variables, or an open transaction with the query editor on the main connection. Keep session-scoped work, such as a multi-statement transaction or a `CREATE TEMP TABLE`, on tabs bound to the same database if they need to see each other's state. + +[PGlite](/databases/pglite) cannot open a second connection at all, so a PGlite tab bound to a database the connection is not on reports an error naming that database instead of running the query against the wrong one. + ## Advanced **~/.pgpass**: format `hostname:port:database:username:password`, wildcards (`*`) allowed. libpq silently ignores the file unless its permissions are `0600`. diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 875441f27..e598f2689 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -23,6 +23,8 @@ Two per-connection settings gate every call on top of the token: - **External access**. `blocked` hides the connection entirely: `list_connections` omits it, `list_recent_tabs` filters its tabs, and any tool that targets it returns `403 forbidden`. `readOnly` blocks write SQL: `execute_query` with a write statement and any `confirm_destructive_operation` call return `403`, even with a `readWrite` token. The session-state tools `disconnect`, `switch_database`, and `switch_schema` are gated by token scope only. - **AI policy `askEachTime`**. The first tool call per session that targets the connection shows an in-app approval dialog. TablePro waits up to 30 seconds. Deny returns `403 forbidden` with message "User denied MCP access to this connection"; no answer fails the call with a timeout error. An approval covers that connection for the rest of the session. +`list_tables`, `list_schemas`, `describe_table`, `get_table_ddl`, and `execute_query` query exactly the `database` and `schema` you pass them and leave the app alone: the sidebar's selected database and every open tab stay where they are, and a tab keeps querying the database and schema it was opened on. `switch_database` and `switch_schema` are the only tools that move the app's selection, which is what they are for. + ## Connection tools ### `list_connections` @@ -168,11 +170,12 @@ Columns, indexes, foreign keys, primary key, DDL. { "connection_id": "...", "table": "users", + "database": "app", "schema": "public" } ``` -`schema` is optional. The connection's current schema is used when omitted. To target a different database, call `switch_database` first. +`database` and `schema` are optional. The connection's current database and schema are used when omitted. **Output**: @@ -215,7 +218,7 @@ Columns, indexes, foreign keys, primary key, DDL. Just the `CREATE TABLE` statement. -**Input**: same as `describe_table` (`connection_id`, `table`, `schema`). +**Input**: same as `describe_table` (`connection_id`, `table`, `database`, `schema`). **Output**: `{ "ddl": "CREATE TABLE ..." }` @@ -240,7 +243,7 @@ Execute a SQL query. All queries are subject to the connection's safe mode polic } ``` -Defaults for `max_rows` and `timeout_seconds` come from **Settings > Integrations > Server Configuration** (default row limit, query timeout). `max_rows` is clamped to the configured maximum (default 10,000). `timeout_seconds` is clamped to 1-300. Single-statement queries only. Query size cap is 100 KB. `database` and `schema` are optional; when present, the tool calls `switch_database` and/or `switch_schema` before executing. +Defaults for `max_rows` and `timeout_seconds` come from **Settings > Integrations > Server Configuration** (default row limit, query timeout). `max_rows` is clamped to the configured maximum (default 10,000). `timeout_seconds` is clamped to 1-300. Single-statement queries only. Query size cap is 100 KB. `database` and `schema` are optional; when present, the query runs against them. It never changes what's selected in the app, and omitting them runs against the connection's currently selected database. **Output**: @@ -314,7 +317,7 @@ Export query or table data as CSV, JSON, or SQL. **Output**: `{ "status": "switched", "current_database": "analytics" }` or `{ "status": "switched", "current_schema": "reporting" }` -**Scope**: `readWrite` (mutates session state). +**Scope**: `readWrite` (moves the connection's selected database or schema, which the sidebar follows). ## Navigation tools diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 107d8057a..e64e61b33 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -63,6 +63,8 @@ The mode picker in the composer footer controls which tools the AI can call. The Mode and [safe mode](/features/safe-mode) are independent gates. Agent mode does not bypass safe mode. +These tools never change what's open in the app. `list_tables`, `list_schemas`, `describe_table`, `get_table_ddl`, and `execute_query` take an optional `database` and query the database or schema they're pointed at, without moving the sidebar's selected database or touching an open tab. + ### Tool Calling In Edit and Agent modes, each tool call appears as a card in the reply. Read-only tools run immediately. Write tools show three buttons: diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 9625506b9..4f7913c01 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -65,6 +65,7 @@ Remote access exposes the server to your network. Leave it off unless you connec - AI access policies are set per connection in each connection's settings, including blocking a connection from external access entirely. - Bearer tokens carry scopes and per-connection allowlists; see [Tokens](/external-api/tokens) for what tokens can and cannot do. - The reachable surface is the [tool catalog](/external-api/mcp-tools) and [resources](/external-api/mcp-resources), nothing else. +- Tool calls never change what's open in the app: `list_tables`, `list_schemas`, and `execute_query` query the database or schema you pass them without moving the sidebar's selected database or touching an open tab. ## Reference diff --git a/docs/features/sql-editor.mdx b/docs/features/sql-editor.mdx index 5aa3dc986..3cb5776ac 100644 --- a/docs/features/sql-editor.mdx +++ b/docs/features/sql-editor.mdx @@ -38,7 +38,7 @@ The editor toolbar shows a database picker (or schema picker, depending on the e - Changing the picker affects only that tab. Switching the active database elsewhere keeps existing tabs on their bound database. - The picker lists the databases visible in the sidebar, so it follows the sidebar database filter. -- Databases whose driver reconnects the session to switch show a lock instead of a menu; those tabs follow the connection's active database. +- Databases whose driver reconnects the session to switch, PostgreSQL, Redshift, and CockroachDB, show a lock instead of a menu: there is no in-place way to change database from this picker. The tab still keeps the database it was opened with; on these engines a tab bound to a database other than the connection's active one runs on a separate connection for that database. See [Cross-Database Tabs](/databases/postgresql#cross-database-tabs). ## Find and Replace diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 09a555609..734b3047f 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -61,7 +61,7 @@ Right-click a foreign key and choose **Open [table]** to jump to the referenced ## Saving Changes -Structure edits queue locally; nothing runs until you save. See [Change Tracking](/features/change-tracking) for how the queue, undo (`Cmd+Z`), and redo (`Cmd+Shift+Z`) work. +Structure edits queue locally; nothing runs until you save. See [Change Tracking](/features/change-tracking) for how the queue, undo (`Cmd+Z`), and redo (`Cmd+Shift+Z`) work. Saving runs on the tab's own connection, database, and schema, the ones it was opened on. It never moves the sidebar's selected database or the toolbar. - **Save Changes** (`Cmd+S` or the toolbar checkmark) applies the queued changes. Changes that can lose data (dropping a column, changing a type, adding NOT NULL, changing the primary key) first show a confirmation that lists each risky change. - **Preview SQL** (`Cmd+Shift+P`) shows the generated DDL statements without executing them. @@ -122,4 +122,4 @@ MongoDB structure is read-only. TablePro infers the schema from the first 50 doc ## Refreshing -Use **Query > Refresh** (`Cmd+R`) or the toolbar refresh button to reload structure from the database. Changes made through TablePro refresh automatically. +Use **Query > Refresh** (`Cmd+R`) or the toolbar refresh button to reload structure from the database. Changes made through TablePro refresh automatically. Refresh always reads from the tab's own database and schema, not the sidebar's current selection. diff --git a/docs/features/tabs.mdx b/docs/features/tabs.mdx index 34d99c5c6..755cf0edf 100644 --- a/docs/features/tabs.mdx +++ b/docs/features/tabs.mdx @@ -72,6 +72,22 @@ Each tab keeps its full state when you switch away: SQL, cursor position, result Each connection opens its own window by default. Turn on **Settings > General > Tabs > Group all connections in one window** to keep tabs from different connections in a single window instead. New windows open at 1200x800; size and position are remembered across launches. +## Database Binding + +A tab is bound to the connection, database, and schema it was opened on, fixed for the life of the tab. Every query, refresh, filter, sort, structure read, and structure save the tab performs uses that binding, not whatever the sidebar shows at the time. + +The sidebar's database selection only controls two things: what the sidebar lists, and which database a new tab opens into. Changing it does not touch tabs that are already open, and switching between tabs does not change the connection's saved default database either. + +To point an existing tab somewhere else, use the database picker in the query editor's toolbar. It rebinds that one tab and reruns it, and leaves the sidebar and every other tab alone. + +The window subtitle shows the database, and the schema on engines that have one, that the tab is bound to. Tabs in the same window can be bound to different databases, so the subtitle is how you tell them apart. + +Dragging a tab out of its window or choosing **Window > Move Tab to New Window** does not change any of this: the tab keeps querying the database and schema it was opened on. + + +PostgreSQL, Redshift, and CockroachDB can only change database by reconnecting. A tab bound to a database other than the connection's active one runs its queries on a separate connection for that database, so it does not share temp tables, session variables, or an open transaction with the query editor on the main connection. See [PostgreSQL](/databases/postgresql#cross-database-tabs). + + ## Tab Persistence | Saved | Not saved |