From a5860eca6f4bde3fc244045d37bb05325b372ec7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 15:16:58 +0700 Subject: [PATCH] fix(datagrid): key staged table operations by the object they target --- CHANGELOG.md | 3 + .../RowEditingCoordinator+Discard.swift | 4 +- .../RowEditingCoordinator+SaveChanges.swift | 51 ++++--- .../Database/TableOperationSQLBuilder.swift | 70 ++++------ .../Models/Connection/ConnectionSession.swift | 11 +- .../Database/DatabaseTreeTableRef.swift | 52 +++++++ .../Models/Database/StagedWriteScope.swift | 44 ++++++ TablePro/Models/Query/QueryResult.swift | 14 ++ TablePro/Models/Query/TableTabIdentity.swift | 50 +++++++ TablePro/Models/UI/WindowSidebarState.swift | 6 +- TablePro/ViewModels/SidebarViewModel.swift | 116 ++++++++-------- .../MainContentCoordinator+Discard.swift | 4 +- .../MainContentCoordinator+SQLPreview.swift | 46 +++++-- .../MainContentCoordinator+SaveChanges.swift | 6 +- ...inContentCoordinator+TableOperations.swift | 12 +- .../Extensions/MainContentView+Bindings.swift | 4 +- .../MainContentView+EventHandlers.swift | 9 +- .../Main/MainContentCommandActions.swift | 28 ++-- .../Views/Main/MainContentCoordinator.swift | 14 +- TablePro/Views/Main/MainContentView.swift | 14 +- .../Views/Main/TableSelectionAction.swift | 23 +++- ...abaseTreeOutlineCoordinator+Commands.swift | 8 +- .../DatabaseTreeOutlineCoordinator+Menu.swift | 2 +- .../DatabaseTreeOutlineCoordinator.swift | 12 +- .../Sidebar/DatabaseTreeOutlineView.swift | 6 +- .../Views/Sidebar/DatabaseTreeRowView.swift | 8 +- .../Views/Sidebar/DatabaseTreeSelection.swift | 16 +-- TablePro/Views/Sidebar/DatabaseTreeView.swift | 26 +--- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 16 ++- .../Sidebar/Menu/SidebarMenuCommand.swift | 6 +- TablePro/Views/Sidebar/SidebarTreeView.swift | 4 +- TablePro/Views/Sidebar/SidebarView.swift | 12 +- .../Core/Database/MultiConnectionTests.swift | 6 +- .../TableOperationSQLBuilderTests.swift | 124 +++++++++-------- TableProTests/Helpers/TestFixtures.swift | 13 ++ .../Models/ConnectionSessionTests.swift | 14 +- .../Database/StagedWriteScopeTests.swift | 129 ++++++++++++++++++ .../Models/Query/TableTabIdentityTests.swift | 106 ++++++++++++++ .../ViewModels/SidebarViewModelTests.swift | 103 +++++++------- .../ViewModels/WindowSidebarStateTests.swift | 8 +- .../Main/CommandActionsBulkCloseTests.swift | 8 +- .../Main/CommandActionsDispatchTests.swift | 8 +- .../Main/CommandActionsFocusGateTests.swift | 8 +- .../MainContentCoordinatorAddRowTests.swift | 8 +- .../Views/Main/SaveCompletionTests.swift | 54 ++++---- .../Views/Main/SharedSidebarSyncTests.swift | 34 ++--- .../Main/SidebarObjectSelectionTests.swift | 12 +- .../Main/TableSelectionChangeTests.swift | 64 ++++----- .../Views/Main/TriggerStructTests.swift | 18 +-- .../Sidebar/DatabaseTreeMenuSpecTests.swift | 33 ++++- ...DatabaseTreeSelectionProjectionTests.swift | 10 +- .../Sidebar/SidebarOutlineScaffoldTests.swift | 52 +++---- 52 files changed, 981 insertions(+), 528 deletions(-) create mode 100644 TablePro/Models/Database/DatabaseTreeTableRef.swift create mode 100644 TablePro/Models/Database/StagedWriteScope.swift create mode 100644 TablePro/Models/Query/TableTabIdentity.swift create mode 100644 TableProTests/Models/Database/StagedWriteScopeTests.swift create mode 100644 TableProTests/Models/Query/TableTabIdentityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dc1f1e07bb..cd93525ee1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,6 +104,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Insert Row offered on Redis connections on iPhone and iPad, where it can only fail. - Missing search field in a connection's Tables tab on iPhone and iPad. (#2544) - A search from one connection or table still filtering another's list on iPhone and iPad. +- A staged drop or truncate running against the database in front at Save time rather than the one it was staged in. +- Unqualified `DROP TABLE` for every object on Oracle, Dameng, Trino, Snowflake and BigQuery, whatever its kind. +- Dropping a table closing the tab on a same-named table in another schema or database. ### Security diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift index 416a80c827..5e800f9478 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+Discard.swift @@ -48,8 +48,8 @@ extension RowEditingCoordinator { // MARK: - Discard func handleDiscard( - pendingTruncates: inout Set, - pendingDeletes: inout Set + pendingTruncates: inout Set, + pendingDeletes: inout Set ) { let originalValues = parent.changeManager.getOriginalValues() var deltas: [Delta] = [] diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift index 84c80ca9cb..58897638e6 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator+SaveChanges.swift @@ -11,13 +11,14 @@ 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. + /// The plan carries the scope it was built for, so it runs where its statements were + /// generated however long the destructive-delete sheet and the authorization prompt stay + /// open. Reading the selected tab's scope again at execution time is what let a queued drop + /// land in whichever database the tab in front had moved to. func saveChanges( - pendingTruncates: inout Set, - pendingDeletes: inout Set, - tableOperationOptions: inout [String: TableOperationOptions] + pendingTruncates: inout Set, + pendingDeletes: inout Set, + tableOperationOptions: inout [DatabaseTreeTableRef: TableOperationOptions] ) { let hasEditedCells = parent.changeManager.hasChanges let hasPendingTableOps = !pendingTruncates.isEmpty || !pendingDeletes.isEmpty @@ -43,7 +44,7 @@ extension RowEditingCoordinator { return } - guard let scope = parent.selectedTabScope else { + guard parent.selectedTabScope != nil else { failSave(message: String(localized: "Not connected to database")) return } @@ -125,7 +126,7 @@ extension RowEditingCoordinator { var opts = snapshotOptions executeCommitPlan( plan, - scope: scope, + scope: plan.scope, clearTableOps: hasPendingTableOps, pendingTruncates: &truncs, pendingDeletes: &dels, @@ -153,9 +154,9 @@ extension RowEditingCoordinator { _ plan: DataWritePlan, scope: DatabaseScope, clearTableOps: Bool, - pendingTruncates: inout Set, - pendingDeletes: inout Set, - tableOperationOptions: inout [String: TableOperationOptions] + pendingTruncates: inout Set, + pendingDeletes: inout Set, + tableOperationOptions: inout [DatabaseTreeTableRef: TableOperationOptions] ) { let validSteps = plan.steps.filter { !$0.statement.sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } guard !validSteps.isEmpty else { @@ -169,7 +170,7 @@ extension RowEditingCoordinator { let truncatedTables = Set(pendingTruncates) let conn = parent.connection - var capturedOptions: [String: TableOperationOptions] = [:] + var capturedOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] for table in deletedTables.union(truncatedTables) { capturedOptions[table] = tableOperationOptions[table] } @@ -291,7 +292,7 @@ extension RowEditingCoordinator { plan: DataWritePlan, savingTabId: UUID?, clearTableOps: Bool, - deletedTables: Set + deletedTables: Set ) { let savingTabIsSelected = savingTabId != nil && parent.tabManager.selectedTabId == savingTabId if savingTabIsSelected { @@ -324,10 +325,24 @@ extension RowEditingCoordinator { parent.runQuery() } - private func closeTabsForDroppedTables(_ deletedTables: Set) { + /// A tab is closed only when the object it is showing is one of the objects that went. + /// + /// It used to compare bare names, so dropping `analytics.users` also closed the tab on + /// `public.users` and threw away its row buffer, with nothing to undo it. + private func closeTabsForDroppedTables(_ deletedTables: Set) { + let browseDatabase = parent.browseDatabaseName + let dropped = Set(deletedTables.map { ref in + TableTabIdentity( + ref: ref, + browsing: browseDatabase, + resolvedSchema: DatabaseManager.shared.resolvedSchemaName( + ref.qualifyingSchema, for: parent.connectionId + ) + ) + }) let tabIdsToRemove = Set( parent.tabManager.tabs - .filter { $0.tabType == .table && deletedTables.contains($0.tableContext.tableName ?? "") } + .filter { tab in tab.tableIdentity(browsing: browseDatabase).map(dropped.contains) ?? false } .map(\.id) ) guard !tabIdsToRemove.isEmpty else { return } @@ -493,9 +508,9 @@ extension RowEditingCoordinator { private func restorePendingTableOperations( connectionId: UUID, - truncates: Set, - deletes: Set, - options: [String: TableOperationOptions] + truncates: Set, + deletes: Set, + options: [DatabaseTreeTableRef: TableOperationOptions] ) { DatabaseManager.shared.updateSession(connectionId) { session in session.pendingTruncates = truncates diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index a76732cc1b..7240c3f330 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -4,61 +4,48 @@ // import Foundation -import os @MainActor struct TableOperationSQLBuilder { - nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "TableOperationSQLBuilder") - - let connectionId: UUID - let databaseType: DatabaseType - let tableInfoProvider: () -> [String: TableInfo] let adapterProvider: () -> PluginDriverAdapter? - init( - connectionId: UUID, - databaseType: DatabaseType, - tableInfoProvider: @escaping () -> [String: TableInfo], - adapterProvider: @escaping () -> PluginDriverAdapter? - ) { - self.connectionId = connectionId - self.databaseType = databaseType - self.tableInfoProvider = tableInfoProvider + init(adapterProvider: @escaping () -> PluginDriverAdapter?) { self.adapterProvider = adapterProvider } + /// Every statement is built from the queued reference alone. + /// + /// The schema and the object's keyword used to be looked up in the connection's flat table + /// cache, which publishes nothing at all on an engine whose tree is per-schema: on Oracle, + /// Dameng, Trino, Snowflake and BigQuery every drop came out unqualified and typed `TABLE`, + /// so dropping a view raised ORA-00942 and dropping a table reached whatever object of that + /// name the login schema happened to hold. func generate( - truncates: Set, - deletes: Set, - options: [String: TableOperationOptions], + truncates: Set, + deletes: Set, + options: [DatabaseTreeTableRef: TableOperationOptions], includeFKHandling: Bool = true ) -> [String] { var statements: [String] = [] - let sortedTruncates = truncates.sorted() - let sortedDeletes = deletes.sorted() + let sortedTruncates = truncates.sorted { $0.id < $1.id } + let sortedDeletes = deletes.sorted { $0.id < $1.id } - let needsDisableFK = includeFKHandling && truncates.union(deletes).contains { tableName in - options[tableName]?.ignoreForeignKeys == true + let needsDisableFK = includeFKHandling && truncates.union(deletes).contains { ref in + options[ref]?.ignoreForeignKeys == true } if needsDisableFK { statements.append(contentsOf: foreignKeyDisableStatements()) } - let tableLookup = tableInfoProvider() - - for tableName in sortedTruncates { - let tableOptions = options[tableName] ?? TableOperationOptions() + for ref in sortedTruncates { statements.append(contentsOf: truncateStatements( - tableName: tableName, schema: tableLookup[tableName]?.schema, options: tableOptions + ref, options: options[ref] ?? TableOperationOptions() )) } - for tableName in sortedDeletes { - let tableOptions = options[tableName] ?? TableOperationOptions() - let stmt = dropObjectStatement( - tableName: tableName, tableInfo: tableLookup[tableName], options: tableOptions - ) + for ref in sortedDeletes { + let stmt = dropObjectStatement(ref, options: options[ref] ?? TableOperationOptions()) if !stmt.isEmpty { statements.append(stmt) } @@ -80,30 +67,27 @@ struct TableOperationSQLBuilder { } private func truncateStatements( - tableName: String, schema: String?, options: TableOperationOptions + _ ref: DatabaseTreeTableRef, options: TableOperationOptions ) -> [String] { guard let adapter = adapterProvider() else { return [] } return adapter.truncateTableStatements( - table: tableName, schema: schema, cascade: options.cascade + table: ref.table.name, schema: ref.qualifyingSchema, cascade: options.cascade ) } private func dropObjectStatement( - tableName: String, tableInfo: TableInfo?, options: TableOperationOptions + _ ref: DatabaseTreeTableRef, options: TableOperationOptions ) -> String { guard let adapter = adapterProvider() else { return "" } - if tableInfo == nil { - Self.logger.warning("No cached TableInfo for \(tableName, privacy: .public); dropping as TABLE") - } return adapter.dropObjectStatement( - name: tableName, - objectType: Self.dropKeyword(for: tableInfo?.type), - schema: tableInfo?.schema, + name: ref.table.name, + objectType: Self.dropKeyword(for: ref.table.type), + schema: ref.qualifyingSchema, cascade: options.cascade ) } - private static func dropKeyword(for type: TableInfo.TableType?) -> String { + private static func dropKeyword(for type: TableInfo.TableType) -> String { switch type { case .view: return "VIEW" @@ -111,7 +95,7 @@ struct TableOperationSQLBuilder { return "MATERIALIZED VIEW" case .foreignTable: return "FOREIGN TABLE" - case .table, .systemTable, .partitionedTable, .externalTable, .none: + case .table, .systemTable, .partitionedTable, .externalTable: return "TABLE" } } diff --git a/TablePro/Models/Connection/ConnectionSession.swift b/TablePro/Models/Connection/ConnectionSession.swift index 112ac1c1a6..fef9c2a3cb 100644 --- a/TablePro/Models/Connection/ConnectionSession.swift +++ b/TablePro/Models/Connection/ConnectionSession.swift @@ -22,10 +22,13 @@ struct ConnectionSession: Identifiable { var safeModeLevel: SafeModeLevel // Per-connection state - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] + var selectedTables: Set = [] + /// Queued Truncate and Drop, keyed by the object each one is aimed at rather than by its name. + /// The queue outlives a database switch, so a name-keyed entry was resolved at Save time + /// against whatever the selected tab pointed at by then. + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] /// 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 diff --git a/TablePro/Models/Database/DatabaseTreeTableRef.swift b/TablePro/Models/Database/DatabaseTreeTableRef.swift new file mode 100644 index 0000000000..8de986b75e --- /dev/null +++ b/TablePro/Models/Database/DatabaseTreeTableRef.swift @@ -0,0 +1,52 @@ +// +// DatabaseTreeTableRef.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// One table, named by everything it takes to reach it. +/// +/// `database` is nil when the connection browses no database, which is the normal state for +/// an engine that has none. It stays optional all the way from `browsingDatabase` so it can be +/// compared against the equally optional active database: an empty string here read as "some +/// database called nothing", never equal to nil, and fired a database switch on every click. +/// +/// It lives beside the models rather than beside the tree because it is what a queued Truncate or +/// Drop is aimed at, and what the tab reconciliation after one matches against. Those used to hold +/// a bare name, and a bare name cannot name a table: `orders` exists in every database on the +/// server, the queue lives on the connection rather than on a tab, and the browse cursor moves +/// freely while something is queued, so the queue resolved against whatever the selected tab +/// happened to point at when Save ran. +struct DatabaseTreeTableRef: Hashable, Identifiable, Sendable { + let database: String? + let schema: String? + let table: TableInfo + + init(database: String?, schema: String?, table: TableInfo) { + self.database = database?.nilIfEmpty + self.schema = schema?.nilIfEmpty + self.table = table + } + + /// The separator is escaped because every part of this is a user-chosen identifier and a + /// quoted one may contain anything. Joined raw, schema `a|b` with table `c` and schema `a` + /// with table `b|c` produced one id for two objects, and this id keys the outline's rows. + var id: String { + "\(Self.escaped(database))|\(Self.escaped(schema))|\(Self.escaped(table.id))" + } + + /// The schema the statement should qualify with, which is the row's own before the table's. + /// A hierarchical tree hangs its tables off a schema node and the `TableInfo` under it may + /// carry none, while a flat list gets it the other way round. + var qualifyingSchema: String? { + schema ?? table.schema?.nilIfEmpty + } + + private static func escaped(_ value: String?) -> String { + (value ?? "") + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "|", with: "\\|") + } +} diff --git a/TablePro/Models/Database/StagedWriteScope.swift b/TablePro/Models/Database/StagedWriteScope.swift new file mode 100644 index 0000000000..78ed1dba8f --- /dev/null +++ b/TablePro/Models/Database/StagedWriteScope.swift @@ -0,0 +1,44 @@ +// +// StagedWriteScope.swift +// TablePro +// + +import Foundation + +/// Where one press of Save runs. +/// +/// A queued Truncate or Drop names the database it was queued against, and the plan follows that +/// rather than the tab in front. It used to take the selected tab's scope, so queueing a drop in +/// one database, switching to another and pressing Save dropped the table of that name in the +/// second one. +/// +/// The statements are generated by the session's own driver before anything is pinned, and a +/// driver that qualifies from its live connection writes the database it is on into the statement: +/// Snowflake builds a three-part name from `currentDatabase`, SurrealDB opens with its current +/// namespace. Pinning the plan afterwards cannot correct a name already written, so a queue is +/// only saved from the database it was queued in, and is refused rather than rewritten anywhere +/// else. Row edits carry no such queue and keep their own tab's scope. +enum StagedWriteScope { + static func resolve( + tabScope: DatabaseScope, + browseDatabase: String, + stagedDatabases: Set, + includesRowEdits: Bool + ) throws -> DatabaseScope { + let staged = stagedDatabases.filter { !$0.isEmpty } + guard !staged.isEmpty else { return tabScope } + guard staged.count == 1, let database = staged.first else { + throw DatabaseError.writeSpansDatabases + } + guard database == browseDatabase else { + throw DatabaseError.stagedWriteOutsideBrowsedDatabase(database) + } + guard !includesRowEdits || tabScope.database == database else { + throw DatabaseError.writeSpansDatabases + } + guard database != tabScope.database else { return tabScope } + /// The tab's schema belongs to the tab's database and means nothing in another one. Every + /// queued statement qualifies its own schema, so the scope does not need to carry one. + return DatabaseScope(connectionId: tabScope.connectionId, database: database, schema: nil) + } +} diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index fe52e750b0..a9c3145168 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -60,6 +60,13 @@ enum DatabaseError: Error, LocalizedError { case fileNotFound(String) case notConnected case unsupportedOperation + /// One save runs against one database, so a queue that reaches two of them cannot be one + /// plan. It is refused rather than resolved to whichever database the tab in front happens + /// to name, which is how a drop queued in one database used to land in another. + case writeSpansDatabases + /// The statements are generated by the session's driver before the plan is pinned, so a queue + /// can only be trusted from the database it was queued in. + case stagedWriteOutsideBrowsedDatabase(String) var errorDescription: String? { switch self { @@ -75,6 +82,13 @@ enum DatabaseError: Error, LocalizedError { return String(localized: "Not connected to database") case .unsupportedOperation: return String(localized: "This operation is not supported") + case .writeSpansDatabases: + return String(localized: "Pending changes reach more than one database. Save or discard one database at a time.") + case .stagedWriteOutsideBrowsedDatabase(let database): + return String( + format: String(localized: "Pending table changes belong to %@. Switch to that database to save them, or discard them."), + database + ) } } } diff --git a/TablePro/Models/Query/TableTabIdentity.swift b/TablePro/Models/Query/TableTabIdentity.swift new file mode 100644 index 0000000000..dc781fc029 --- /dev/null +++ b/TablePro/Models/Query/TableTabIdentity.swift @@ -0,0 +1,50 @@ +// +// TableTabIdentity.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// The three values a table tab is keyed on from the moment it opens. +/// +/// `QueryTabManager.tabShowingTable` finds a tab by this triple and the strip titles two tabs +/// apart by it, so anything that has to reach the tab showing a given object has to spell it the +/// same way. Closing tabs after a drop compared the table name on its own, so dropping +/// `analytics.users` also closed the tab on `public.users` and discarded its row buffer. +struct TableTabIdentity: Hashable, Sendable { + let table: String + let database: String + let schema: String? + + init(table: String, database: String, schema: String?) { + self.table = table + self.database = database + self.schema = schema?.nilIfEmpty + } + + /// A tab opened from the object tree takes the browse database rather than the row's own, + /// because the tree activates the row's database before it opens anything. Resolving the + /// schema is the caller's job for the same reason: only the session knows its default. + init(ref: DatabaseTreeTableRef, browsing browseDatabase: String, resolvedSchema: String?) { + self.init( + table: ref.table.name, + database: ref.database ?? browseDatabase, + schema: resolvedSchema + ) + } +} + +extension QueryTab { + /// A tab restored from a payload written before tabs carried a database has an empty one, and + /// `resolvedDatabaseName` defines that as the browse cursor. Reading the raw field instead left + /// such a tab open with dead rows after its table was dropped. + func tableIdentity(browsing browseDatabase: String) -> TableTabIdentity? { + guard tabType == .table, let name = tableContext.tableName, !name.isEmpty else { return nil } + return TableTabIdentity( + table: name, + database: tableContext.resolvedDatabaseName(browsing: browseDatabase), + schema: tableContext.schemaName?.nilIfEmpty + ) + } +} diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index 41a5bf277d..d1d08ce622 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -25,7 +25,7 @@ internal final class WindowSidebarState { @ObservationIgnored private let defaults: UserDefaults @ObservationIgnored private var isLoaded = false - var selectedTables: Set = [] + var selectedTables: Set = [] /// How many rows are selected, which is not the same as how many tables. A table selected /// alongside a schema is an extension of a selection, not a pick, and the set of tables alone @@ -35,11 +35,11 @@ internal final class WindowSidebarState { /// through `selectTables(_:)` and the two stay consistent by construction. private(set) var selectedRowCount = 0 - func selectTables(_ tables: Set) { + func selectTables(_ tables: Set) { select(tables: tables, rowCount: tables.count) } - func select(tables: Set, rowCount: Int) { + func select(tables: Set, rowCount: Int) { selectedRowCount = rowCount guard selectedTables != tables else { return } selectedTables = tables diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index bffc2a15b8..df683d6071 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -14,10 +14,10 @@ final class SidebarViewModel { static func shared( connectionId: UUID, databaseType: DatabaseType, - selectedTables: Binding>, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]> + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> ) -> SidebarViewModel { if let existing = registry[connectionId] { existing.updateBindings( @@ -45,10 +45,10 @@ final class SidebarViewModel { } func updateBindings( - selectedTables: Binding>, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]> + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> ) { selectedTablesBinding = selectedTables pendingTruncatesBinding = pendingTruncates @@ -135,14 +135,14 @@ final class SidebarViewModel { } var showOperationDialog = false var pendingOperationType: TableOperationType? - var pendingOperationTables: [String] = [] + var pendingOperationTables: [DatabaseTreeTableRef] = [] // MARK: - Binding Storage - private var selectedTablesBinding: Binding> - private var pendingTruncatesBinding: Binding> - private var pendingDeletesBinding: Binding> - private var tableOperationOptionsBinding: Binding<[String: TableOperationOptions]> + private var selectedTablesBinding: Binding> + private var pendingTruncatesBinding: Binding> + private var pendingDeletesBinding: Binding> + private var tableOperationOptionsBinding: Binding<[DatabaseTreeTableRef: TableOperationOptions]> let databaseType: DatabaseType // MARK: - Dependencies @@ -155,22 +155,22 @@ final class SidebarViewModel { // MARK: - Convenience Accessors - var selectedTables: Set { + var selectedTables: Set { get { selectedTablesBinding.wrappedValue } set { selectedTablesBinding.wrappedValue = newValue } } - var pendingTruncates: Set { + var pendingTruncates: Set { get { pendingTruncatesBinding.wrappedValue } set { pendingTruncatesBinding.wrappedValue = newValue } } - var pendingDeletes: Set { + var pendingDeletes: Set { get { pendingDeletesBinding.wrappedValue } set { pendingDeletesBinding.wrappedValue = newValue } } - var tableOperationOptions: [String: TableOperationOptions] { + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] { get { tableOperationOptionsBinding.wrappedValue } set { tableOperationOptionsBinding.wrappedValue = newValue } } @@ -183,10 +183,10 @@ final class SidebarViewModel { // MARK: - Initialization init( - selectedTables: Binding>, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]>, + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, databaseType: DatabaseType, connectionId: UUID ) { @@ -272,42 +272,42 @@ final class SidebarViewModel { // MARK: - Batch Operations - func batchToggleTruncate(tableNames: [String]? = nil) { - let tablesToToggle = tableNames ?? (selectedTables.isEmpty ? [] : Array(selectedTables.map { $0.name })) - guard !tablesToToggle.isEmpty else { return } + /// A queued Truncate or Drop carries the row it was raised from, not that row's name. + /// The queue lives on the connection and outlives a database switch, so a name-keyed entry + /// was resolved at Save time against whatever the tab in front pointed at by then. + func batchToggleTruncate(refs: [DatabaseTreeTableRef]? = nil) { + let targets = refs ?? Array(selectedTables) + guard !targets.isEmpty else { return } - let allAlreadyPending = tablesToToggle.allSatisfy { pendingTruncates.contains($0) } - if allAlreadyPending { - var updated = pendingTruncates - for name in tablesToToggle { - updated.remove(name) - tableOperationOptions.removeValue(forKey: name) - } - pendingTruncates = updated - } else { - pendingOperationType = .truncate - pendingOperationTables = tablesToToggle - showOperationDialog = true + guard !targets.allSatisfy({ pendingTruncates.contains($0) }) else { + unstage(targets, from: &pendingTruncatesBinding.wrappedValue) + return } + pendingOperationType = .truncate + pendingOperationTables = targets + showOperationDialog = true } - func batchToggleDelete(tableNames: [String]? = nil) { - let tablesToToggle = tableNames ?? (selectedTables.isEmpty ? [] : Array(selectedTables.map { $0.name })) - guard !tablesToToggle.isEmpty else { return } + func batchToggleDelete(refs: [DatabaseTreeTableRef]? = nil) { + let targets = refs ?? Array(selectedTables) + guard !targets.isEmpty else { return } - let allAlreadyPending = tablesToToggle.allSatisfy { pendingDeletes.contains($0) } - if allAlreadyPending { - var updated = pendingDeletes - for name in tablesToToggle { - updated.remove(name) - tableOperationOptions.removeValue(forKey: name) - } - pendingDeletes = updated - } else { - pendingOperationType = .drop - pendingOperationTables = tablesToToggle - showOperationDialog = true + guard !targets.allSatisfy({ pendingDeletes.contains($0) }) else { + unstage(targets, from: &pendingDeletesBinding.wrappedValue) + return + } + pendingOperationType = .drop + pendingOperationTables = targets + showOperationDialog = true + } + + private func unstage(_ targets: [DatabaseTreeTableRef], from queue: inout Set) { + var options = tableOperationOptions + for ref in targets { + queue.remove(ref) + options.removeValue(forKey: ref) } + tableOperationOptions = options } func cancelPendingOperation() { @@ -322,15 +322,15 @@ final class SidebarViewModel { var updatedDeletes = pendingDeletes var updatedOptions = tableOperationOptions - for tableName in pendingOperationTables { + for ref in pendingOperationTables { if operationType == .truncate { - updatedDeletes.remove(tableName) - updatedTruncates.insert(tableName) + updatedDeletes.remove(ref) + updatedTruncates.insert(ref) } else { - updatedTruncates.remove(tableName) - updatedDeletes.insert(tableName) + updatedTruncates.remove(ref) + updatedDeletes.insert(ref) } - updatedOptions[tableName] = options + updatedOptions[ref] = options } pendingTruncates = updatedTruncates @@ -345,7 +345,7 @@ final class SidebarViewModel { func copySelectedTableNames() { guard !selectedTables.isEmpty else { return } - let names = selectedTables.map { $0.name }.sorted() + let names = selectedTables.map { $0.table.name }.sorted() ClipboardService.shared.writeText(names.joined(separator: ",")) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift index 431430051a..ff91684a4a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Discard.swift @@ -11,8 +11,8 @@ extension MainContentCoordinator { } func handleDiscard( - pendingTruncates: inout Set, - pendingDeletes: inout Set + pendingTruncates: inout Set, + pendingDeletes: inout Set ) { rowEditingCoordinator.handleDiscard( pendingTruncates: &pendingTruncates, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift index 17bc1c4cf1..2248739e6d 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SQLPreview.swift @@ -6,15 +6,16 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { // MARK: - SQL Preview /// Routes SQL preview request to the appropriate handler based on current tab mode func handlePreviewSQL( - pendingTruncates: Set, - pendingDeletes: Set, - tableOperationOptions: [String: TableOperationOptions] + pendingTruncates: Set, + pendingDeletes: Set, + tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] ) { if tabManager.selectedTab?.display.resultsViewMode == .structure { // Structure view handles its own preview via direct call @@ -30,9 +31,9 @@ extension MainContentCoordinator { /// Generate SQL preview of all pending changes with inlined parameters func generatePreviewSQL( - pendingTruncates: Set, - pendingDeletes: Set, - tableOperationOptions: [String: TableOperationOptions] + pendingTruncates: Set, + pendingDeletes: Set, + tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] ) { do { let plan = try buildDataWritePlan( @@ -53,9 +54,9 @@ extension MainContentCoordinator { /// carries the rows it should touch, which is what lets the executor hold the server to that /// number, and the row operations carry the before and after images a later rewind needs. func buildDataWritePlan( - pendingTruncates: Set, - pendingDeletes: Set, - tableOperationOptions: [String: TableOperationOptions] + pendingTruncates: Set, + pendingDeletes: Set, + tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] ) throws -> DataWritePlan { let dbType = connection.type let hasPendingTableOps = !pendingTruncates.isEmpty || !pendingDeletes.isEmpty @@ -65,13 +66,16 @@ extension MainContentCoordinator { /// `PRAGMA foreign_keys` is a no-op there on every SQLite-derived engine, so the option /// would silently do nothing. They travel as the plan's prologue and epilogue instead. let needsDisableFK = PluginManager.shared.supportsForeignKeyDisable(for: dbType) - && pendingTruncates.union(pendingDeletes).contains { tableName in - tableOperationOptions[tableName]?.ignoreForeignKeys == true + && pendingTruncates.union(pendingDeletes).contains { ref in + tableOperationOptions[ref]?.ignoreForeignKeys == true } let prologue = needsDisableFK ? fkDisableStatements(for: dbType) : [] let epilogue = needsDisableFK ? fkEnableStatements(for: dbType) : [] - let scope = selectedTabScope ?? DatabaseScope(connectionId: connection.id, database: "", schema: nil) + let scope = try writeScope( + stagedTables: pendingTruncates.union(pendingDeletes), + includesRowEdits: changeManager.hasChanges + ) var rowOperations: [RowWriteOperation] = [] if changeManager.hasChanges { @@ -108,11 +112,23 @@ extension MainContentCoordinator { ) } + private func writeScope( + stagedTables: Set, + includesRowEdits: Bool + ) throws -> DatabaseScope { + try StagedWriteScope.resolve( + tabScope: selectedTabScope ?? DatabaseScope(connectionId: connection.id, database: "", schema: nil), + browseDatabase: browseDatabaseName, + stagedDatabases: Set(stagedTables.compactMap(\.database)), + includesRowEdits: includesRowEdits + ) + } + /// Assembles all pending SQL statements (cell edits + table operations) in execution order. func assemblePendingStatements( - pendingTruncates: Set, - pendingDeletes: Set, - tableOperationOptions: [String: TableOperationOptions] + pendingTruncates: Set, + pendingDeletes: Set, + tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] ) throws -> [ParameterizedStatement] { try buildDataWritePlan( pendingTruncates: pendingTruncates, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift index 7a6adc5434..1d6731756a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SaveChanges.swift @@ -7,9 +7,9 @@ import Foundation extension MainContentCoordinator { func saveChanges( - pendingTruncates: inout Set, - pendingDeletes: inout Set, - tableOperationOptions: inout [String: TableOperationOptions] + pendingTruncates: inout Set, + pendingDeletes: inout Set, + tableOperationOptions: inout [DatabaseTreeTableRef: TableOperationOptions] ) { rowEditingCoordinator.saveChanges( pendingTruncates: &pendingTruncates, diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift index 4394749953..f747909746 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableOperations.swift @@ -8,12 +8,6 @@ import Foundation extension MainContentCoordinator { private var tableOperationBuilder: TableOperationSQLBuilder { TableOperationSQLBuilder( - connectionId: connectionId, - databaseType: connection.type, - tableInfoProvider: { - guard let session = DatabaseManager.shared.session(for: self.connectionId) else { return [:] } - return Dictionary(session.tables.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first }) - }, adapterProvider: { DatabaseManager.shared.driver(for: self.connectionId) as? PluginDriverAdapter } @@ -21,9 +15,9 @@ extension MainContentCoordinator { } func generateTableOperationSQL( - truncates: Set, - deletes: Set, - options: [String: TableOperationOptions], + truncates: Set, + deletes: Set, + options: [DatabaseTreeTableRef: TableOperationOptions], includeFKHandling: Bool = true ) -> [String] { tableOperationBuilder.generate( diff --git a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift index cb5fbda152..45cb8c7a63 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+Bindings.swift @@ -215,8 +215,8 @@ enum InspectorValueDisplayFormatResolver { /// for consolidated toolbar badge onChange observation. struct PendingChangeTrigger: Equatable { let hasDataChanges: Bool - let pendingTruncates: Set - let pendingDeletes: Set + let pendingTruncates: Set + let pendingDeletes: Set let hasStructureChanges: Bool let isFileDirty: Bool let hasCreateTablePending: Bool diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index c54b3e8536..6638bbbeb0 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -99,7 +99,7 @@ extension MainContentView { } func handleTableSelectionChange( - from oldTables: Set, to newTables: Set + from oldTables: Set, to newTables: Set ) { let action = TableSelectionAction.resolve( oldTables: oldTables, @@ -107,9 +107,10 @@ extension MainContentView { selectedRowCount: coordinator.windowSidebarState.selectedRowCount ) - guard case .navigate(let table) = action else { + guard case .navigate(let ref) = action else { return } + let table = ref.table guard coordinator.isKeyWindow else { return @@ -136,9 +137,9 @@ extension MainContentView { return case .reuseActiveTab: coordinator.selectionState.indices = [] - coordinator.openTableTab(table) + coordinator.openTableTab(table, schema: ref.qualifyingSchema) case .openNewTab: - coordinator.openTableTab(table) + coordinator.openTableTab(table, schema: ref.qualifyingSchema) } } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 450d76b56b..02f290b0f1 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -35,10 +35,10 @@ final class MainContentCommandActions { // MARK: - Bindings @ObservationIgnored private let selectionState: GridSelectionState - @ObservationIgnored private let selectedTables: Binding> - @ObservationIgnored private let pendingTruncates: Binding> - @ObservationIgnored private let pendingDeletes: Binding> - @ObservationIgnored private let tableOperationOptions: Binding<[String: TableOperationOptions]> + @ObservationIgnored private let selectedTables: Binding> + @ObservationIgnored private let pendingTruncates: Binding> + @ObservationIgnored private let pendingDeletes: Binding> + @ObservationIgnored private let tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> @ObservationIgnored private let rightPanelState: RightPanelState /// The window this instance belongs to — used for key-window guards. @@ -72,10 +72,10 @@ final class MainContentCommandActions { coordinator: MainContentCoordinator, connection: DatabaseConnection, selectionState: GridSelectionState, - selectedTables: Binding>, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]>, + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, rightPanelState: RightPanelState ) { self.coordinator = coordinator @@ -239,12 +239,12 @@ final class MainContentCommandActions { var updatedDeletes = pendingDeletes.wrappedValue var updatedTruncates = pendingTruncates.wrappedValue - for table in selectedTables.wrappedValue { - updatedTruncates.remove(table.name) - if updatedDeletes.contains(table.name) { - updatedDeletes.remove(table.name) + for ref in selectedTables.wrappedValue { + updatedTruncates.remove(ref) + if updatedDeletes.contains(ref) { + updatedDeletes.remove(ref) } else { - updatedDeletes.insert(table.name) + updatedDeletes.insert(ref) } } @@ -483,7 +483,7 @@ final class MainContentCommandActions { var selectedObject: TableInfo? { let selection = selectedTables.wrappedValue guard selection.count == 1 else { return nil } - return selection.first + return selection.first?.table } var hasQueryText: Bool { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index a70c1503ab..34f2fefbe9 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -816,22 +816,22 @@ final class MainContentCoordinator { let tables = services.schemaService.allLoadedTables(for: connectionId) guard let vm = sidebarViewModel else { return } let validNames = Set(tables.map(\.name)) - let staleSelections = vm.selectedTables.filter { !validNames.contains($0.name) } + let staleSelections = vm.selectedTables.filter { !validNames.contains($0.table.name) } if !staleSelections.isEmpty { vm.selectedTables.subtract(staleSelections) } - let stalePendingDeletes = vm.pendingDeletes.subtracting(validNames) + let stalePendingDeletes = vm.pendingDeletes.filter { !validNames.contains($0.table.name) } if !stalePendingDeletes.isEmpty { vm.pendingDeletes.subtract(stalePendingDeletes) - for name in stalePendingDeletes { - vm.tableOperationOptions.removeValue(forKey: name) + for ref in stalePendingDeletes { + vm.tableOperationOptions.removeValue(forKey: ref) } } - let stalePendingTruncates = vm.pendingTruncates.subtracting(validNames) + let stalePendingTruncates = vm.pendingTruncates.filter { !validNames.contains($0.table.name) } if !stalePendingTruncates.isEmpty { vm.pendingTruncates.subtract(stalePendingTruncates) - for name in stalePendingTruncates { - vm.tableOperationOptions.removeValue(forKey: name) + for ref in stalePendingTruncates { + vm.tableOperationOptions.removeValue(forKey: ref) } } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 135b1134e1..ff8159660c 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -33,9 +33,9 @@ struct MainContentView: View { @Binding var windowSubtitle: String @Bindable var schemaService = SchemaService.shared var sidebarState: SharedSidebarState - @Binding var pendingTruncates: Set - @Binding var pendingDeletes: Set - @Binding var tableOperationOptions: [String: TableOperationOptions] + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set + @Binding var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] var rightPanelState: RightPanelState private var tables: [TableInfo] { @@ -71,9 +71,9 @@ struct MainContentView: View { windowTitle: Binding, windowSubtitle: Binding, sidebarState: SharedSidebarState, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, rightPanelState: RightPanelState, tabManager: QueryTabManager, changeManager: DataChangeManager, @@ -192,7 +192,7 @@ struct MainContentView: View { mode: .tables( connection: exportConnection, preselection: coordinator.exportPreselection - ?? .tables(Set(coordinator.windowSidebarState.selectedTables.map(\.name))) + ?? .tables(Set(coordinator.windowSidebarState.selectedTables.map(\.table.name))) ), sidebarTables: tables ) diff --git a/TablePro/Views/Main/TableSelectionAction.swift b/TablePro/Views/Main/TableSelectionAction.swift index e08a0e1b93..2564572801 100644 --- a/TablePro/Views/Main/TableSelectionAction.swift +++ b/TablePro/Views/Main/TableSelectionAction.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit /// Describes what should happen when the sidebar selection set changes. /// @@ -17,23 +18,23 @@ import Foundation /// selection is still exactly one addition. enum TableSelectionAction: Equatable { case noNavigation - case navigate(table: TableInfo) + case navigate(ref: DatabaseTreeTableRef) /// `selectedRowCount` is how many rows the sidebar has selected, which the table set alone /// cannot tell: a table Cmd-clicked alongside a schema yields one table and is still an /// extension. Callers that can only ever select tables pass the table count. static func resolve( - oldTables: Set, - newTables: Set, + oldTables: Set, + newTables: Set, selectedRowCount: Int ) -> TableSelectionAction { guard selectedRowCount == 1, newTables.count == 1, - let table = SelectionDelta.singleAddition(old: oldTables, new: newTables) + let ref = SelectionDelta.singleAddition(old: oldTables, new: newTables) else { return .noNavigation } - return .navigate(table: table) + return .navigate(ref: ref) } } @@ -69,7 +70,7 @@ enum SidebarObjectSelection: Equatable { /// Clearing the mark here would blank it every time a container starts loading. case leaveUnchanged /// The rows the open document occupies, empty when it occupies none in the database on screen. - case mark(Set) + case mark(Set) /// - Parameters: /// - tabScope: the selected tab's own scope, which it owns for life. @@ -88,7 +89,15 @@ enum SidebarObjectSelection: Equatable { else { return .mark([]) } - return .mark([match]) + /// Spelled the way the tree spells its own rows, or the mark names a row the tree does not + /// have. A tree hangs a table off a schema node and the row's own schema may be empty + /// there, so the tab's schema stands in; the database is the browsed one, which is the + /// only database whose rows are on screen. + return .mark([DatabaseTreeTableRef( + database: browseScope.database.nilIfEmpty, + schema: match.schema?.nilIfEmpty ?? tabScope.schema, + table: match + )]) } /// A schema decides between two rows only when both sides name one. An engine without schemas diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index f05772c815..26c5cdfb2d 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -60,13 +60,13 @@ extension DatabaseTreeOutlineCoordinator { schema: ref.schema ) } - case .truncateTables(let names, let ref): + case .truncateTables(let targets, let ref): activateThen(ref) { [weak self] in - self?.viewModel?.batchToggleTruncate(tableNames: names) + self?.viewModel?.batchToggleTruncate(refs: targets) } - case .dropTables(let names, let ref): + case .dropTables(let targets, let ref): activateThen(ref) { [weak self] in - self?.viewModel?.batchToggleDelete(tableNames: names) + self?.viewModel?.batchToggleDelete(refs: targets) } case .toggleFavorite(let ref): toggleFavorite(ref) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index a2fafa611f..6aca24a662 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -36,7 +36,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { let settings = AppSettingsManager.shared.general return DatabaseTreeMenuContext( clicked: clicked?.kind, - selectedTables: Set(selectedRefs().map(\.table)), + selectedTables: Set(selectedRefs()), selectedContainers: selectedContainerRefs(), activeDatabase: activeDatabase, activeSchema: activeSchema, diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 75e60563cc..8279b7318a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -27,8 +27,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var isConnected = false internal var activeDatabase: String? internal var activeSchema: String? - private var pendingTruncates: Set = [] - private var pendingDeletes: Set = [] + private var pendingTruncates: Set = [] + private var pendingDeletes: Set = [] internal var showRecentTables = true private var rowSize: SidebarRowSize = .medium @@ -42,7 +42,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private var cachedRowActions: DatabaseTreeRowActions? private var lastSelection: Set = [] private var lastSelectedNodeIds: [String] = [] - private var publishedTables: Set = [] + private var publishedTables: Set = [] private var publishedSelectionDatabase: String? private var isModelSelectionAdoptionPending = false private var openSelectionDepth = 0 @@ -370,7 +370,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { var nodes: [DatabaseTreeNode] = [] for node in nodeCache.values { - guard case .table(let ref) = node.kind, selectedTables.contains(ref.table) else { continue } + guard case .table(let ref) = node.kind, selectedTables.contains(ref) else { continue } guard selectionDatabase == nil || ref.database == selectionDatabase else { continue } nodes.append(node) } @@ -378,7 +378,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { lastSelection = Set(DatabaseTreeSelection.tableRefs(of: nodes)) /// Still pending while a selected table has no row in the database being browsed: the row is /// usually one that has not been built yet, and the next sync adopts it. - isModelSelectionAdoptionPending = Set(lastSelection.map(\.table)) != selectedTables + isModelSelectionAdoptionPending = Set(lastSelection) != selectedTables } private var modelSelectionDatabase: String? { @@ -423,7 +423,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { private func publishSelection() { guard let windowState else { return } let nodes = selectedNodes() - let tables = DatabaseTreeSelection.tableInfos(of: nodes) + let tables = Set(DatabaseTreeSelection.tableRefs(of: nodes)) publishedTables = tables publishedSelectionDatabase = modelSelectionDatabase isModelSelectionAdoptionPending = false diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift index 57c4c56be8..8379e2859c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineView.swift @@ -19,15 +19,15 @@ struct DatabaseTreeOutlineView: NSViewRepresentable { let windowState: WindowSidebarState let sidebarState: SharedSidebarState let viewModel: SidebarViewModel - let pendingTruncates: Set - let pendingDeletes: Set + let pendingTruncates: Set + let pendingDeletes: Set let searchText: String /// Rebuilds the tree when the session comes back, which is the one thing outside the metadata /// services that invalidates every node at once. let isConnected: Bool let activeDatabase: String? let activeSchema: String? - let selectedTables: Set + let selectedTables: Set let showRecentTables: Bool let rowSizePreference: SidebarRowSizePreference diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 2906761b58..23e357bc19 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -27,8 +27,8 @@ struct DatabaseTreeRowContext { let activeDatabase: String? let activeSchema: String? let systemSchemas: Set - let pendingTruncates: Set - let pendingDeletes: Set + let pendingTruncates: Set + let pendingDeletes: Set /// AppKit sizes the row, but it lays out `NSTableCellView.textField` and `imageView` to do it, /// and this cell hosts SwiftUI instead. The size has to reach the content or the text stays one /// size inside three different row heights. @@ -145,8 +145,8 @@ struct DatabaseTreeRowView: View { private func tableRow(_ ref: DatabaseTreeTableRef) -> some View { TableRow( table: ref.table, - isPendingTruncate: context.pendingTruncates.contains(ref.table.name), - isPendingDelete: context.pendingDeletes.contains(ref.table.name), + isPendingTruncate: context.pendingTruncates.contains(ref), + isPendingDelete: context.pendingDeletes.contains(ref), isFavorite: isFavorite, onToggleFavorite: { actions.toggleFavorite(ref) } ) diff --git a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift index 842ca68dde..b473f92157 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift @@ -29,20 +29,16 @@ internal enum DatabaseTreeSelection { node.tableRef ?? node.recentTableRef } + /// What the Table menu acts on, and what a queued Truncate or Drop is keyed by. The tree + /// never published its selection at all, so those commands read an always-empty set and did + /// nothing in tree layout while the sidebar plainly showed rows selected. + /// + /// A table reachable from two rows, its own and its Recent entry, is one table, so a set built + /// from these collapses them. internal static func tableRefs(of nodes: [DatabaseTreeNode]) -> [DatabaseTreeTableRef] { nodes.compactMap(tableRef) } - /// What the Table menu acts on. The tree never published its selection, so Truncate, Copy Name - /// and Delete read an always-empty set and did nothing at all in tree layout while the sidebar - /// plainly showed rows selected. - /// - /// A table reachable from two rows, its own and its Recent entry, is one table, so the set - /// collapses them. - internal static func tableInfos(of nodes: [DatabaseTreeNode]) -> Set { - Set(tableRefs(of: nodes).map(\.table)) - } - /// The one table a selection change should open, if any. /// /// Navigation follows a selection of one. Cmd-clicking a second table, or Shift-arrowing onto diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 9a4f18b19c..d7e108b280 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -6,28 +6,6 @@ import SwiftUI import TableProPluginKit -/// `database` is nil when the connection browses no database, which is the normal state for -/// an engine that has none. It stays optional all the way from `browsingDatabase` so it can be -/// compared against the equally optional active database: an empty string here read as "some -/// database called nothing", never equal to nil, and fired a database switch on every click. -struct DatabaseTreeTableRef: Hashable, Identifiable { - let database: String? - let schema: String? - let table: TableInfo - - var id: String { - "\(database ?? "")|\(schema ?? "")|\(table.id)" - } - - static func == (lhs: DatabaseTreeTableRef, rhs: DatabaseTreeTableRef) -> Bool { - lhs.id == rhs.id - } - - func hash(into hasher: inout Hasher) { - hasher.combine(id) - } -} - struct DatabaseTreeRoutineRef: Identifiable, Equatable { let database: String? let schema: String? @@ -63,8 +41,8 @@ struct DatabaseTreeView: View { let databaseType: DatabaseType let viewModel: SidebarViewModel let windowState: WindowSidebarState - @Binding var pendingTruncates: Set - @Binding var pendingDeletes: Set + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set let coordinator: MainContentCoordinator? let sidebarState: SharedSidebarState diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 00c3f9041e..f4ee9aa566 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -12,7 +12,7 @@ import TableProPluginKit /// reports as `clickedRow == -1`. That case used to produce no menu at all. internal struct DatabaseTreeMenuContext { internal let clicked: DatabaseTreeNode.Kind? - internal let selectedTables: Set + internal let selectedTables: Set internal let selectedContainers: [DatabaseContainerRef] internal let activeDatabase: String? internal let activeSchema: String? @@ -97,8 +97,14 @@ internal enum DatabaseTreeMenuSpec { _ ref: DatabaseTreeTableRef, context: DatabaseTreeMenuContext ) -> [DatabaseTreeMenuItem] { - let targets = SidebarMenuTarget.resolve(clicked: ref.table, selection: Array(context.selectedTables)) - let names = targets.map(\.name).sorted() + /// Narrowed to the clicked row's own database, because a queued Truncate or Drop is + /// applied by one save against one database. A tree selection can span two of them, and + /// the second database's tables would then either run in the first or refuse the whole + /// save; asking for them separately is the honest shape. + let targets = SidebarMenuTarget + .resolve(clicked: ref, selection: Array(context.selectedTables)) + .filter { $0.database == ref.database } + let names = targets.map(\.table.name).sorted() var items: [DatabaseTreeMenuItem] = [ .command(String(localized: "Open in New Tab"), .openInNewTab(ref)), .command(String(localized: "Show Structure"), .showStructure(ref)) @@ -140,11 +146,11 @@ internal enum DatabaseTreeMenuSpec { items.append(.separator) items.append(.command(String(localized: "Create New View…"), .createView)) if SidebarContextMenuLogic.truncateVisible(clickedTable: ref.table) { - items.append(.command(String(localized: "Truncate"), .truncateTables(names: names, ref: ref))) + items.append(.command(String(localized: "Truncate"), .truncateTables(targets: targets, ref: ref))) } items.append(.command( SidebarContextMenuLogic.deleteLabel(for: ref.table.type), - .dropTables(names: names, ref: ref) + .dropTables(targets: targets, ref: ref) )) return items } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index eecff18512..fb67145076 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -29,8 +29,10 @@ internal enum SidebarMenuCommand: Equatable { case exportTables(names: Set, ref: DatabaseTreeTableRef) case importTables(formatId: String, ref: DatabaseTreeTableRef) case maintenance(operation: String, tableName: String, ref: DatabaseTreeTableRef) - case truncateTables(names: [String], ref: DatabaseTreeTableRef) - case dropTables(names: [String], ref: DatabaseTreeTableRef) + /// Queued rather than run, so these carry every target in full: a queue keyed by name is + /// resolved against whatever the tab in front points at by the time Save runs. + case truncateTables(targets: [DatabaseTreeTableRef], ref: DatabaseTreeTableRef) + case dropTables(targets: [DatabaseTreeTableRef], ref: DatabaseTreeTableRef) case toggleFavorite(DatabaseTreeTableRef) case removeRecent(DatabaseTreeTableRef) case clearRecents diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index c0e3d3511f..289cbeed0c 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -8,8 +8,8 @@ struct SidebarTreeView: View { let viewModel: SidebarViewModel let windowState: WindowSidebarState var sidebarState: SharedSidebarState - @Binding var pendingTruncates: Set - @Binding var pendingDeletes: Set + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set weak var coordinator: MainContentCoordinator? @State private var settingsManager = AppSettingsManager.shared diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 915bb9a39f..5c1a293385 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -16,8 +16,8 @@ struct SidebarView: View { var sidebarState: SharedSidebarState var windowState: WindowSidebarState - @Binding var pendingTruncates: Set - @Binding var pendingDeletes: Set + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set var connectionId: UUID private weak var coordinator: MainContentCoordinator? @@ -65,9 +65,9 @@ struct SidebarView: View { init( sidebarState: SharedSidebarState, windowState: WindowSidebarState, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]>, databaseType: DatabaseType, connectionId: UUID, coordinator: MainContentCoordinator? = nil @@ -143,7 +143,7 @@ struct SidebarView: View { } let prompt = TableOperationPrompt( operationType: operationType, - tableName: firstTable, + tableName: firstTable.table.name, tableCount: viewModel.pendingOperationTables.count, cascadeSupported: PluginManager.shared.supportsCascadeDrop(for: viewModel.databaseType), foreignKeyDisableSupported: PluginManager.shared.supportsForeignKeyDisable(for: viewModel.databaseType) diff --git a/TableProTests/Core/Database/MultiConnectionTests.swift b/TableProTests/Core/Database/MultiConnectionTests.swift index 20760415f9..31ed97efb8 100644 --- a/TableProTests/Core/Database/MultiConnectionTests.swift +++ b/TableProTests/Core/Database/MultiConnectionTests.swift @@ -97,10 +97,10 @@ struct DatabaseManagerMultiSessionTests { } DatabaseManager.shared.updateSession(id1) { session in - session.pendingTruncates = ["users"] + session.pendingTruncates = [TestFixtures.makeTableRef(name: "users")] } - #expect(DatabaseManager.shared.session(for: id1)?.pendingTruncates == ["users"]) + #expect(DatabaseManager.shared.session(for: id1)?.pendingTruncates == [TestFixtures.makeTableRef(name: "users")]) #expect(DatabaseManager.shared.session(for: id2)?.pendingTruncates.isEmpty == true) } @@ -133,7 +133,7 @@ struct DatabaseManagerMultiSessionTests { let countBefore = DatabaseManager.shared.activeSessions.count DatabaseManager.shared.updateSession(unknownId) { session in - session.pendingTruncates = ["ghost"] + session.pendingTruncates = [TestFixtures.makeTableRef(name: "ghost")] } #expect(DatabaseManager.shared.activeSessions.count == countBefore) diff --git a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift index ff6f3a2e0d..e6518b9b22 100644 --- a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift +++ b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift @@ -75,98 +75,120 @@ private final class StubForeignKeyDriver: PluginDatabaseDriver, @unchecked Senda @Suite("TableOperationSQLBuilder") @MainActor struct TableOperationSQLBuilderTests { + private func ref( + _ name: String, + _ type: TableInfo.TableType = .table, + schema: String? = nil, + rowSchema: String? = nil, + database: String? = nil + ) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: database, + schema: rowSchema, + table: TableInfo(name: name, type: type, rowCount: nil, schema: schema) + ) + } + private func makeForeignKeyBuilder() -> TableOperationSQLBuilder { let connection = DatabaseConnection(name: "Test", type: .mysql) let adapter = PluginDriverAdapter(connection: connection, pluginDriver: StubForeignKeyDriver()) - return TableOperationSQLBuilder( - connectionId: connection.id, - databaseType: .mysql, - tableInfoProvider: { [:] }, - adapterProvider: { adapter } - ) + return TableOperationSQLBuilder(adapterProvider: { adapter }) } - private func makeBuilder(tables: [TableInfo]) -> TableOperationSQLBuilder { + private func makeBuilder() -> TableOperationSQLBuilder { let connection = DatabaseConnection(name: "Test", type: .postgresql) let adapter = PluginDriverAdapter(connection: connection, pluginDriver: StubDropDriver()) - return TableOperationSQLBuilder( - connectionId: connection.id, - databaseType: .postgresql, - tableInfoProvider: { Dictionary(uniqueKeysWithValues: tables.map { ($0.name, $0) }) }, - adapterProvider: { adapter } - ) + return TableOperationSQLBuilder(adapterProvider: { adapter }) } @Test("Materialized view drops with DROP MATERIALIZED VIEW") func dropsMaterializedView() { - let builder = makeBuilder(tables: [ - TableInfo(name: "daily_sales", type: .materializedView, rowCount: nil, schema: "public") - ]) - let stmts = builder.generate(truncates: [], deletes: ["daily_sales"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("daily_sales", .materializedView, schema: "public")], + options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP MATERIALIZED VIEW \"public\".\"daily_sales\""]) } @Test("View drops with DROP VIEW") func dropsView() { - let builder = makeBuilder(tables: [TableInfo(name: "active_users", type: .view, rowCount: nil)]) - let stmts = builder.generate(truncates: [], deletes: ["active_users"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("active_users", .view)], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP VIEW \"active_users\""]) } @Test("Foreign table drops with DROP FOREIGN TABLE") func dropsForeignTable() { - let builder = makeBuilder(tables: [TableInfo(name: "remote_orders", type: .foreignTable, rowCount: nil)]) - let stmts = builder.generate(truncates: [], deletes: ["remote_orders"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("remote_orders", .foreignTable)], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP FOREIGN TABLE \"remote_orders\""]) } @Test("External table drops with DROP TABLE") func dropsExternalTable() { - let builder = makeBuilder(tables: [TableInfo(name: "customers", type: .externalTable, rowCount: nil)]) - let stmts = builder.generate(truncates: [], deletes: ["customers"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("customers", .externalTable)], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP TABLE \"customers\""]) } @Test("Plain table drops with DROP TABLE") func dropsTable() { - let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil)]) - let stmts = builder.generate(truncates: [], deletes: ["orders"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("orders")], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP TABLE \"orders\""]) } @Test("System table drops with DROP TABLE") func dropsSystemTable() { - let builder = makeBuilder(tables: [TableInfo(name: "pg_stats", type: .systemTable, rowCount: nil)]) - let stmts = builder.generate(truncates: [], deletes: ["pg_stats"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("pg_stats", .systemTable)], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP TABLE \"pg_stats\""]) } - @Test("Unresolvable name falls back to DROP TABLE") - func fallsBackWhenLookupMisses() { - let builder = makeBuilder(tables: []) - let stmts = builder.generate(truncates: [], deletes: ["ghost"], options: [:], includeFKHandling: false) - #expect(stmts == ["DROP TABLE \"ghost\""]) + /// The queued row carries its own type and schema, so a tree that publishes no flat table list + /// still produces the right statement. It used to look the object up in that list, which is + /// empty on every engine whose tree is per-schema: a view on Oracle came out as an unqualified + /// `DROP TABLE` and raised ORA-00942, and a table of the same name in the login schema was a + /// live target. + @Test("A row under a schema node drops qualified and typed with no table cache") + func hierarchicalRowKeepsTypeAndSchema() { + let stmts = makeBuilder().generate( + truncates: [], + deletes: [ref("EMP_VIEW", .view, rowSchema: "HR")], + options: [:], + includeFKHandling: false + ) + #expect(stmts == ["DROP VIEW \"HR\".\"EMP_VIEW\""]) } @Test("Cascade applies to materialized view drops") func cascadeAppliesToMaterializedView() { - let builder = makeBuilder(tables: [TableInfo(name: "daily_sales", type: .materializedView, rowCount: nil)]) - let options = ["daily_sales": TableOperationOptions(cascade: true)] - let stmts = builder.generate(truncates: [], deletes: ["daily_sales"], options: options, includeFKHandling: false) + let target = ref("daily_sales", .materializedView) + let stmts = makeBuilder().generate( + truncates: [], deletes: [target], + options: [target: TableOperationOptions(cascade: true)], includeFKHandling: false + ) #expect(stmts == ["DROP MATERIALIZED VIEW \"daily_sales\" CASCADE"]) } @Test("Drop qualifies schema when TableInfo carries one") func qualifiesSchema() { - let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil, schema: "sales")]) - let stmts = builder.generate(truncates: [], deletes: ["orders"], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [], deletes: [ref("orders", schema: "sales")], options: [:], includeFKHandling: false + ) #expect(stmts == ["DROP TABLE \"sales\".\"orders\""]) } @Test("Truncate qualifies schema when TableInfo carries one") func truncateQualifiesSchema() { - let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil, schema: "sales")]) - let stmts = builder.generate(truncates: ["orders"], deletes: [], options: [:], includeFKHandling: false) + let stmts = makeBuilder().generate( + truncates: [ref("orders", schema: "sales")], deletes: [], options: [:], includeFKHandling: false + ) #expect(stmts == ["TRUNCATE TABLE \"sales\".\"orders\""]) } @@ -187,7 +209,7 @@ struct TableOperationSQLBuilderTests { /// tell it. @Test("A driver with no foreign key support produces no statements") func noForeignKeySupportProducesNothing() { - let builder = makeBuilder(tables: []) + let builder = makeBuilder() #expect(builder.foreignKeyDisableStatements().isEmpty) #expect(builder.foreignKeyEnableStatements().isEmpty) } @@ -201,13 +223,14 @@ struct TableOperationSQLBuilderTests { /// that has never known what MySQL is. @Test("Foreign key handling wraps the sorted truncates and drops") func foreignKeyHandlingWrapsSortedWork() { - let builder = makeForeignKeyBuilder() - let stmts = builder.generate( - truncates: ["zebra", "apple"], - deletes: ["yak", "bee"], + let apple = ref("apple") + let zebra = ref("zebra") + let stmts = makeForeignKeyBuilder().generate( + truncates: [zebra, apple], + deletes: [ref("yak"), ref("bee")], options: [ - "apple": TableOperationOptions(ignoreForeignKeys: true), - "zebra": TableOperationOptions(ignoreForeignKeys: true), + apple: TableOperationOptions(ignoreForeignKeys: true), + zebra: TableOperationOptions(ignoreForeignKeys: true), ] ) #expect(stmts == [ @@ -219,13 +242,4 @@ struct TableOperationSQLBuilderTests { "SET FOREIGN_KEY_CHECKS=1", ]) } - - /// No table asked to ignore foreign keys, so nothing wraps the work even though the driver - /// would happily supply the statements. - @Test("Nothing wraps the work when no table ignores foreign keys") - func noWrappingWithoutTheOption() { - let builder = makeForeignKeyBuilder() - let stmts = builder.generate(truncates: ["apple"], deletes: [], options: [:]) - #expect(stmts == ["TRUNCATE TABLE \"apple\""]) - } } diff --git a/TableProTests/Helpers/TestFixtures.swift b/TableProTests/Helpers/TestFixtures.swift index a4c861034f..56423273f5 100644 --- a/TableProTests/Helpers/TestFixtures.swift +++ b/TableProTests/Helpers/TestFixtures.swift @@ -111,6 +111,19 @@ enum TestFixtures { ) } + static func makeTableRef( + name: String = "test_table", + type: TableInfo.TableType = .table, + database: String? = nil, + schema: String? = nil + ) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: database, + schema: schema, + table: makeTableInfo(name: name, type: type, schema: schema) + ) + } + static func makeEditableColumn( name: String = "id", dataType: String = "INT", diff --git a/TableProTests/Models/ConnectionSessionTests.swift b/TableProTests/Models/ConnectionSessionTests.swift index a49e68901e..e0c5cd3ab5 100644 --- a/TableProTests/Models/ConnectionSessionTests.swift +++ b/TableProTests/Models/ConnectionSessionTests.swift @@ -107,7 +107,7 @@ struct ConnectionSessionEquivalenceTests { var a = makeSession(id: id) var b = makeSession(id: id) - a.pendingTruncates = ["users"] + a.pendingTruncates = [TestFixtures.makeTableRef(name: "users")] b.pendingTruncates = [] #expect(!a.isContentViewEquivalent(to: b)) @@ -119,7 +119,7 @@ struct ConnectionSessionEquivalenceTests { var a = makeSession(id: id) var b = makeSession(id: id) - a.selectedTables = [TestFixtures.makeTableInfo(name: "users")] + a.selectedTables = [TestFixtures.makeTableRef(name: "users")] b.selectedTables = [] #expect(a.isContentViewEquivalent(to: b)) @@ -163,7 +163,7 @@ struct ConnectionSessionStateTests { @Test("clearCachedData clears selectedTables") func clearCachedDataClearsSelectedTables() { var session = makeSession() - session.selectedTables = [TestFixtures.makeTableInfo(name: "users")] + session.selectedTables = [TestFixtures.makeTableRef(name: "users")] session.clearCachedData() #expect(session.selectedTables.isEmpty) } @@ -171,7 +171,7 @@ struct ConnectionSessionStateTests { @Test("clearCachedData clears pendingTruncates") func clearCachedDataClearsPendingTruncates() { var session = makeSession() - session.pendingTruncates = ["users", "orders"] + session.pendingTruncates = [TestFixtures.makeTableRef(name: "users"), TestFixtures.makeTableRef(name: "orders")] session.clearCachedData() #expect(session.pendingTruncates.isEmpty) } @@ -179,7 +179,7 @@ struct ConnectionSessionStateTests { @Test("clearCachedData clears pendingDeletes") func clearCachedDataClearsPendingDeletes() { var session = makeSession() - session.pendingDeletes = ["users", "orders"] + session.pendingDeletes = [TestFixtures.makeTableRef(name: "users"), TestFixtures.makeTableRef(name: "orders")] session.clearCachedData() #expect(session.pendingDeletes.isEmpty) } @@ -187,7 +187,7 @@ struct ConnectionSessionStateTests { @Test("clearCachedData clears tableOperationOptions") func clearCachedDataClearsTableOperationOptions() { var session = makeSession() - session.tableOperationOptions = ["users": TableOperationOptions()] + session.tableOperationOptions = [TestFixtures.makeTableRef(name: "users"): TableOperationOptions()] session.clearCachedData() #expect(session.tableOperationOptions.isEmpty) } @@ -197,7 +197,7 @@ struct ConnectionSessionStateTests { let connection = TestFixtures.makeConnection(name: "Production") var session = ConnectionSession(connection: connection) session.status = .connected - session.selectedTables = [TestFixtures.makeTableInfo(name: "users")] + session.selectedTables = [TestFixtures.makeTableRef(name: "users")] session.clearCachedData() #expect(session.status == .connected) #expect(session.connection.id == connection.id) diff --git a/TableProTests/Models/Database/StagedWriteScopeTests.swift b/TableProTests/Models/Database/StagedWriteScopeTests.swift new file mode 100644 index 0000000000..c5e4205566 --- /dev/null +++ b/TableProTests/Models/Database/StagedWriteScopeTests.swift @@ -0,0 +1,129 @@ +// +// StagedWriteScopeTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Staged write scope") +struct StagedWriteScopeTests { + private let connectionId = UUID() + + private func scope(_ database: String, schema: String? = nil) -> DatabaseScope { + DatabaseScope(connectionId: connectionId, database: database, schema: schema) + } + + /// The statements are generated by the session's own driver before anything is pinned, and a + /// driver that qualifies from its live connection writes the database it is on into the + /// statement. Queue a drop while browsing `staging`, switch to `production` and press Save, and + /// the plan used to run the queued name against production and drop the production table. + @Test("A queue for a database the connection has left is refused") + func queueOutsideTheBrowsedDatabaseIsRefused() { + #expect(throws: DatabaseError.self) { + try StagedWriteScope.resolve( + tabScope: scope("production", schema: "public"), + browseDatabase: "production", + stagedDatabases: ["staging"], + includesRowEdits: false + ) + } + } + + /// The tab in front is free to be somewhere else. Only the driver that generated the + /// statements has to be where they will run. + @Test("A queue for the browsed database runs there even when the tab is elsewhere") + func queueRunsInTheBrowsedDatabase() throws { + let resolved = try StagedWriteScope.resolve( + tabScope: scope("production", schema: "public"), + browseDatabase: "staging", + stagedDatabases: ["staging"], + includesRowEdits: false + ) + + #expect(resolved.database == "staging") + } + + /// A schema belongs to the database it was read from. Carrying the tab's into another database + /// would qualify every statement with a schema that need not exist there, and each queued + /// statement qualifies its own anyway. + @Test("Crossing databases drops the tab's schema") + func crossingDatabasesDropsTheSchema() throws { + let resolved = try StagedWriteScope.resolve( + tabScope: scope("production", schema: "public"), + browseDatabase: "staging", + stagedDatabases: ["staging"], + includesRowEdits: false + ) + + #expect(resolved.schema == nil) + } + + @Test("A queue in the tab's own database keeps the tab's scope whole") + func sameDatabaseKeepsTheTabScope() throws { + let tab = scope("app", schema: "reporting") + let resolved = try StagedWriteScope.resolve( + tabScope: tab, + browseDatabase: "app", + stagedDatabases: ["app"], + includesRowEdits: false + ) + + #expect(resolved == tab) + } + + @Test("Nothing queued leaves the tab's scope alone") + func emptyQueueUsesTheTabScope() throws { + let tab = scope("app", schema: "public") + let resolved = try StagedWriteScope.resolve( + tabScope: tab, + browseDatabase: "reporting", + stagedDatabases: [], + includesRowEdits: true + ) + + #expect(resolved == tab) + } + + /// An engine with no databases queues rows that name none, which is not the same as naming a + /// different one. + @Test("A queue that names no database runs where the tab does") + func unnamedDatabaseUsesTheTabScope() throws { + let tab = scope("", schema: nil) + let resolved = try StagedWriteScope.resolve( + tabScope: tab, + browseDatabase: "", + stagedDatabases: [""], + includesRowEdits: false + ) + + #expect(resolved == tab) + } + + /// One plan runs on one driver, so this has no answer. Refusing is what stops the save landing + /// half of the queue in the wrong place. + @Test("A queue spanning two databases is refused") + func spanningQueueIsRefused() { + #expect(throws: DatabaseError.self) { + try StagedWriteScope.resolve( + tabScope: scope("app"), + browseDatabase: "app", + stagedDatabases: ["staging", "production"], + includesRowEdits: false + ) + } + } + + @Test("Row edits on one database and a queue on another are refused") + func rowEditsAcrossTheQueueAreRefused() { + #expect(throws: DatabaseError.self) { + try StagedWriteScope.resolve( + tabScope: scope("production"), + browseDatabase: "staging", + stagedDatabases: ["staging"], + includesRowEdits: true + ) + } + } +} diff --git a/TableProTests/Models/Query/TableTabIdentityTests.swift b/TableProTests/Models/Query/TableTabIdentityTests.swift new file mode 100644 index 0000000000..abc650fffe --- /dev/null +++ b/TableProTests/Models/Query/TableTabIdentityTests.swift @@ -0,0 +1,106 @@ +// +// TableTabIdentityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// Closing tabs after a drop compared bare table names, so dropping `analytics.users` also closed +/// the tab on `public.users` and threw away its row buffer with nothing to undo it. +@Suite("Table tab identity") +struct TableTabIdentityTests { + private func ref(_ name: String, database: String?, schema: String?) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: database, + schema: schema, + table: TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + ) + } + + @Test("Two schemas of one database are two different objects") + func schemaSeparatesTwoRowsOfOneName() { + let analytics = TableTabIdentity( + ref: ref("users", database: "app", schema: "analytics"), + browsing: "app", + resolvedSchema: "analytics" + ) + let publicUsers = TableTabIdentity( + ref: ref("users", database: "app", schema: "public"), + browsing: "app", + resolvedSchema: "public" + ) + + #expect(analytics != publicUsers) + } + + @Test("Two databases holding one name are two different objects") + func databaseSeparatesTwoRowsOfOneName() { + let staging = TableTabIdentity( + ref: ref("orders", database: "staging", schema: nil), + browsing: "staging", + resolvedSchema: nil + ) + let production = TableTabIdentity( + ref: ref("orders", database: "production", schema: nil), + browsing: "production", + resolvedSchema: nil + ) + + #expect(staging != production) + } + + /// The tree activates a row's database before it opens anything, so a tab keyed from the browse + /// cursor and a row that names the same database are the same object. + @Test("A row naming its database matches a tab keyed from the browse cursor") + func rowMatchesTheTabItOpened() { + let fromRow = TableTabIdentity( + ref: ref("orders", database: "app", schema: "public"), + browsing: "app", + resolvedSchema: "public" + ) + let fromTab = TableTabIdentity(table: "orders", database: "app", schema: "public") + + #expect(fromRow == fromTab) + } + + /// A tab restored from a payload written before tabs carried a database has an empty one, which + /// means the browse cursor. Read raw, it matched no dropped row, so the tab stayed open on a + /// table that had gone. + @Test("A tab that names no database of its own takes the browsed one") + func legacyTabTakesTheBrowsedDatabase() { + let fromRow = TableTabIdentity( + ref: ref("orders", database: "app", schema: nil), + browsing: "app", + resolvedSchema: nil + ) + let legacyTab = TableTabIdentity(table: "orders", database: "", schema: nil) + + #expect(fromRow != legacyTab) + #expect(fromRow == TableTabIdentity(table: "orders", database: "app", schema: nil)) + } + + /// The two sides spell an absent schema differently: a tab that never had one stores the empty + /// string, a row leaves it nil. + @Test("An empty schema and no schema are the same object") + func emptySchemaMatchesNoSchema() { + #expect( + TableTabIdentity(table: "orders", database: "app", schema: "") + == TableTabIdentity(table: "orders", database: "app", schema: nil) + ) + } + + /// An engine with no databases leaves the row's own empty, and the browse cursor is then the + /// only thing that names one. + @Test("A row naming no database takes the browsed one") + func rowWithoutADatabaseTakesTheBrowsedOne() { + let identity = TableTabIdentity( + ref: ref("orders", database: nil, schema: nil), + browsing: "app", + resolvedSchema: nil + ) + + #expect(identity.database == "app") + } +} diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 2f47724422..1c4040599c 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -28,18 +28,18 @@ private final class SidebarMockClipboard: ClipboardProvider { @MainActor private func makeSUT( tables: [TableInfo] = [], - selectedTables: Set = [], - pendingTruncates: Set = [], - pendingDeletes: Set = [], - tableOperationOptions: [String: TableOperationOptions] = [:], + selectedTables: Set = [], + pendingTruncates: Set = [], + pendingDeletes: Set = [], + tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:], databaseType: DatabaseType = .mysql ) -> ( vm: SidebarViewModel, tables: Binding<[TableInfo]>, - selectedTables: Binding>, - pendingTruncates: Binding>, - pendingDeletes: Binding>, - tableOperationOptions: Binding<[String: TableOperationOptions]> + selectedTables: Binding>, + pendingTruncates: Binding>, + pendingDeletes: Binding>, + tableOperationOptions: Binding<[DatabaseTreeTableRef: TableOperationOptions]> ) { var tablesState = tables var selectedState = selectedTables @@ -65,6 +65,15 @@ private func makeSUT( return (vm, tablesBinding, selectedBinding, truncatesBinding, deletesBinding, optionsBinding) } +@MainActor +private func makeRef(_ name: String, database: String? = nil, schema: String? = nil) -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: database, + schema: schema, + table: TestFixtures.makeTableInfo(name: name) + ) +} + // MARK: - Tests @Suite("SidebarViewModel") @@ -75,31 +84,31 @@ struct SidebarViewModelTests { @Test("batchToggleTruncate shows dialog for new tables") @MainActor func batchToggleTruncateShowsDialog() { - let table = TestFixtures.makeTableInfo(name: "users") + let table = makeRef("users") let (vm, _, _, _, _, _) = makeSUT(selectedTables: [table]) vm.batchToggleTruncate() #expect(vm.showOperationDialog) #expect(vm.pendingOperationType == .truncate) - #expect(vm.pendingOperationTables == ["users"]) + #expect(vm.pendingOperationTables == [table]) } @Test("batchToggleTruncate cancels when all already pending") @MainActor func batchToggleTruncateCancels() { - let table = TestFixtures.makeTableInfo(name: "users") + let table = makeRef("users") let (vm, _, _, truncatesBinding, _, optionsBinding) = makeSUT( selectedTables: [table], - pendingTruncates: ["users"], - tableOperationOptions: ["users": TableOperationOptions()] + pendingTruncates: [table], + tableOperationOptions: [table: TableOperationOptions()] ) vm.batchToggleTruncate() #expect(!vm.showOperationDialog) - #expect(!truncatesBinding.wrappedValue.contains("users")) - #expect(optionsBinding.wrappedValue["users"] == nil) + #expect(!truncatesBinding.wrappedValue.contains(table)) + #expect(optionsBinding.wrappedValue[table] == nil) } @Test("batchToggleTruncate does nothing when no selection") @@ -117,31 +126,31 @@ struct SidebarViewModelTests { @Test("batchToggleDelete shows dialog for new tables") @MainActor func batchToggleDeleteShowsDialog() { - let table = TestFixtures.makeTableInfo(name: "orders") + let table = makeRef("orders") let (vm, _, _, _, _, _) = makeSUT(selectedTables: [table]) vm.batchToggleDelete() #expect(vm.showOperationDialog) #expect(vm.pendingOperationType == .drop) - #expect(vm.pendingOperationTables == ["orders"]) + #expect(vm.pendingOperationTables == [table]) } @Test("batchToggleDelete cancels when all already pending") @MainActor func batchToggleDeleteCancels() { - let table = TestFixtures.makeTableInfo(name: "orders") + let table = makeRef("orders") let (vm, _, _, _, deletesBinding, optionsBinding) = makeSUT( selectedTables: [table], - pendingDeletes: ["orders"], - tableOperationOptions: ["orders": TableOperationOptions()] + pendingDeletes: [table], + tableOperationOptions: [table: TableOperationOptions()] ) vm.batchToggleDelete() #expect(!vm.showOperationDialog) - #expect(!deletesBinding.wrappedValue.contains("orders")) - #expect(optionsBinding.wrappedValue["orders"] == nil) + #expect(!deletesBinding.wrappedValue.contains(table)) + #expect(optionsBinding.wrappedValue[table] == nil) } // MARK: - Confirm Operation @@ -149,68 +158,68 @@ struct SidebarViewModelTests { @Test("confirmOperation truncate moves tables from pendingDeletes to pendingTruncates") @MainActor func confirmTruncateMovesFromDeletes() { - let table = TestFixtures.makeTableInfo(name: "users") + let table = makeRef("users") let (vm, _, _, truncatesBinding, deletesBinding, optionsBinding) = makeSUT( selectedTables: [table], - pendingDeletes: ["users"] + pendingDeletes: [table] ) vm.pendingOperationType = .truncate - vm.pendingOperationTables = ["users"] + vm.pendingOperationTables = [table] let options = TableOperationOptions(ignoreForeignKeys: true) vm.confirmOperation(options: options) - #expect(truncatesBinding.wrappedValue.contains("users")) - #expect(!deletesBinding.wrappedValue.contains("users")) - #expect(optionsBinding.wrappedValue["users"]?.ignoreForeignKeys == true) + #expect(truncatesBinding.wrappedValue.contains(table)) + #expect(!deletesBinding.wrappedValue.contains(table)) + #expect(optionsBinding.wrappedValue[table]?.ignoreForeignKeys == true) } @Test("confirmOperation drop moves tables from pendingTruncates to pendingDeletes") @MainActor func confirmDropMovesFromTruncates() { - let table = TestFixtures.makeTableInfo(name: "users") + let table = makeRef("users") let (vm, _, _, truncatesBinding, deletesBinding, optionsBinding) = makeSUT( selectedTables: [table], - pendingTruncates: ["users"] + pendingTruncates: [table] ) vm.pendingOperationType = .drop - vm.pendingOperationTables = ["users"] + vm.pendingOperationTables = [table] let options = TableOperationOptions(cascade: true) vm.confirmOperation(options: options) - #expect(!truncatesBinding.wrappedValue.contains("users")) - #expect(deletesBinding.wrappedValue.contains("users")) - #expect(optionsBinding.wrappedValue["users"]?.cascade == true) + #expect(!truncatesBinding.wrappedValue.contains(table)) + #expect(deletesBinding.wrappedValue.contains(table)) + #expect(optionsBinding.wrappedValue[table]?.cascade == true) } @Test("confirmOperation stores options per table") @MainActor func confirmOperationStoresOptions() { - let t1 = TestFixtures.makeTableInfo(name: "t1") - let t2 = TestFixtures.makeTableInfo(name: "t2") + let t1 = makeRef("t1") + let t2 = makeRef("t2") let (vm, _, _, _, _, optionsBinding) = makeSUT(selectedTables: [t1, t2]) vm.pendingOperationType = .truncate - vm.pendingOperationTables = ["t1", "t2"] + vm.pendingOperationTables = [t1, t2] let options = TableOperationOptions(ignoreForeignKeys: true, cascade: true) vm.confirmOperation(options: options) - #expect(optionsBinding.wrappedValue["t1"] == options) - #expect(optionsBinding.wrappedValue["t2"] == options) + #expect(optionsBinding.wrappedValue[t1] == options) + #expect(optionsBinding.wrappedValue[t2] == options) } @Test("confirmOperation resets dialog state after confirm") @MainActor func confirmOperationResetsDialogState() { - let table = TestFixtures.makeTableInfo(name: "users") + let table = makeRef("users") let (vm, _, _, _, _, _) = makeSUT(selectedTables: [table]) vm.pendingOperationType = .truncate - vm.pendingOperationTables = ["users"] + vm.pendingOperationTables = [table] vm.showOperationDialog = true vm.confirmOperation(options: TableOperationOptions()) @@ -229,8 +238,8 @@ struct SidebarViewModelTests { let clipboard = SidebarMockClipboard() ClipboardService.shared = clipboard - let t1 = TestFixtures.makeTableInfo(name: "zebra") - let t2 = TestFixtures.makeTableInfo(name: "alpha") + let t1 = makeRef("zebra") + let t2 = makeRef("alpha") let (vm, _, _, _, _, _) = makeSUT(selectedTables: [t1, t2]) vm.copySelectedTableNames() @@ -261,10 +270,10 @@ private func makeViewModel( connectionId: UUID = UUID(), databaseType: DatabaseType = .postgresql ) -> SidebarViewModel { - var selectedState: Set = [] - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var selectedState: Set = [] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] let selectedBinding = Binding(get: { selectedState }, set: { selectedState = $0 }) let truncatesBinding = Binding(get: { truncates }, set: { truncates = $0 }) let deletesBinding = Binding(get: { deletes }, set: { deletes = $0 }) diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift index e00365593a..e6815e167e 100644 --- a/TableProTests/ViewModels/WindowSidebarStateTests.swift +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -21,7 +21,7 @@ struct WindowSidebarStateTests { let windowA = WindowSidebarState() let windowB = WindowSidebarState() - let users = TestFixtures.makeTableInfo(name: "users") + let users = TestFixtures.makeTableRef(name: "users") windowA.selectedTables = [users] #expect(windowA.selectedTables == [users]) @@ -36,7 +36,7 @@ struct WindowSidebarStateTests { @Test("A table the user picked without opening it survives a background reload") func refusesTheMarkOverASingleUserPick() { let state = WindowSidebarState() - state.select(tables: [TestFixtures.makeTableInfo(name: "orders")], rowCount: 1) + state.select(tables: [TestFixtures.makeTableRef(name: "orders")], rowCount: 1) #expect(!state.acceptsObjectMarkRefresh) } @@ -46,8 +46,8 @@ struct WindowSidebarStateTests { let state = WindowSidebarState() state.select( tables: [ - TestFixtures.makeTableInfo(name: "orders"), - TestFixtures.makeTableInfo(name: "users"), + TestFixtures.makeTableRef(name: "orders"), + TestFixtures.makeTableRef(name: "users"), ], rowCount: 2 ) diff --git a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift index c12a69ec81..dd3f27aefb 100644 --- a/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift +++ b/TableProTests/Views/Main/CommandActionsBulkCloseTests.swift @@ -25,10 +25,10 @@ struct CommandActionsBulkCloseTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] let actions = MainContentCommandActions( coordinator: coordinator, diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index bdb187ab03..d13d0ab4bf 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -42,10 +42,10 @@ struct CommandActionsDispatchTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] let rightPanelState = RightPanelState() let actions = MainContentCommandActions( diff --git a/TableProTests/Views/Main/CommandActionsFocusGateTests.swift b/TableProTests/Views/Main/CommandActionsFocusGateTests.swift index 0f3108263c..6de26fe6d3 100644 --- a/TableProTests/Views/Main/CommandActionsFocusGateTests.swift +++ b/TableProTests/Views/Main/CommandActionsFocusGateTests.swift @@ -19,10 +19,10 @@ struct CommandActionsFocusGateTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] return MainContentCommandActions( coordinator: coordinator, diff --git a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift index 236f99a34f..f590dfdd26 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorAddRowTests.swift @@ -65,10 +65,10 @@ struct MainContentCommandActionsResultViewTests { let state = SessionStateFactory.create(connection: connection, payload: nil) let coordinator = state.coordinator - var selectedTables: Set = [] - var pendingTruncates: Set = [] - var pendingDeletes: Set = [] - var tableOperationOptions: [String: TableOperationOptions] = [:] + var selectedTables: Set = [] + var pendingTruncates: Set = [] + var pendingDeletes: Set = [] + var tableOperationOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] let actions = MainContentCommandActions( coordinator: coordinator, diff --git a/TableProTests/Views/Main/SaveCompletionTests.swift b/TableProTests/Views/Main/SaveCompletionTests.swift index 110a73fedf..2d00196a1f 100644 --- a/TableProTests/Views/Main/SaveCompletionTests.swift +++ b/TableProTests/Views/Main/SaveCompletionTests.swift @@ -73,9 +73,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator() tabManager.addTab(databaseName: "testdb") - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -94,9 +94,9 @@ struct SaveCompletionTests { changeManager.hasChanges = true - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -116,9 +116,9 @@ struct SaveCompletionTests { changeManager.hasChanges = true - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -137,9 +137,9 @@ struct SaveCompletionTests { let (coordinator, _, changeManager) = makeCoordinator(safeModeLevel: .readOnly) changeManager.hasChanges = true - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -155,9 +155,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator() tabManager.addTab(databaseName: "testdb") - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -177,9 +177,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) tabManager.addTab(databaseName: "testdb") - var truncates: Set = ["users"] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [TestFixtures.makeTableRef(name: "users")] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -196,9 +196,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .safeMode) tabManager.addTab(databaseName: "testdb") - var truncates: Set = [] - var deletes: Set = ["orders"] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [TestFixtures.makeTableRef(name: "orders")] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -214,9 +214,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .alert) tabManager.addTab(databaseName: "testdb") - var truncates: Set = [] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, @@ -234,9 +234,9 @@ struct SaveCompletionTests { let (coordinator, tabManager, _) = makeCoordinator(safeModeLevel: .silent) tabManager.addTab(databaseName: "testdb") - var truncates: Set = ["users"] - var deletes: Set = [] - var options: [String: TableOperationOptions] = [:] + var truncates: Set = [TestFixtures.makeTableRef(name: "users")] + var deletes: Set = [] + var options: [DatabaseTreeTableRef: TableOperationOptions] = [:] coordinator.saveChanges( pendingTruncates: &truncates, diff --git a/TableProTests/Views/Main/SharedSidebarSyncTests.swift b/TableProTests/Views/Main/SharedSidebarSyncTests.swift index e71d7cc1de..a0377db1b9 100644 --- a/TableProTests/Views/Main/SharedSidebarSyncTests.swift +++ b/TableProTests/Views/Main/SharedSidebarSyncTests.swift @@ -17,8 +17,8 @@ import Testing struct SharedSidebarSyncTests { // MARK: - Helpers - private func makeTable(_ name: String, type: TableInfo.TableType = .table) -> TableInfo { - TestFixtures.makeTableInfo(name: name, type: type) + private func makeTable(_ name: String, type: TableInfo.TableType = .table) -> DatabaseTreeTableRef { + TestFixtures.makeTableRef(name: name, type: type) } // MARK: - syncSidebarObjectSelection must not trigger navigation @@ -27,8 +27,8 @@ struct SharedSidebarSyncTests { func syncSameTableSkipsNavigation() { // Simulates: didBecomeKey → syncSidebarObjectSelection → onChange fires // previousSelectedTables was empty (initial), sync sets [users] - let previousSelectedTables: Set = [] - let newSelectedTables: Set = [makeTable("users")] + let previousSelectedTables: Set = [] + let newSelectedTables: Set = [makeTable("users")] // TableSelectionAction sees one table added let action = TableSelectionAction.resolve( @@ -36,7 +36,7 @@ struct SharedSidebarSyncTests { newTables: newSelectedTables, selectedRowCount: (newSelectedTables).count ) - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) // But SidebarNavigationResult.resolve skips because clicked == current tab let result = SidebarNavigationResult.resolve( @@ -52,8 +52,8 @@ struct SharedSidebarSyncTests { func syncNoChangeNoOnChange() { // When sidebarState already has [users] and sync sets [users], // @Observable does not fire onChange (same value) - let previous: Set = [makeTable("users")] - let new: Set = [makeTable("users")] + let previous: Set = [makeTable("users")] + let new: Set = [makeTable("users")] let action = TableSelectionAction.resolve(oldTables: previous, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation, "Same selection set must not trigger navigation") } @@ -61,8 +61,8 @@ struct SharedSidebarSyncTests { @Test("syncSidebarObjectSelection clears selection for a query tab without navigating") func syncClearsForQueryTab() { // Current tab is SQL query (tableName = nil), sync clears sidebar - let previous: Set = [makeTable("users")] - let new: Set = [] + let previous: Set = [makeTable("users")] + let new: Set = [] let action = TableSelectionAction.resolve(oldTables: previous, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation, "Clearing selection must not navigate") } @@ -78,7 +78,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("users")], selectedRowCount: ([makeTable("users")]).count ) - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) // But isKeyWindow guard blocks it. We test the invariant: // handleTableSelectionChange should early-return when isKeyWindow=false. @@ -94,8 +94,8 @@ struct SharedSidebarSyncTests { func switchBackSameTable() { // User has "users" tab, switches away and back // syncSidebarObjectSelection sets [users] (same as before) - let previous: Set = [makeTable("users")] - let new: Set = [makeTable("users")] + let previous: Set = [makeTable("users")] + let new: Set = [makeTable("users")] let action = TableSelectionAction.resolve(oldTables: previous, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation, "Switch-back with same table must be no-op") } @@ -110,7 +110,7 @@ struct SharedSidebarSyncTests { selectedRowCount: ([makeTable("users")]).count ) // This produces .navigate — but SidebarNavigationResult catches it - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -142,7 +142,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("orders")], selectedRowCount: ([makeTable("orders")]).count ) - #expect(action == .navigate(table: TableInfo(name: "orders", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "orders", type: .table))) let result = SidebarNavigationResult.resolve( clickedTableName: "orders", @@ -160,7 +160,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("users")], selectedRowCount: ([makeTable("users")]).count ) - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -179,7 +179,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("users")], selectedRowCount: ([makeTable("users")]).count ) - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) let result = SidebarNavigationResult.resolve( clickedTableName: "users", @@ -201,7 +201,7 @@ struct SharedSidebarSyncTests { newTables: [makeTable("users")], selectedRowCount: ([makeTable("users")]).count ) - #expect(action == .navigate(table: TableInfo(name: "users", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "users", type: .table))) // Window B's isKeyWindow = false → handleTableSelectionChange returns early // This is enforced by the guard, not by these pure functions } diff --git a/TableProTests/Views/Main/SidebarObjectSelectionTests.swift b/TableProTests/Views/Main/SidebarObjectSelectionTests.swift index 31af9d82d4..1f18bdaf6c 100644 --- a/TableProTests/Views/Main/SidebarObjectSelectionTests.swift +++ b/TableProTests/Views/Main/SidebarObjectSelectionTests.swift @@ -26,6 +26,10 @@ struct SidebarObjectSelectionTests { TableInfo(name: name, type: .table, rowCount: nil, schema: schema) } + private func marked(_ table: TableInfo, database: String, schema: String? = nil) -> DatabaseTreeTableRef { + DatabaseTreeTableRef(database: database, schema: table.schema ?? schema, table: table) + } + @Test("The tab's table is marked when the tab is in the container being browsed") func marksTabInBrowsedContainer() { let selection = SidebarObjectSelection.resolve( @@ -34,7 +38,7 @@ struct SidebarObjectSelectionTests { browseScope: scope("banshi_online"), tables: tables ) - #expect(selection == .mark([TestFixtures.makeTableInfo(name: "orders")])) + #expect(selection == .mark([marked(TestFixtures.makeTableInfo(name: "orders"), database: "banshi_online")])) } @Test("A tab in another database marks nothing, so the same-named row stays clickable") @@ -57,7 +61,7 @@ struct SidebarObjectSelectionTests { browseScope: scope("app", schema: "reporting"), tables: [publicOrders, table("orders", schema: "reporting")] ) - #expect(selection == .mark([publicOrders])) + #expect(selection == .mark([marked(publicOrders, database: "app")])) } @Test("Two rows sharing a name in one database are told apart by the tab's schema") @@ -69,7 +73,7 @@ struct SidebarObjectSelectionTests { browseScope: scope("app", schema: "reporting"), tables: [table("orders", schema: "public"), reportingOrders] ) - #expect(selection == .mark([reportingOrders])) + #expect(selection == .mark([marked(reportingOrders, database: "app")])) } @Test("A tab naming no schema takes the first row with that name, as before") @@ -81,7 +85,7 @@ struct SidebarObjectSelectionTests { browseScope: scope("app"), tables: [first, table("orders", schema: "reporting")] ) - #expect(selection == .mark([first])) + #expect(selection == .mark([marked(first, database: "app")])) } @Test("A tab that names no table marks nothing") diff --git a/TableProTests/Views/Main/TableSelectionChangeTests.swift b/TableProTests/Views/Main/TableSelectionChangeTests.swift index 61fb90071c..b442c46a75 100644 --- a/TableProTests/Views/Main/TableSelectionChangeTests.swift +++ b/TableProTests/Views/Main/TableSelectionChangeTests.swift @@ -18,36 +18,36 @@ struct TableSelectionChangeTests { @Test("Single click adds one table — navigate to it") func singleClickNavigates() { - let old: Set = [] - let new: Set = [TestFixtures.makeTableInfo(name: "orders")] + let old: Set = [] + let new: Set = [TestFixtures.makeTableRef(name: "orders")] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) - #expect(action == .navigate(table: TableInfo(name: "orders", type: .table, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "orders", type: .table))) } @Test("Single click on a view — navigate with isView true") func singleClickOnView() { - let old: Set = [] - let view = TableInfo(name: "my_view", type: .view, rowCount: nil) - let new: Set = [view] + let old: Set = [] + let view = TestFixtures.makeTableRef(name: "my_view", type: .view) + let new: Set = [view] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) - #expect(action == .navigate(table: TableInfo(name: "my_view", type: .view, rowCount: nil))) + #expect(action == .navigate(ref: TestFixtures.makeTableRef(name: "my_view", type: .view))) } @Test("Cmd+click extends the selection without opening the table it added") func cmdClickAddsOneMore() { - let existing = TestFixtures.makeTableInfo(name: "users") - let added = TestFixtures.makeTableInfo(name: "orders") - let old: Set = [existing] - let new: Set = [existing, added] + let existing = TestFixtures.makeTableRef(name: "users") + let added = TestFixtures.makeTableRef(name: "orders") + let old: Set = [existing] + let new: Set = [existing, added] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) } @Test("Narrowing a multi-selection back to one table does not reopen it") func narrowingToAPreviouslySelectedTableDoesNotNavigate() { - let kept = TestFixtures.makeTableInfo(name: "users") - let old: Set = [kept, TestFixtures.makeTableInfo(name: "orders")] - let new: Set = [kept] + let kept = TestFixtures.makeTableRef(name: "users") + let old: Set = [kept, TestFixtures.makeTableRef(name: "orders")] + let new: Set = [kept] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) } @@ -56,11 +56,11 @@ struct TableSelectionChangeTests { @Test("Cmd+A adds many tables — no navigation") func cmdANoNavigation() { - let old: Set = [] - let new: Set = [ - TestFixtures.makeTableInfo(name: "users"), - TestFixtures.makeTableInfo(name: "orders"), - TestFixtures.makeTableInfo(name: "products") + let old: Set = [] + let new: Set = [ + TestFixtures.makeTableRef(name: "users"), + TestFixtures.makeTableRef(name: "orders"), + TestFixtures.makeTableRef(name: "products") ] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) @@ -68,12 +68,12 @@ struct TableSelectionChangeTests { @Test("Shift+click adds multiple tables — no navigation") func shiftClickNoNavigation() { - let existing = TestFixtures.makeTableInfo(name: "users") - let old: Set = [existing] - let new: Set = [ + let existing = TestFixtures.makeTableRef(name: "users") + let old: Set = [existing] + let new: Set = [ existing, - TestFixtures.makeTableInfo(name: "orders"), - TestFixtures.makeTableInfo(name: "products") + TestFixtures.makeTableRef(name: "orders"), + TestFixtures.makeTableRef(name: "products") ] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) @@ -83,19 +83,19 @@ struct TableSelectionChangeTests { @Test("Deselect tables (none added) — no navigation") func deselectNoNavigation() { - let old: Set = [ - TestFixtures.makeTableInfo(name: "users"), - TestFixtures.makeTableInfo(name: "orders") + let old: Set = [ + TestFixtures.makeTableRef(name: "users"), + TestFixtures.makeTableRef(name: "orders") ] - let new: Set = [TestFixtures.makeTableInfo(name: "users")] + let new: Set = [TestFixtures.makeTableRef(name: "users")] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) } @Test("Deselect all — no navigation") func deselectAllNoNavigation() { - let old: Set = [TestFixtures.makeTableInfo(name: "users")] - let new: Set = [] + let old: Set = [TestFixtures.makeTableRef(name: "users")] + let new: Set = [] let action = TableSelectionAction.resolve(oldTables: old, newTables: new, selectedRowCount: new.count) #expect(action == .noNavigation) } @@ -104,7 +104,7 @@ struct TableSelectionChangeTests { @Test("No change (same set) — no navigation") func noChangeNoNavigation() { - let tables: Set = [TestFixtures.makeTableInfo(name: "users")] + let tables: Set = [TestFixtures.makeTableRef(name: "users")] let action = TableSelectionAction.resolve(oldTables: tables, newTables: tables, selectedRowCount: tables.count) #expect(action == .noNavigation) } @@ -120,7 +120,7 @@ struct TableSelectionChangeTests { /// what tells the two apart. @Test("A table selected alongside a non-table row is an extension, not a pick") func aTableBesideAnotherRowDoesNotNavigate() { - let new: Set = [TestFixtures.makeTableInfo(name: "orders")] + let new: Set = [TestFixtures.makeTableRef(name: "orders")] let action = TableSelectionAction.resolve(oldTables: [], newTables: new, selectedRowCount: 2) #expect(action == .noNavigation) } diff --git a/TableProTests/Views/Main/TriggerStructTests.swift b/TableProTests/Views/Main/TriggerStructTests.swift index 6f57359dfb..30ac602290 100644 --- a/TableProTests/Views/Main/TriggerStructTests.swift +++ b/TableProTests/Views/Main/TriggerStructTests.swift @@ -88,8 +88,8 @@ struct InspectorTriggerTests { struct PendingChangeTriggerTests { private func makeTrigger( hasDataChanges: Bool = false, - pendingTruncates: Set = [], - pendingDeletes: Set = [], + pendingTruncates: Set = [], + pendingDeletes: Set = [], hasStructureChanges: Bool = false, isFileDirty: Bool = false, hasCreateTablePending: Bool = false @@ -106,8 +106,10 @@ struct PendingChangeTriggerTests { @Test("Same values are equal") func sameValuesAreEqual() { - let a = makeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"]) - let b = makeTrigger(hasDataChanges: true, pendingTruncates: ["t1"], pendingDeletes: ["t2"]) + let truncate = TestFixtures.makeTableRef(name: "t1") + let delete = TestFixtures.makeTableRef(name: "t2") + let a = makeTrigger(hasDataChanges: true, pendingTruncates: [truncate], pendingDeletes: [delete]) + let b = makeTrigger(hasDataChanges: true, pendingTruncates: [truncate], pendingDeletes: [delete]) #expect(a == b) } @@ -127,15 +129,15 @@ struct PendingChangeTriggerTests { @Test("Different pendingTruncates produces unequal triggers") func differentPendingTruncates() { - let a = makeTrigger(pendingTruncates: ["t1"]) - let b = makeTrigger(pendingTruncates: ["t2"]) + let a = makeTrigger(pendingTruncates: [TestFixtures.makeTableRef(name: "t1")]) + let b = makeTrigger(pendingTruncates: [TestFixtures.makeTableRef(name: "t2")]) #expect(a != b) } @Test("Different pendingDeletes produces unequal triggers") func differentPendingDeletes() { - let a = makeTrigger(pendingDeletes: ["d1"]) - let b = makeTrigger(pendingDeletes: ["d2"]) + let a = makeTrigger(pendingDeletes: [TestFixtures.makeTableRef(name: "d1")]) + let b = makeTrigger(pendingDeletes: [TestFixtures.makeTableRef(name: "d2")]) #expect(a != b) } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 949284db96..f86631b9b8 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -21,7 +21,7 @@ struct DatabaseTreeMenuSpecTests { private func context( clicked: DatabaseTreeNode.Kind?, - selectedTables: Set = [], + selectedTables: Set = [], selectedContainers: [DatabaseContainerRef] = [], isReadOnly: Bool = false, isFavorite: Bool = false, @@ -196,7 +196,7 @@ struct DatabaseTreeMenuSpecTests { func clickedTableOutsideSelectionActsOnItself() { let clicked = tableRef("orders") let items = DatabaseTreeMenuSpec.items( - for: context(clicked: .table(clicked), selectedTables: [tableRef("users").table]) + for: context(clicked: .table(clicked), selectedTables: [tableRef("users")]) ) #expect(commands(items).contains(.copyTableNames(["orders"]))) @@ -208,7 +208,7 @@ struct DatabaseTreeMenuSpecTests { let items = DatabaseTreeMenuSpec.items( for: context( clicked: .table(clicked), - selectedTables: [clicked.table, tableRef("users").table] + selectedTables: [clicked, tableRef("users")] ) ) @@ -221,8 +221,8 @@ struct DatabaseTreeMenuSpecTests { let items = DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked), isReadOnly: true)) let issued = commands(items) - #expect(!issued.contains(.truncateTables(names: ["orders"], ref: clicked))) - #expect(!issued.contains(.dropTables(names: ["orders"], ref: clicked))) + #expect(!issued.contains(.truncateTables(targets: [clicked], ref: clicked))) + #expect(!issued.contains(.dropTables(targets: [clicked], ref: clicked))) #expect(!issued.contains(.createView)) #expect(issued.contains(.copyTableNames(["orders"]))) } @@ -239,11 +239,30 @@ struct DatabaseTreeMenuSpecTests { ) let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .table(elsewhere)))) - #expect(issued.contains(.truncateTables(names: ["orders"], ref: elsewhere))) - #expect(issued.contains(.dropTables(names: ["orders"], ref: elsewhere))) + #expect(issued.contains(.truncateTables(targets: [elsewhere], ref: elsewhere))) + #expect(issued.contains(.dropTables(targets: [elsewhere], ref: elsewhere))) #expect(issued.contains(.exportTables(names: ["orders"], ref: elsewhere))) } + /// One save runs against one database, so a queue must not gather rows from two of them. A + /// tree selection can span databases, and a right-click inside it used to stage the lot under + /// bare names, which the save then resolved against whatever the tab in front pointed at. + @Test("A table menu narrows a cross-database selection to the clicked row's own database") + func crossDatabaseSelectionNarrowsToTheClickedDatabase() { + let clicked = tableRef("orders") + let elsewhere = DatabaseTreeTableRef( + database: "reporting", + schema: "public", + table: TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public") + ) + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(clicked), selectedTables: [clicked, elsewhere]) + )) + + #expect(issued.contains(.dropTables(targets: [clicked], ref: clicked))) + #expect(!issued.contains(.dropTables(targets: [clicked, elsewhere], ref: clicked))) + } + @Test("The favourite item names the action it will take") func favouriteItemFlipsItsTitle() { let clicked = tableRef("orders") diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift index 339650891d..7bfa4e8cc6 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift @@ -35,13 +35,13 @@ struct DatabaseTreeSelectionProjectionTests { @Test("An empty selection publishes nothing") func emptySelection() { - #expect(DatabaseTreeSelection.tableInfos(of: []).isEmpty) + #expect(DatabaseTreeSelection.tableRefs(of: []).isEmpty) } @Test("Every selected table is published") func tablesArePublished() { let nodes = [node(.table(ref("users"))), node(.table(ref("orders")))] - #expect(DatabaseTreeSelection.tableInfos(of: nodes) == [table("users"), table("orders")]) + #expect(DatabaseTreeSelection.tableRefs(of: nodes) == [ref("users"), ref("orders")]) } /// The commands act on tables. A routine caught in a mixed selection must never reach a @@ -55,7 +55,7 @@ struct DatabaseTreeSelectionProjectionTests { node(.status(.loading)), node(.recentSection), ] - #expect(DatabaseTreeSelection.tableInfos(of: nodes) == [table("users")]) + #expect(DatabaseTreeSelection.tableRefs(of: nodes) == [ref("users")]) } /// A Recent row and the table's own row are two rows for one table, so selecting both must not @@ -63,7 +63,7 @@ struct DatabaseTreeSelectionProjectionTests { @Test("A table reachable from two rows collapses to one entry") func duplicateRowsCollapse() { let nodes = [node(.table(ref("orders"))), node(.recentTable(ref("orders")))] - #expect(DatabaseTreeSelection.tableInfos(of: nodes).count == 1) + #expect(Set(DatabaseTreeSelection.tableRefs(of: nodes)).count == 1) } @Test("Tables of the same name in different schemas stay distinct") @@ -72,6 +72,6 @@ struct DatabaseTreeSelectionProjectionTests { node(.table(ref("users", schema: "public"))), node(.table(ref("users", schema: "audit"))), ] - #expect(DatabaseTreeSelection.tableInfos(of: nodes).count == 2) + #expect(Set(DatabaseTreeSelection.tableRefs(of: nodes)).count == 2) } } diff --git a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift index 8dc4b7a5a4..1198cc7e93 100644 --- a/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift +++ b/TableProTests/Views/Sidebar/SidebarOutlineScaffoldTests.swift @@ -191,7 +191,7 @@ struct DatabaseTreeObjectGroupHierarchyTests { ) coordinator.activeDatabase = "shop" coordinator.nodeCache = [database.id: database, schema.id: schema, group.id: group] - windowState.selectTables([parentRef.table]) + windowState.selectTables([parentRef]) coordinator.syncSelectionToModel() coordinator.nodeCache[parent.id] = parent @@ -241,19 +241,19 @@ struct DatabaseTreeObjectGroupHierarchyTests { coordinator.attach(outlineView: outlineView) outlineView.reloadData() - windowState.selectTables([tableRef.table]) + windowState.selectTables([tableRef]) coordinator.syncSelectionToModel() #expect(outlineView.item(atRow: outlineView.selectedRow) as? DatabaseTreeNode === table) - windowState.selectTables([viewRef.table]) + windowState.selectTables([viewRef]) coordinator.syncSelectionToModel() #expect(outlineView.selectedRow == -1) - windowState.selectTables([tableRef.table]) + windowState.selectTables([tableRef]) coordinator.syncSelectionToModel() #expect(outlineView.item(atRow: outlineView.selectedRow) as? DatabaseTreeNode === table) - windowState.selectTables([viewRef.table]) + windowState.selectTables([viewRef]) coordinator.syncSelectionToModel() coordinator.nodeCache[view.id] = view outlineView.expandItem(group) @@ -295,7 +295,7 @@ struct DatabaseTreeObjectGroupHierarchyTests { coordinator.attach(outlineView: outlineView) outlineView.reloadData() - windowState.selectTables([table]) + windowState.selectTables([oldRef]) coordinator.syncSelectionToModel() #expect(outlineView.item(atRow: outlineView.selectedRow) as? DatabaseTreeNode === oldView) @@ -319,10 +319,8 @@ struct DatabaseTreeObjectGroupHierarchyTests { let windowState = WindowSidebarState(connectionId: connectionId, defaults: defaults) let orders = TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public") let report = TableInfo(name: "report", type: .table, rowCount: nil, schema: "public") - let shopOrders = DatabaseTreeNode( - id: "shop-orders", - kind: .table(DatabaseTreeTableRef(database: "shop", schema: "public", table: orders)) - ) + let shopOrdersRef = DatabaseTreeTableRef(database: "shop", schema: "public", table: orders) + let shopOrders = DatabaseTreeNode(id: "shop-orders", kind: .table(shopOrdersRef)) let shopReport = DatabaseTreeNode( id: "shop-report", kind: .table(DatabaseTreeTableRef(database: "shop", schema: "public", table: report)) @@ -331,10 +329,8 @@ struct DatabaseTreeObjectGroupHierarchyTests { id: "archive-orders", kind: .table(DatabaseTreeTableRef(database: "archive", schema: "public", table: orders)) ) - let archiveReport = DatabaseTreeNode( - id: "archive-report", - kind: .table(DatabaseTreeTableRef(database: "archive", schema: "public", table: report)) - ) + let archiveReportRef = DatabaseTreeTableRef(database: "archive", schema: "public", table: report) + let archiveReport = DatabaseTreeNode(id: "archive-report", kind: .table(archiveReportRef)) let scrollView = SidebarOutlineScaffold.makeScrollView( outlineView: NSOutlineView(), configuration: SidebarOutlineScaffold.Configuration( @@ -360,7 +356,7 @@ struct DatabaseTreeObjectGroupHierarchyTests { coordinator.attach(outlineView: outlineView) outlineView.reloadData() - windowState.selectTables([orders, report]) + windowState.selectTables([shopOrdersRef, archiveReportRef]) coordinator.syncSelectionToModel() let selected = outlineView.selectedRowIndexes.compactMap { @@ -383,10 +379,8 @@ struct DatabaseTreeObjectGroupHierarchyTests { let schema = DatabaseTreeNode(id: "schema", kind: .schema(database: "shop", schema: "public")) let groupRef = DatabaseTreeObjectGroup(database: "shop", schema: "public", kind: .table) let group = DatabaseTreeNode(id: "group", kind: .containerObjectKindSection(groupRef)) - let table = DatabaseTreeNode( - id: "table", - kind: .table(DatabaseTreeTableRef(database: "shop", schema: "public", table: orders)) - ) + let ordersRef = DatabaseTreeTableRef(database: "shop", schema: "public", table: orders) + let table = DatabaseTreeNode(id: "table", kind: .table(ordersRef)) let scrollView = SidebarOutlineScaffold.makeScrollView( outlineView: NSOutlineView(), configuration: SidebarOutlineScaffold.Configuration( @@ -409,13 +403,13 @@ struct DatabaseTreeObjectGroupHierarchyTests { outlineView.expandItem(schema) outlineView.expandItem(group) - windowState.selectTables([orders]) + windowState.selectTables([ordersRef]) coordinator.syncSelectionToModel() #expect(outlineView.item(atRow: outlineView.selectedRow) as? DatabaseTreeNode === table) outlineView.collapseItem(group) - #expect(windowState.selectedTables == [orders]) + #expect(windowState.selectedTables == [ordersRef]) outlineView.expandItem(group) @@ -425,7 +419,7 @@ struct DatabaseTreeObjectGroupHierarchyTests { /// is pinned too rather than left to work by accident. outlineView.collapseItem(schema) - #expect(windowState.selectedTables == [orders]) + #expect(windowState.selectedTables == [ordersRef]) outlineView.expandItem(schema) @@ -442,14 +436,10 @@ struct DatabaseTreeObjectGroupHierarchyTests { let windowState = WindowSidebarState(connectionId: connectionId, defaults: defaults) let orders = TableInfo(name: "orders", type: .table, rowCount: nil, schema: "public") let report = TableInfo(name: "report", type: .table, rowCount: nil, schema: "public") - let shopOrders = DatabaseTreeNode( - id: "shop-orders", - kind: .table(DatabaseTreeTableRef(database: "shop", schema: "public", table: orders)) - ) - let archiveReport = DatabaseTreeNode( - id: "archive-report", - kind: .table(DatabaseTreeTableRef(database: "archive", schema: "public", table: report)) - ) + let shopOrdersRef = DatabaseTreeTableRef(database: "shop", schema: "public", table: orders) + let shopOrders = DatabaseTreeNode(id: "shop-orders", kind: .table(shopOrdersRef)) + let archiveReportRef = DatabaseTreeTableRef(database: "archive", schema: "public", table: report) + let archiveReport = DatabaseTreeNode(id: "archive-report", kind: .table(archiveReportRef)) let scrollView = SidebarOutlineScaffold.makeScrollView( outlineView: NSOutlineView(), configuration: SidebarOutlineScaffold.Configuration( @@ -470,7 +460,7 @@ struct DatabaseTreeObjectGroupHierarchyTests { coordinator.attach(outlineView: outlineView) outlineView.reloadData() - windowState.selectTables([orders]) + windowState.selectTables([shopOrdersRef]) coordinator.syncSelectionToModel() #expect(outlineView.item(atRow: outlineView.selectedRow) as? DatabaseTreeNode === shopOrders)