From a5860eca6f4bde3fc244045d37bb05325b372ec7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 15:16:58 +0700 Subject: [PATCH 1/6] 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) From 96abf793edbc7db6a89cecdb9883d3fe859556d1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 15:46:07 +0700 Subject: [PATCH 2/6] feat(sidebar): rename a table, a database and a schema from the object tree --- CHANGELOG.md | 2 + .../BigQueryDriverPlugin/BigQueryPlugin.swift | 2 + .../BigQueryPluginDriver+Rename.swift | 18 +++ .../ClickHousePlugin.swift | 2 + .../ClickHousePluginDriver+Schema.swift | 22 ++++ .../CloudflareD1Plugin.swift | 2 + .../CloudflareD1PluginDriver+Rename.swift | 20 +++ Plugins/DamengDriverPlugin/DamengPlugin.swift | 2 + .../DamengPluginDriver+Rename.swift | 17 +++ Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 2 + .../DuckDBPluginDriver+Rename.swift | 17 +++ Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift | 2 + .../LibSQLPluginDriver+Rename.swift | 20 +++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 2 + .../MSSQLPluginDriver+Rename.swift | 23 ++++ .../MongoDBDriverPlugin/MongoDBPlugin.swift | 2 + .../MongoDBPluginDriver.swift | 23 ++++ Plugins/MySQLDriverPlugin/MySQLPlugin.swift | 1 + .../MySQLDriverPlugin/MySQLPluginDriver.swift | 9 ++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 2 + .../OraclePluginDriver+Rename.swift | 19 +++ .../PostgreSQLPlugin.swift | 3 + .../PostgreSQLPluginDriver.swift | 21 ++++ Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 14 +++ .../SnowflakePlugin.swift | 6 + .../SnowflakePluginDriver+Rename.swift | 31 +++++ Plugins/TableProPluginKit/DriverPlugin.swift | 6 + .../PluginDatabaseDriver.swift | 21 ++++ .../PluginDriverUnsupportedOperation.swift | 33 +++++ .../TeradataDriverPlugin/TeradataPlugin.swift | 2 + .../TeradataPluginDriver+Rename.swift | 21 ++++ Plugins/TrinoDriverPlugin/TrinoPlugin.swift | 4 + .../TrinoPluginDriver+Rename.swift | 27 ++++ TablePro/Core/Database/DatabaseDriver.swift | 18 +++ .../Database/TableOperationSQLBuilder.swift | 15 +-- .../Core/Plugins/PluginDriverAdapter.swift | 12 ++ .../Plugins/PluginManager+Registration.swift | 15 +++ .../Core/Plugins/PluginMetadataRegistry.swift | 27 ++++ .../Core/Storage/ColumnLayoutPersister.swift | 15 +++ .../Storage/FavoriteDatabasesStorage.swift | 9 ++ .../Core/Storage/FilterSettingsStorage.swift | 30 +++++ TablePro/Core/Storage/RecentTablesStore.swift | 26 ++++ .../Database/ObjectRenameEligibility.swift | 50 ++++++++ TablePro/Models/UI/SharedSidebarState.swift | 23 ++++ .../MainContentCoordinator+Rename.swift | 115 +++++++++++++++++ ...ainContentCoordinator+RenameAdoption.swift | 111 ++++++++++++++++ .../Views/Sidebar/DatabaseTreeCellView.swift | 27 +++- ...abaseTreeOutlineCoordinator+Commands.swift | 6 + .../DatabaseTreeOutlineCoordinator+Menu.swift | 8 ++ ...atabaseTreeOutlineCoordinator+Rename.swift | 95 ++++++++++++++ .../DatabaseTreeOutlineCoordinator.swift | 42 ++++++- .../Sidebar/DatabaseTreeRenameSession.swift | 41 ++++++ .../Sidebar/FavoritesOutlineCellView.swift | 86 +------------ .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 24 +++- .../Sidebar/Menu/SidebarMenuCommand.swift | 5 + .../Sidebar/RenamableSidebarCellView.swift | 100 +++++++++++++++ .../ObjectRenameEligibilityTests.swift | 118 ++++++++++++++++++ .../Sidebar/DatabaseTreeMenuSpecTests.swift | 51 +++++++- .../Sidebar/DatabaseTreeRenameTests.swift | 45 +++++++ docs/features/table-operations.mdx | 24 ++++ 60 files changed, 1433 insertions(+), 103 deletions(-) create mode 100644 Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift create mode 100644 Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift create mode 100644 Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift create mode 100644 Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift create mode 100644 Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift create mode 100644 Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift create mode 100644 Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift create mode 100644 Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift create mode 100644 Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift create mode 100644 Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift create mode 100644 Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift create mode 100644 TablePro/Models/Database/ObjectRenameEligibility.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift create mode 100644 TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift create mode 100644 TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift create mode 100644 TablePro/Views/Sidebar/RenamableSidebarCellView.swift create mode 100644 TableProTests/Models/Database/ObjectRenameEligibilityTests.swift create mode 100644 TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index cd93525ee1..ca2094fc30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Data Rewind settings in Settings > Data & Results, with an off switch and Clear Saved Changes. - Restore Previous Values in the toolbar's Table Actions group. - Rebindable Find shortcut in Settings > Keyboard, for giving `Cmd+F` to the filter bar instead. +- Rename on a table's right-click menu, editing the row's label in place. (#2482) +- Rename Database and Rename Schema on the sidebar's container rows, where the engine has them. (#2482) ### Changed diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 45ec3ccb95..629860ca58 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -16,6 +16,8 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "BigQuery" + + static let supportsRenameTable = true static let databaseDisplayName = "Google BigQuery" static let iconName = "bigquery-icon" static let defaultPort = 0 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift new file mode 100644 index 0000000000..4aba88e324 --- /dev/null +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift @@ -0,0 +1,18 @@ +// +// BigQueryPluginDriver+Rename.swift +// BigQueryDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension BigQueryPluginDriver { + /// The new name is bare and the table stays in its dataset. BigQuery refuses the statement + /// while a streaming buffer is active, which is roughly five hours after the last row streamed + /// in, and for an external table; both come back as the server's own message. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 9faa8ee4cd..23d0e280b5 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -56,6 +56,8 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] static let supportsQueryProgress = true static let supportsDropDatabase = true + static let supportsRenameTable = true + static let supportsRenameDatabase = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "`", diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift index 8987c0395d..c2d27943f0 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift @@ -322,6 +322,28 @@ extension ClickHousePluginDriver { _ = try await execute(query: "DROP DATABASE `\(escapedName)`") } + /// Both sides are qualified with the same database, so this renames in place. Qualifying them + /// differently is how ClickHouse moves a table, which is a different verb to the user. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let database = schema ?? lock.withLock { _currentDatabase } + let old = qualified(database: database, name: name) + let new = qualified(database: database, name: newName) + _ = try await execute(query: "RENAME TABLE \(old) TO \(new)") + } + + /// Needs the Atomic database engine, the default since 20.10. An Ordinary database refuses, + /// and the server's own message says so. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "RENAME DATABASE \(quoteIdentifier(name)) TO \(quoteIdentifier(newName))" + ) + } + + private func qualified(database: String?, name: String) -> String { + guard let database, !database.isEmpty else { return quoteIdentifier(name) } + return "\(quoteIdentifier(database)).\(quoteIdentifier(name))" + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 348e886db4..336b3c6940 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -14,6 +14,8 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Cloudflare D1" + + static let supportsRenameTable = true static let databaseDisplayName = "Cloudflare D1" static let iconName = "cloudflare-d1-icon" static let defaultPort = 0 diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift new file mode 100644 index 0000000000..a5fd32d4ba --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift @@ -0,0 +1,20 @@ +// +// CloudflareD1PluginDriver+Rename.swift +// CloudflareD1DriverPlugin +// + +import Foundation +import TableProPluginKit + +extension CloudflareD1PluginDriver { + /// SQLite's rules, and SQLite's one rename: `ALTER TABLE` refuses a view. A D1 database is an + /// API object whose edit endpoint accepts only read replication, so its name cannot change. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/DamengDriverPlugin/DamengPlugin.swift b/Plugins/DamengDriverPlugin/DamengPlugin.swift index 6ca76bb333..a3de3bb830 100644 --- a/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -8,6 +8,8 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Dameng" + + static let supportsRenameTable = true static let databaseDisplayName = "Dameng DM8" static let iconName = "cylinder" static let defaultPort = 5_236 diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift new file mode 100644 index 0000000000..24a4bde495 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift @@ -0,0 +1,17 @@ +// +// DamengPluginDriver+Rename.swift +// DamengDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension DamengPluginDriver { + /// Oracle-compatible, so the new name stays bare. Dameng also ships `sp_rename`, which is not + /// used here: the ALTER form is the one its own documentation leads with. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 5eafabcfc2..35bcaa2644 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -15,6 +15,8 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "DuckDB" + + static let supportsRenameTable = true static let databaseDisplayName = "DuckDB" static let iconName = "duckdb-icon" static let defaultPort = 9_494 diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift new file mode 100644 index 0000000000..346d0a8f10 --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift @@ -0,0 +1,17 @@ +// +// DuckDBPluginDriver+Rename.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension DuckDBPluginDriver { + /// The new name is bare and the object stays in its schema. DuckDB has no `ALTER SCHEMA + /// RENAME` and no database rename at all, so those stay unimplemented. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index c560d48f84..177b20f8d6 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -14,6 +14,8 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "libSQL" + + static let supportsRenameTable = true static let additionalDatabaseTypeIds = ["Turso"] static let databaseDisplayName = "libSQL / Turso" static let iconName = "libsql-icon" diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift new file mode 100644 index 0000000000..4a8b1a44fd --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift @@ -0,0 +1,20 @@ +// +// LibSQLPluginDriver+Rename.swift +// LibSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension LibSQLPluginDriver { + /// SQLite's rules, and SQLite's one rename: `ALTER TABLE` refuses a view. A Turso database + /// name has no libSQL wire operation, which is why `dropDatabase` already refuses too. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 41795375e1..9f5dad59eb 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -74,6 +74,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "SQL Server" + + static let supportsRenameTable = true static let databaseDisplayName = "SQL Server" static let iconName = "mssql-icon" static let defaultPort = 1433 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift new file mode 100644 index 0000000000..3bd60f2b19 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift @@ -0,0 +1,23 @@ +// +// MSSQLPluginDriver+Rename.swift +// MSSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MSSQLPluginDriver { + /// `sp_rename` takes names as string literals rather than identifiers, and the new one must be + /// a single part: passing `schema.new` renames the object to something literally called + /// "schema.new". Its object type argument is what tells the procedure this is not a column. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let qualified = [schema, name].compactMap { $0 }.joined(separator: ".") + _ = try await execute( + query: "EXEC sp_rename \(literal(qualified)), \(literal(newName)), 'OBJECT'" + ) + } + + private func literal(_ value: String) -> String { + "N'\(value.replacingOccurrences(of: "'", with: "''"))'" + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 3b49a6355f..b3efa9bd85 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -13,6 +13,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MongoDB" + + static let supportsRenameTable = true static let databaseDisplayName = "MongoDB" static let iconName = "mongodb-icon" static let defaultPort = 27017 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 54a8847acc..419b503353 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -635,6 +635,29 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + /// `renameCollection` runs against `admin` and nowhere else, and it names both sides with the + /// full `database.collection`, so the two halves cannot be quoted or qualified the way a SQL + /// driver's would be. Atlas grants only the same-database form, which is all this offers. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + let database = schema ?? currentDb + let from = Self.jsonString("\(database).\(name)") + let to = Self.jsonString("\(database).\(newName)") + _ = try await conn.runCommand( + "{\"renameCollection\": \(from), \"to\": \(to)}", + database: "admin" + ) + } + + private static func jsonString(_ value: String) -> String { + let escaped = value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + func dropDatabase(name: String) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 901c3f7e05..5d4b72ecb7 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -102,6 +102,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let supportsDropDatabase = true + static let supportsRenameTable = true static let supportsTriggers = true static let supportsRoutines = true static let supportsDatabaseTriggerBrowse = true diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 45f4dd4321..e0a25142b2 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -642,6 +642,15 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "DROP DATABASE `\(escapedName)`") } + /// `RENAME TABLE` rather than `ALTER TABLE ... RENAME TO`, because it is the only form that + /// takes a view, and both sides are qualified with the same schema so the statement cannot + /// move the object anywhere. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let old = MySQLObjectQueries.qualifiedIdentifier(schema: schema, name: name) + let new = MySQLObjectQueries.qualifiedIdentifier(schema: schema, name: newName) + _ = try await execute(query: "RENAME TABLE \(old) TO \(new)") + } + // MARK: - Database Switching func switchDatabase(to database: String) async throws { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 8e555a042c..0f698b0236 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -15,6 +15,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Oracle" + + static let supportsRenameTable = true static let databaseDisplayName = "Oracle" static let iconName = "oracle-icon" static let defaultPort = 1_521 diff --git a/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift b/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift new file mode 100644 index 0000000000..96027ec207 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift @@ -0,0 +1,19 @@ +// +// OraclePluginDriver+Rename.swift +// OracleDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension OraclePluginDriver { + /// The new name must be bare. A qualified one raises ORA-14047, because Oracle renames in + /// place and has no statement that moves an object between schemas. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = OracleObjectQueries.quoteIdentifier(name) + let target = schema.map { "\(OracleObjectQueries.quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute( + query: "ALTER \(objectType) \(target) RENAME TO \(OracleObjectQueries.quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 38bae8f0cd..810e4e736d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -83,6 +83,9 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresReconnectForDatabaseSwitch = true static let parameterStyle: ParameterStyle = .dollar static let supportsDropDatabase = true + static let supportsRenameTable = true + static let supportsRenameDatabase = true + static let supportsRenameSchema = true static let supportsDropSchema = true static let supportsTriggers = true static let supportsRoutines = true diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 0f18aac13d..be5bc827de 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -953,6 +953,27 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { _ = try await execute(query: "DROP SCHEMA \(quoteIdentifier(name)) CASCADE") } + /// The new name must be bare. PostgreSQL rejects a qualified one, because this statement + /// renames in place and never moves the object; `SET SCHEMA` is the separate verb for that. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let target = qualifiedTable(name, schema: schema) + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } + + /// Not the database the connection is on: PostgreSQL answers that with "the current database + /// cannot be renamed", so the app keeps the item off a row it is browsing. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + private struct Template1Defaults { let collate: String let ctype: String diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index ce737f3ce9..7261bfdea6 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -37,6 +37,7 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let fileExtensions: [String] = ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"] static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false + static let supportsRenameTable = true static let supportsTriggers = true static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true @@ -1123,6 +1124,19 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - ALTER TABLE DDL + /// `ALTER TABLE` is the only rename SQLite has and it refuses a view, so a view is turned + /// away here rather than by a message from the engine. From 3.25 the statement rewrites the + /// references to the table in every trigger and view, and from 3.26 in every foreign key, + /// unless `PRAGMA legacy_alter_table` is on. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { let colDef = sqliteColumnDefinition(column, inlinePK: false) return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(colDef)" diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 3c7bf5fad0..7423dc8931 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -18,6 +18,12 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Snowflake" + + static let supportsRenameTable = true + + static let supportsRenameDatabase = true + + static let supportsRenameSchema = true static let databaseDisplayName = "Snowflake" static let iconName = "snowflake-icon" static let defaultPort = 443 diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift new file mode 100644 index 0000000000..0c0a4fc243 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift @@ -0,0 +1,31 @@ +// +// SnowflakePluginDriver+Rename.swift +// SnowflakeDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension SnowflakePluginDriver { + /// Snowflake accepts a qualified new name and treats it as a move, so both sides are qualified + /// the same way and the statement can only rename in place. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let target = qualifiedName(table: name, schema: schema) + let renamed = qualifiedName(table: newName, schema: schema) + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(renamed)") + } + + /// Not the database the session is on: the rename succeeds but leaves the session pointing at + /// a name that no longer exists, so the app keeps the item off the row it is browsing. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 2944dc8e19..ee8b4e367f 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -63,6 +63,9 @@ public protocol DriverPlugin: TableProPlugin { static var parameterStyle: ParameterStyle { get } static var supportsDropDatabase: Bool { get } static var supportsDropSchema: Bool { get } + static var supportsRenameTable: Bool { get } + static var supportsRenameDatabase: Bool { get } + static var supportsRenameSchema: Bool { get } static var supportsAddColumn: Bool { get } static var supportsModifyColumn: Bool { get } @@ -146,6 +149,9 @@ public extension DriverPlugin { static var postConnectActions: [PostConnectAction] { [] } static var supportsDropDatabase: Bool { false } static var supportsDropSchema: Bool { false } + static var supportsRenameTable: Bool { false } + static var supportsRenameDatabase: Bool { false } + static var supportsRenameSchema: Bool { false } static var supportsAddColumn: Bool { true } static var supportsModifyColumn: Bool { true } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 234c0ada60..adbe6b24b8 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -143,6 +143,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func createDatabase(_ request: PluginCreateDatabaseRequest) async throws func dropDatabase(name: String) async throws func dropSchema(name: String) async throws + + /// Renaming runs rather than generating a statement, because for several engines it is not a + /// statement: MongoDB renames a collection through an admin command, SQL Server calls + /// `sp_rename`. The driver also owns the quoting, which differs even between two SQLite + /// builds here, and the rules for the new name: PostgreSQL and Oracle reject a qualified one, + /// Snowflake accepts one and treats it as a move. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws + func renameDatabase(name: String, to newName: String) async throws + func renameSchema(name: String, to newName: String) async throws func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult // Session contexts (optional, switchable session dimensions such as a warehouse or role) @@ -419,6 +428,18 @@ public extension PluginDatabaseDriver { ) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + throw PluginDriverUnsupportedOperation.renameTable + } + + func renameDatabase(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameDatabase + } + + func renameSchema(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameSchema + } + func dropDatabase(name: String) async throws { throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) diff --git a/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift b/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift new file mode 100644 index 0000000000..78d326bece --- /dev/null +++ b/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift @@ -0,0 +1,33 @@ +// +// PluginDriverUnsupportedOperation.swift +// TableProPluginKit +// + +import Foundation + +/// A rename the engine has no operation for. +/// +/// The three are separate because they are separate facts about an engine, and one message for +/// all of them would be wrong for most of it. MySQL renames a table and has had no way to rename +/// a database since 5.1.23; Oracle renames a table while its "databases" here are users, which it +/// cannot rename at all; Cassandra can rename neither, because a CQL `ALTER TABLE ... RENAME` +/// renames primary key columns. +/// +/// A driver reaches these only through a default implementation. Where the engine can do the +/// work, its `DriverPlugin` says so and the menu never offers what would throw. +public enum PluginDriverUnsupportedOperation: Error, LocalizedError, Sendable { + case renameTable + case renameDatabase + case renameSchema + + public var errorDescription: String? { + switch self { + case .renameTable: + return String(localized: "This database cannot rename a table") + case .renameDatabase: + return String(localized: "This database cannot be renamed") + case .renameSchema: + return String(localized: "This database cannot rename a schema") + } + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index b82a8e8597..a18a876a13 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -35,6 +35,8 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Teradata" + + static let supportsRenameTable = true static let databaseDisplayName = "Teradata" static let iconName = "teradata-icon" static let defaultPort = 1_025 diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift new file mode 100644 index 0000000000..608d4a9a53 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift @@ -0,0 +1,21 @@ +// +// TeradataPluginDriver+Rename.swift +// TeradataDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + /// Teradata cannot move a table between databases, so the new name is bare and the object + /// keeps its own. Views, macros and procedures each need their own `RENAME` keyword, which is + /// what the object type carries. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = TeradataSchemaQueries.quoteIdentifier(name) + let target = schema.map { "\(TeradataSchemaQueries.quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute( + query: "RENAME \(objectType) \(target) TO \(TeradataSchemaQueries.quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index 02fba93c79..d332caa89f 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -8,6 +8,10 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Trino" + + static let supportsRenameTable = true + + static let supportsRenameSchema = true static let databaseDisplayName = "Trino" static let iconName = "trino-icon" static let defaultPort = 8_080 diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift new file mode 100644 index 0000000000..9675498239 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift @@ -0,0 +1,27 @@ +// +// TrinoPluginDriver+Rename.swift +// TrinoDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension TrinoPluginDriver { + /// Whether this works at all is the connector's decision, and many answer "this connector does + /// not support renaming tables". That message is the honest one to show, so nothing here tries + /// to predict it. The new name is bare: Trino renames within a schema and never across a + /// catalog. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } + + /// A Trino "database" in the tree is a catalog, which is configuration rather than an object, + /// so only the schema level is renameable. + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 82aaa87594..1fb855d603 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -199,6 +199,12 @@ protocol DatabaseDriver: AnyObject, Sendable { func dropSchema(name: String) async throws + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws + + func renameDatabase(name: String, to newName: String) async throws + + func renameSchema(name: String, to newName: String) async throws + func fetchSessionContexts() async throws -> [PluginSessionContext]? func switchSessionContext(id: String, to value: String) async throws @@ -362,6 +368,18 @@ extension DatabaseDriver { userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"]) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + throw PluginDriverUnsupportedOperation.renameTable + } + + func renameDatabase(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameDatabase + } + + func renameSchema(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameSchema + } + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil } func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil } diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index 7240c3f330..9c484cbe89 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -81,22 +81,9 @@ struct TableOperationSQLBuilder { guard let adapter = adapterProvider() else { return "" } return adapter.dropObjectStatement( name: ref.table.name, - objectType: Self.dropKeyword(for: ref.table.type), + objectType: TableObjectKeyword.forDDL(ref.table.type), schema: ref.qualifyingSchema, cascade: options.cascade ) } - - private static func dropKeyword(for type: TableInfo.TableType) -> String { - switch type { - case .view: - return "VIEW" - case .materializedView: - return "MATERIALIZED VIEW" - case .foreignTable: - return "FOREIGN TABLE" - case .table, .systemTable, .partitionedTable, .externalTable: - return "TABLE" - } - } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 50d23baf77..a143fb990f 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -457,6 +457,18 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.dropSchema(name: name) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + try await pluginDriver.renameTable(name: name, schema: schema, to: newName, objectType: objectType) + } + + func renameDatabase(name: String, to newName: String) async throws { + try await pluginDriver.renameDatabase(name: name, to: newName) + } + + func renameSchema(name: String, to newName: String) async throws { + try await pluginDriver.renameSchema(name: name, to: newName) + } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { try await pluginDriver.fetchSessionContexts() } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 6b3d9ce94c..4038dd656c 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -533,6 +533,21 @@ extension PluginManager { .capabilities.supportsDropSchema ?? false } + func supportsRenameTable(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameTable ?? false + } + + func supportsRenameDatabase(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameDatabase ?? false + } + + func supportsRenameSchema(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameSchema ?? false + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let snapshot = PluginMetadataRegistry.shared.snapshot(for: databaseType) else { return .limit diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 4634d7f08c..26e9da85c4 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -47,6 +47,9 @@ struct PluginMetadataSnapshot: Sendable { let supportsQueryProgress: Bool let requiresReconnectForDatabaseSwitch: Bool let supportsDropDatabase: Bool + var supportsRenameTable: Bool = false + var supportsRenameDatabase: Bool = false + var supportsRenameSchema: Bool = false // `var` with defaults so existing call sites compile without passing these fields var supportsDropSchema: Bool = false var supportsAddColumn: Bool = true @@ -541,6 +544,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, @@ -598,6 +604,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, @@ -656,6 +665,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, supportsDropSchema: true, supportsRenameColumn: true, supportsTriggers: true, @@ -712,6 +724,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, supportsDropSchema: true, defaultSSLMode: .preferred ), @@ -774,6 +789,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, supportsDropSchema: true, supportsAddColumn: false, supportsModifyColumn: false, @@ -831,6 +849,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameDatabase: false, + supportsRenameSchema: true, supportsDropSchema: true, supportsRenameColumn: true, supportsTriggers: true, @@ -887,6 +908,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, supportsDropDatabase: false, + supportsRenameTable: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, supportsModifyColumn: false, supportsRenameColumn: true, supportsModifyPrimaryKey: false, @@ -1150,6 +1174,9 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: driverType.supportsQueryProgress, requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, supportsDropDatabase: driverType.supportsDropDatabase, + supportsRenameTable: driverType.supportsRenameTable, + supportsRenameDatabase: driverType.supportsRenameDatabase, + supportsRenameSchema: driverType.supportsRenameSchema, supportsDropSchema: driverType.supportsDropSchema, supportsAddColumn: driverType.supportsAddColumn, supportsModifyColumn: driverType.supportsModifyColumn, diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 58c3bd3647..5d70d65fbd 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -126,6 +126,21 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { syncTracker.markDirty(.settings, id: Self.syncCategory(for: key.storageKey)) } + /// Moves a table's saved widths, order and hidden columns onto its new name. + /// + /// Persisted before either sync marker is written, because `markDeleted` posts a change + /// notification that can start a sync, and a sync reading the old file would put the entry + /// back under the name that has gone. + func rename(from oldKey: ColumnLayoutTableKey, to newKey: ColumnLayoutTableKey) { + var entries = loadEntries(for: oldKey.connectionId) + guard let entry = entries.removeValue(forKey: oldKey.storageKey) else { return } + entries[newKey.storageKey] = entry + cache[oldKey.connectionId] = entries + writeEntries(entries, for: oldKey.connectionId) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: newKey.storageKey)) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey.storageKey)) + } + func clear(for key: ColumnLayoutTableKey) { removeLegacyHidden(for: key) diff --git a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift index f033c1062e..7cad9436c7 100644 --- a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift +++ b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift @@ -67,6 +67,15 @@ internal final class FavoriteDatabasesStorage { notify(after: mutate { Self.upsert(entry, into: &$0) }, skipSync: true) } + /// A favourite follows its database's new name rather than being dropped, because the tag the + /// user put on it is about the database, not about what it is called. It is synced, so the + /// entry is written before the removal is announced. + internal func rename(database oldName: String, to newName: String, connectionId: UUID) { + guard let existing = favorites(for: connectionId).first(where: { $0.database == oldName }) else { return } + setFavorite(database: newName, environment: existing.environment, connectionId: connectionId) + removeFavorite(database: oldName, connectionId: connectionId) + } + internal func removeFavorite(database: String, connectionId: UUID) { notify(after: mutate { favorites in guard let existing = favorites.first(where: { diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 882c46e4eb..f6d1251b30 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -255,6 +255,36 @@ final class FilterSettingsStorage { } } + /// Moves a table's saved filters onto its new name. A rename keeps the columns the filters + /// name, so the working set is still valid; leaving it behind would silently drop it. + func renameLastFilters( + from oldTableName: String, + to newTableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) { + let oldKey = compositeKey( + tableName: oldTableName, connectionId: connectionId, + databaseName: databaseName, schemaName: schemaName + ) + let newKey = compositeKey( + tableName: newTableName, connectionId: connectionId, + databaseName: databaseName, schemaName: schemaName + ) + guard oldKey != newKey else { return } + if let cached = lastFiltersCache.removeValue(forKey: oldKey) { + lastFiltersCache[newKey] = cached + } + let source = fileURL(forKey: oldKey) + let destination = fileURL(forKey: newKey) + ioQueue.async { + guard FileManager.default.fileExists(atPath: source.path) else { return } + try? FileManager.default.removeItem(at: destination) + try? FileManager.default.moveItem(at: source, to: destination) + } + } + func waitForPendingDiskWrites() { ioQueue.sync {} } diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index 2f41352fba..f0e442c065 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -85,6 +85,32 @@ final class RecentTablesStore { return updated } + func rename(connectionId: UUID, entry: RecentTableEntry, to newName: String) -> [RecentTableEntry] { + var entries = self.entries(connectionId: connectionId) + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return entries } + let existing = entries[index] + entries[index] = RecentTableEntry( + database: existing.database, schema: existing.schema, name: newName, + isView: existing.isView, openedAt: existing.openedAt + ) + persist(entries, connectionId: connectionId) + return entries + } + + func renameDatabase(connectionId: UUID, from oldName: String, to newName: String) -> [RecentTableEntry] { + var entries = self.entries(connectionId: connectionId) + guard entries.contains(where: { $0.database == oldName }) else { return entries } + entries = entries.map { entry in + guard entry.database == oldName else { return entry } + return RecentTableEntry( + database: newName, schema: entry.schema, name: entry.name, + isView: entry.isView, openedAt: entry.openedAt + ) + } + persist(entries, connectionId: connectionId) + return entries + } + func removeEntries(for connectionId: UUID) { defaults.removeObject(forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) defaults.removeObject(forKey: legacyKeyPrefix + connectionId.uuidString) diff --git a/TablePro/Models/Database/ObjectRenameEligibility.swift b/TablePro/Models/Database/ObjectRenameEligibility.swift new file mode 100644 index 0000000000..4fcd11c333 --- /dev/null +++ b/TablePro/Models/Database/ObjectRenameEligibility.swift @@ -0,0 +1,50 @@ +// +// ObjectRenameEligibility.swift +// TablePro +// + +import Foundation + +/// Which rows offer Rename. +/// +/// A container is never renamed while the connection is on it, the same rule Drop already +/// applies. Several engines refuse outright, PostgreSQL among them with "the current database +/// cannot be renamed", and the ones that allow it leave the session pointing at a name that no +/// longer exists. Switching away first is the gesture Drop already asks for. +enum ObjectRenameEligibility { + struct Context { + let activeDatabase: String? + let activeSchema: String? + let supportsRenameTable: Bool + let supportsRenameDatabase: Bool + let supportsRenameSchema: Bool + let isReadOnly: Bool + } + + static func canRename(table: TableInfo, context: Context) -> Bool { + guard !context.isReadOnly, context.supportsRenameTable else { return false } + return table.type != .systemTable + } + + static func renameable(_ targets: [DatabaseContainerRef], context: Context) -> DatabaseContainerRef? { + guard !context.isReadOnly else { return nil } + /// One at a time. A rename names one new name, so a multi-row selection has nothing to + /// apply, and offering the item over one would silently act on a row the user did not + /// mean. + guard targets.count == 1, let target = targets.first else { return nil } + return isRenameable(target, context: context) ? target : nil + } + + private static func isRenameable(_ target: DatabaseContainerRef, context: Context) -> Bool { + guard !target.isSystem else { return false } + switch target.kind { + case .database: + guard context.supportsRenameDatabase else { return false } + return target.database != context.activeDatabase + case .schema: + guard context.supportsRenameSchema else { return false } + guard target.database == context.activeDatabase else { return true } + return target.schema != context.activeSchema + } + } +} diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index f5099313a1..f0980bf98c 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -68,6 +68,29 @@ final class SharedSidebarState { recentTables = RecentTablesStore.shared.remove(connectionId: connectionId, entry: entry) } + /// A renamed table keeps its place in Recent. Dropping it instead would look like the entry + /// aged out, and re-adding it under the new name would move it to the top of a list the user + /// did not open anything from. + func renameRecentTable(database: String?, schema: String?, from oldName: String, to newName: String) { + let scope = normalizedDatabase(database) + guard let index = recentTables.firstIndex(where: { + $0.database == scope && $0.schema == schema && $0.name == oldName + }) else { return } + recentTables = RecentTablesStore.shared.rename( + connectionId: connectionId, + entry: recentTables[index], + to: newName + ) + } + + /// Every Recent entry in a renamed database follows it, because the entries are keyed by the + /// database's name and would otherwise all point at one that has gone. + func renameRecentDatabase(from oldName: String, to newName: String) { + recentTables = RecentTablesStore.shared.renameDatabase( + connectionId: connectionId, from: oldName, to: newName + ) + } + func clearRecentTables(inDatabase database: String?) { recentTables = RecentTablesStore.shared.clear( connectionId: connectionId, database: normalizedDatabase(database) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift new file mode 100644 index 0000000000..cdc0fe60f1 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift @@ -0,0 +1,115 @@ +// +// MainContentCoordinator+Rename.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +private let renameLogger = Logger(subsystem: "com.TablePro", category: "Rename") + +/// Renaming an object, and moving everything that named it by the old name. +/// +/// It runs at once rather than joining the Truncate and Drop queue. The row's own label is what +/// the user edits, so a queued rename would leave the tree showing a name the server does not +/// have, and every later command on that row would name an object that does not exist. Dropping a +/// database already works this way. +extension MainContentCoordinator { + func renameTable(_ ref: DatabaseTreeTableRef, to newName: String) { + let objectType = TableObjectKeyword.forDDL(ref.table.type) + Task { [weak self] in + guard let self else { return } + do { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + try await driver.renameTable( + name: ref.table.name, + schema: ref.qualifyingSchema, + to: newName, + objectType: objectType + ) + } catch { + renameLogger.error( + "Rename failed for \(ref.id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: error.localizedDescription, + window: contentWindow + ) + return + } + adoptTableRename(ref, to: newName) + await refreshTables() + } + } + + func renameContainer(_ ref: DatabaseContainerRef, to newName: String) { + Task { [weak self] in + guard let self else { return } + do { + try await performContainerRename(ref, to: newName) + } catch { + renameLogger.error( + "Rename failed for \(ref.id, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: error.localizedDescription, + window: contentWindow + ) + return + } + adoptContainerRename(ref, to: newName) + await DatabaseTreeMetadataService.shared.refreshDatabases( + connectionId: connectionId, + databaseType: connection.type + ) + if ref.kind == .schema, let database = ref.database { + await DatabaseTreeMetadataService.shared.refreshSchemas( + connectionId: connectionId, + database: database + ) + } + } + } + + private func performContainerRename(_ ref: DatabaseContainerRef, to newName: String) async throws { + switch ref.kind { + case .database: + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + throw DatabaseError.notConnected + } + try await driver.renameDatabase(name: ref.name, to: newName) + case .schema: + guard let scope = DatabaseManager.shared.resolvedScope( + database: ref.database, schema: nil, for: connectionId + ) else { + throw DatabaseError.notConnected + } + let name = ref.name + try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.renameSchema(name: name, to: newName) + } + } + } +} + +/// The `DROP` and `ALTER` keyword for an object kind, in one place because the rename and the drop +/// have to spell the same object the same way. +enum TableObjectKeyword { + static func forDDL(_ type: TableInfo.TableType) -> String { + switch type { + case .view: + return "VIEW" + case .materializedView: + return "MATERIALIZED VIEW" + case .foreignTable: + return "FOREIGN TABLE" + case .table, .systemTable, .partitionedTable, .externalTable: + return "TABLE" + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift new file mode 100644 index 0000000000..249f667efe --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift @@ -0,0 +1,111 @@ +// +// MainContentCoordinator+RenameAdoption.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Everything that named the object by its old name, moved onto the new one. +/// +/// A rename is the one destructive-looking operation whose object survives it, so the state the +/// user built around it survives too: the tab stays open on the same rows, its filters and column +/// widths stay applied, and a favourite stays a favourite rather than pointing at a table that no +/// longer exists on every device it synced to. +extension MainContentCoordinator { + func adoptTableRename(_ ref: DatabaseTreeTableRef, to newName: String) { + let database = ref.database ?? browseDatabaseName + let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(ref.qualifyingSchema, for: connectionId) + let identity = TableTabIdentity(ref: ref, browsing: browseDatabaseName, resolvedSchema: resolvedSchema) + + retitleTabs(matching: identity, to: newName) + movePerTableSettings( + from: ref.table.name, to: newName, database: database, schema: resolvedSchema + ) + moveFavorite(ref, to: newName, database: ref.database) + moveRecent(ref, to: newName) + unstagePendingOperations(for: ref) + } + + func adoptContainerRename(_ ref: DatabaseContainerRef, to newName: String) { + guard ref.kind == .database, let oldDatabase = ref.database else { return } + SharedSidebarState.forConnection(connectionId) + .renameRecentDatabase(from: oldDatabase, to: newName) + FavoriteDatabasesStorage.shared.rename( + database: oldDatabase, to: newName, connectionId: connectionId + ) + } + + private func retitleTabs(matching identity: TableTabIdentity, to newName: String) { + let browseDatabase = browseDatabaseName + for index in tabManager.tabs.indices + where tabManager.tabs[index].tableIdentity(browsing: browseDatabase) == identity { + tabManager.mutate(at: index) { tab in + tab.tableContext.tableName = newName + tab.title = newName + } + tabManager.markTabRenamed(tabManager.tabs[index].id) + /// The browse query still names the old table, so the next page, sort or filter would + /// run against a name the server no longer has. + rebuildTableQuery(at: index) + } + /// The change manager serves the whole window and holds the name its statements target, + /// so a save started after the rename would still write to the old one. + if changeManager.tableName == identity.table { + changeManager.tableName = newName + } + } + + private func movePerTableSettings( + from oldName: String, + to newName: String, + database: String, + schema: String? + ) { + FilterSettingsStorage.shared.renameLastFilters( + from: oldName, + to: newName, + connectionId: connectionId, + databaseName: database, + schemaName: schema + ) + FileColumnLayoutPersister.shared.rename( + from: ColumnLayoutTableKey( + connectionId: connectionId, databaseName: database, schemaName: schema, tableName: oldName + ), + to: ColumnLayoutTableKey( + connectionId: connectionId, databaseName: database, schemaName: schema, tableName: newName + ) + ) + } + + private func moveFavorite(_ ref: DatabaseTreeTableRef, to newName: String, database: String?) { + let storage = FavoriteTablesStorage.shared + guard storage.isFavorite( + name: ref.table.name, schema: ref.schema, database: database, connectionId: connectionId + ) else { return } + storage.removeFavorite( + name: ref.table.name, schema: ref.schema, database: database, connectionId: connectionId + ) + storage.addFavorite( + name: newName, schema: ref.schema, database: database, connectionId: connectionId + ) + } + + private func moveRecent(_ ref: DatabaseTreeTableRef, to newName: String) { + SharedSidebarState.forConnection(connectionId).renameRecentTable( + database: ref.database, schema: ref.schema, from: ref.table.name, to: newName + ) + } + + /// A queued Truncate or Drop against the old name would either miss or, once a new table takes + /// that name, reach the wrong object. The queue is dropped rather than moved, because the + /// confirmation the user gave named the object they were looking at. + private func unstagePendingOperations(for ref: DatabaseTreeTableRef) { + guard let viewModel = sidebarViewModel else { return } + guard viewModel.pendingTruncates.contains(ref) || viewModel.pendingDeletes.contains(ref) else { return } + viewModel.pendingTruncates.remove(ref) + viewModel.pendingDeletes.remove(ref) + viewModel.tableOperationOptions.removeValue(forKey: ref) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift index fa8dd1cd57..07fe25a879 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift @@ -6,15 +6,22 @@ import AppKit import SwiftUI -/// One row of the object tree. All the hosting geometry is `SidebarHostingCellView`'s; this only -/// knows how to turn a node into the row it draws. -final class DatabaseTreeCellView: SidebarHostingCellView { +/// One row of the object tree. All the hosting geometry is `SidebarHostingCellView`'s and the +/// rename field is `RenamableSidebarCellView`'s; this only knows how to turn a node into the row +/// it draws, and which glyph that row wears while it is being renamed. +final class DatabaseTreeCellView: RenamableSidebarCellView { + private var renameSymbolName = "tablecells" + + override var editorSymbolName: String { renameSymbolName } + override var editorAccessibilityIdentifier: String { "database-tree-rename-field" } + func configure( node: DatabaseTreeNode, isFavorite: Bool, context: DatabaseTreeRowContext, actions: DatabaseTreeRowActions ) { + renameSymbolName = Self.symbolName(for: node) update(rootView: DatabaseTreeRowView( node: node, isFavorite: isFavorite, @@ -22,4 +29,18 @@ final class DatabaseTreeCellView: SidebarHostingCellView { actions: actions )) } + + private static func symbolName(for node: DatabaseTreeNode) -> String { + switch node.kind { + case .table(let ref), .recentTable(let ref): + return TableRowLogic.iconName(for: ref.table.type) + case .database(let metadata): + return metadata.isSystemDatabase ? "gearshape" : "cylinder" + case .schema: + return "folder" + case .routine, .trigger, .status, .recentSection, .objectKindSection, + .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: + return "tablecells" + } + } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 26c5cdfb2d..11ff5071ee 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -68,6 +68,12 @@ extension DatabaseTreeOutlineCoordinator { activateThen(ref) { [weak self] in self?.viewModel?.batchToggleDelete(refs: targets) } + case .beginRenameTable(let ref): + activateThen(ref) { [weak self] in + self?.beginRename(.table(ref)) + } + case .renameContainer(let ref): + beginRename(.container(ref)) case .toggleFavorite(let ref): toggleFavorite(ref) case .removeRecent(let ref): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 6aca24a662..620a007aaf 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -53,6 +53,14 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { supportsDropSchema: PluginManager.shared.supportsDropSchema(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false ), + renameEligibility: ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: PluginManager.shared.supportsRenameTable(for: databaseType), + supportsRenameDatabase: PluginManager.shared.supportsRenameDatabase(for: databaseType), + supportsRenameSchema: PluginManager.shared.supportsRenameSchema(for: databaseType), + isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false + ), containerEntityName: PluginManager.shared.containerEntityName(for: databaseType), containerEntityNamePlural: PluginManager.shared.containerEntityNamePlural(for: databaseType), schemaEntityName: PluginManager.shared.schemaEntityName(for: databaseType), diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift new file mode 100644 index 0000000000..a49dd809fc --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift @@ -0,0 +1,95 @@ +// +// DatabaseTreeOutlineCoordinator+Rename.swift +// TablePro +// + +import AppKit + +/// Renaming a row of the object tree in the cell's own field. +/// +/// The `NSTextFieldDelegate` conformance itself lives on the main declaration, because the +/// callbacks are `@objc` and reach the coordinator rather than this extension. +extension DatabaseTreeOutlineCoordinator { + internal func beginRename(_ target: DatabaseTreeRenameSession.Target) { + guard let outlineView else { return } + endRename(commit: false) + + let nodeId: String + let name: String + switch target { + case .table(let ref): + nodeId = DatabaseTreeNode.tableId(ref) + name = ref.table.name + case .container(let ref): + nodeId = ref.kind == .schema + ? DatabaseTreeNode.schemaId(database: ref.database ?? "", schema: ref.schema ?? "") + : DatabaseTreeNode.databaseId(ref.database ?? "") + name = ref.name + } + + let row = outlineView.row(forItem: nodeCache[nodeId]) + guard row >= 0 else { return } + outlineView.scrollRowToVisible(row) + outlineView.layoutSubtreeIfNeeded() + guard let cell = outlineView.view(atColumn: 0, row: row, makeIfNecessary: true) + as? DatabaseTreeCellView else { return } + + renameSession = DatabaseTreeRenameSession( + target: target, nodeId: nodeId, originalName: name, pendingName: name + ) + cell.beginRename(text: name, delegate: self) + focus(cell) + } + + /// Re-installs a live edit after a reload, which drops every cell view. The typed value is + /// carried across so a refresh from another window does not swallow what the user has entered. + internal func restoreRenameAfterReload() { + guard let session = renameSession else { return } + guard let cell = renameCell(forNodeId: session.nodeId) else { + endRename(commit: false) + return + } + guard !cell.isRenaming else { return } + cell.beginRename(text: session.pendingName ?? session.originalName, delegate: self) + focus(cell) + } + + internal func endRename(commit: Bool) { + guard let session = renameSession else { return } + renameSession = nil + + /// The field is the authority while it exists. `pendingName` is the fallback for the case + /// where the row has already gone, which is the only way there is no field left to ask. + var typed = session.pendingName ?? session.originalName + if let cell = renameCell(forNodeId: session.nodeId) { + typed = cell.endRename() + outlineView?.window?.makeFirstResponder(outlineView) + } + + guard commit, + case .commit(let newName) = RenameNameDecision.decide( + typed: typed, original: session.originalName + ) + else { return } + + switch session.target { + case .table(let ref): + mainCoordinator?.renameTable(ref, to: newName) + case .container(let ref): + mainCoordinator?.renameContainer(ref, to: newName) + } + } + + private func focus(_ cell: DatabaseTreeCellView) { + guard let field = cell.editor else { return } + outlineView?.window?.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + + private func renameCell(forNodeId nodeId: String) -> DatabaseTreeCellView? { + guard let outlineView, let node = nodeCache[nodeId] else { return nil } + let row = outlineView.row(forItem: node) + guard row >= 0 else { return nil } + return outlineView.view(atColumn: 0, row: row, makeIfNecessary: false) as? DatabaseTreeCellView + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 8279b7318a..06851506ce 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -10,7 +10,7 @@ import SwiftUI import TableProPluginKit @MainActor -final class DatabaseTreeOutlineCoordinator: NSObject { +final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { internal weak var outlineView: NSOutlineView? internal let service = DatabaseTreeMetadataService.shared private static let cellIdentifier = NSUserInterfaceItemIdentifier("DatabaseTreeCell") @@ -38,6 +38,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { /// Whether a routine row shows its signature depends on the other rows in its own section, so /// the label is decided where the section is built and looked up here when the row draws. internal var routineDisplayLabels: [String: String] = [:] + + /// A rename in progress, held as identity only. See `DatabaseTreeOutlineCoordinator+Rename`. + internal var renameSession: DatabaseTreeRenameSession? private var cachedRowContext: DatabaseTreeRowContext? private var cachedRowActions: DatabaseTreeRowActions? private var lastSelection: Set = [] @@ -239,6 +242,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { outlineView.reloadData() applyDesiredExpansion() syncSelectionToModel() + restoreRenameAfterReload() isReloading = false beginObserving() } @@ -641,6 +645,42 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDataSource { } } +extension DatabaseTreeOutlineCoordinator { + // MARK: - NSTextFieldDelegate + + /// The rename editor's callbacks. They live here rather than in the rename extension because + /// they are `@objc` and an extension cannot supply them for the conformance. + internal func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + renameSession?.pendingName = field.stringValue + } + + /// The click-away path, which commits the way Finder and the Xcode navigator do. + internal func controlTextDidEndEditing(_ obj: Notification) { + guard renameSession != nil else { return } + endRename(commit: true) + } + + internal func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy selector: Selector + ) -> Bool { + if selector == #selector(NSResponder.insertNewline(_:)) { + endRename(commit: true) + return true + } + if selector == #selector(NSResponder.cancelOperation(_:)) { + /// `abortEditing` discards the edit without posting `controlTextDidEndEditing`, so the + /// cancel does not immediately arrive back as a commit. + (control as? NSTextField)?.abortEditing() + endRename(commit: false) + return true + } + return false + } +} + extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let node = item as? DatabaseTreeNode else { return nil } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift b/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift new file mode 100644 index 0000000000..6534d7c259 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift @@ -0,0 +1,41 @@ +// +// DatabaseTreeRenameSession.swift +// TablePro +// + +import Foundation + +/// What the object tree is renaming. +/// +/// Identity only, no cell and no field: `reloadData()` drops every row and cell view, so a stored +/// reference is a reference to a view that is no longer the row being edited. The cell is +/// re-resolved from the node id on every pass instead. +internal struct DatabaseTreeRenameSession: Equatable { + internal enum Target: Equatable { + case table(DatabaseTreeTableRef) + case container(DatabaseContainerRef) + } + + internal let target: Target + internal let nodeId: String + internal let originalName: String + internal var pendingName: String? +} + +/// Whether a typed name is worth sending to the server. +/// +/// The three answers are separate because two of them are not failures. An unchanged name is the +/// user finishing where they started, and an empty field is a rename they abandoned; neither is +/// worth an alert, and neither should reach a driver that would answer with a syntax error. +internal enum RenameNameDecision: Equatable { + case commit(String) + case unchanged + case discard + + internal static func decide(typed: String, original: String) -> RenameNameDecision { + let trimmed = typed.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .discard } + guard trimmed != original else { return .unchanged } + return .commit(trimmed) + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift index 84d221afc6..511e7acb02 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift @@ -6,86 +6,8 @@ import AppKit import SwiftUI -/// One row of the Favorites list, plus the field that renames it. -/// -/// The rename field is a real subview assigned to `NSTableCellView.textField`, so `NSOutlineView` -/// lays it out through every expand, collapse, scroll and row-height change, and AppKit treats a -/// cell with an edit in progress as in use rather than recycling it. The overlay this replaced was -/// a bare field added to the outline view, positioned by hand from one call site, which left it -/// painted over a neighbouring row after any disclosure change. -internal final class FavoritesOutlineCellView: SidebarHostingCellView { - private var editorField: NSTextField? - private var editorIcon: NSImageView? - - internal private(set) var isRenaming = false - - internal func beginRename(text: String, delegate: any NSTextFieldDelegate) { - let field = makeEditorIfNeeded() - field.stringValue = text - field.delegate = delegate - isRenaming = true - applyContentVisibility() - } - - @discardableResult - internal func endRename() -> String { - guard let field = editorField else { return "" } - let value = field.stringValue - field.delegate = nil - isRenaming = false - applyContentVisibility() - return value - } - - internal var editor: NSTextField? { editorField } - - /// A reload during an edit calls `update(rootView:)` on every visible cell, so the row's own - /// label must not come back over the field the user is typing in. - override internal func applyContentVisibility() { - setHostedContentHidden(isRenaming) - editorField?.isHidden = !isRenaming - editorIcon?.isHidden = !isRenaming - } - - private func makeEditorIfNeeded() -> NSTextField { - if let editorField { return editorField } - - let icon = NSImageView() - icon.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil) - icon.translatesAutoresizingMaskIntoConstraints = false - icon.isHidden = true - addSubview(icon) - - let field = NSTextField() - field.translatesAutoresizingMaskIntoConstraints = false - field.isBezeled = false - field.drawsBackground = true - field.isEditable = true - field.isSelectable = true - field.focusRingType = .default - field.usesSingleLineMode = true - field.lineBreakMode = .byTruncatingTail - field.font = .systemFont(ofSize: NSFont.systemFontSize) - (field.cell as? NSTextFieldCell)?.isScrollable = true - field.isHidden = true - field.setAccessibilityIdentifier("favorites-rename-field") - addSubview(field) - - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor), - icon.centerYAnchor.constraint(equalTo: centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 16), - field.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), - field.trailingAnchor.constraint(equalTo: trailingAnchor), - field.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - - /// The inherited outlets are what make AppKit colour the field for a selected row and hand - /// it to VoiceOver, so they are set rather than kept as private references. - imageView = icon - textField = field - editorIcon = icon - editorField = field - return field - } +/// One row of the Favorites list. Only folders are renamed here, so the editor keeps one glyph. +internal final class FavoritesOutlineCellView: RenamableSidebarCellView { + override internal var editorSymbolName: String { "folder" } + override internal var editorAccessibilityIdentifier: String { "favorites-rename-field" } } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index f4ee9aa566..dcb8add862 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -25,6 +25,7 @@ internal struct DatabaseTreeMenuContext { internal let importFormats: [ImportFormatOption] internal let maintenanceOperations: [String] internal let dropEligibility: ContainerDropEligibility.Context + internal let renameEligibility: ObjectRenameEligibility.Context internal let containerEntityName: String internal let containerEntityNamePlural: String internal let schemaEntityName: String @@ -144,6 +145,9 @@ internal enum DatabaseTreeMenuSpec { guard !context.isReadOnly else { return items } items.append(.separator) + if ObjectRenameEligibility.canRename(table: ref.table, context: context.renameEligibility) { + items.append(.command(String(localized: "Rename"), .beginRenameTable(ref))) + } items.append(.command(String(localized: "Create New View…"), .createView)) if SidebarContextMenuLogic.truncateVisible(clickedTable: ref.table) { items.append(.command(String(localized: "Truncate"), .truncateTables(targets: targets, ref: ref))) @@ -262,9 +266,15 @@ internal enum DatabaseTreeMenuSpec { items.append(.separator) items.append(.command(String(localized: "Export…"), .exportContainers(targets))) } - guard !droppable.isEmpty else { return items } + let renameable = ObjectRenameEligibility.renameable(targets, context: context.renameEligibility) + guard renameable != nil || !droppable.isEmpty else { return items } items.append(.separator) - items.append(.command(dropTitle(for: droppable, context: context), .dropContainers(droppable))) + if let renameable { + items.append(.command(renameTitle(for: renameable, context: context), .renameContainer(renameable))) + } + if !droppable.isEmpty { + items.append(.command(dropTitle(for: droppable, context: context), .dropContainers(droppable))) + } return items } @@ -322,6 +332,16 @@ internal enum DatabaseTreeMenuSpec { ).menuTitle } + /// The engine's own word for the container, so the item reads "Rename Keyspace" on Cassandra + /// and "Rename Dataset" on BigQuery. No ellipsis: it opens the row's own field, not a sheet. + private static func renameTitle( + for target: DatabaseContainerRef, + context: DatabaseTreeMenuContext + ) -> String { + let entity = target.kind == .schema ? context.schemaEntityName : context.containerEntityName + return String(format: String(localized: "Rename %@"), entity) + } + private static func copyNamesTitle(count: Int) -> String { count == 1 ? String(localized: "Copy Name") diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index fb67145076..42df57a0a1 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -33,6 +33,11 @@ internal enum SidebarMenuCommand: Equatable { /// 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) + /// Renaming runs at once rather than joining the queue, because the row's label is what the + /// user edits: a queued rename would leave the tree showing a name the server does not have, + /// and every later command on that row would name an object that does not exist. + case beginRenameTable(DatabaseTreeTableRef) + case renameContainer(DatabaseContainerRef) case toggleFavorite(DatabaseTreeTableRef) case removeRecent(DatabaseTreeTableRef) case clearRecents diff --git a/TablePro/Views/Sidebar/RenamableSidebarCellView.swift b/TablePro/Views/Sidebar/RenamableSidebarCellView.swift new file mode 100644 index 0000000000..f38d7f93ee --- /dev/null +++ b/TablePro/Views/Sidebar/RenamableSidebarCellView.swift @@ -0,0 +1,100 @@ +// +// RenamableSidebarCellView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// A source list cell whose label can be edited in place. +/// +/// The rename field is a real subview assigned to `NSTableCellView.textField`, so `NSOutlineView` +/// lays it out through every expand, collapse, scroll and row-height change, and AppKit treats a +/// cell with an edit in progress as in use rather than recycling it. The overlay this replaced was +/// a bare field added to the outline view, positioned by hand from one call site, which left it +/// painted over a neighbouring row after any disclosure change. +/// +/// The icon and the accessibility identifier are the subclass's, because the two lists that use +/// this have neither in common: Favorites edits folders and nothing else, while the object tree +/// edits tables, views and schemas, each with its own glyph. +internal class RenamableSidebarCellView: SidebarHostingCellView { + private var editorField: NSTextField? + private var editorIcon: NSImageView? + + internal private(set) var isRenaming = false + + /// Overridden by a cell that draws more than one kind of row. + internal var editorSymbolName: String { "folder" } + internal var editorAccessibilityIdentifier: String { "sidebar-rename-field" } + + internal func beginRename(text: String, delegate: any NSTextFieldDelegate) { + let field = makeEditorIfNeeded() + editorIcon?.image = NSImage(systemSymbolName: editorSymbolName, accessibilityDescription: nil) + field.stringValue = text + field.delegate = delegate + isRenaming = true + applyContentVisibility() + } + + @discardableResult + internal func endRename() -> String { + guard let field = editorField else { return "" } + let value = field.stringValue + field.delegate = nil + isRenaming = false + applyContentVisibility() + return value + } + + internal var editor: NSTextField? { editorField } + + /// A reload during an edit calls `update(rootView:)` on every visible cell, so the row's own + /// label must not come back over the field the user is typing in. + override internal func applyContentVisibility() { + setHostedContentHidden(isRenaming) + editorField?.isHidden = !isRenaming + editorIcon?.isHidden = !isRenaming + } + + private func makeEditorIfNeeded() -> NSTextField { + if let editorField { return editorField } + + let icon = NSImageView() + icon.image = NSImage(systemSymbolName: editorSymbolName, accessibilityDescription: nil) + icon.translatesAutoresizingMaskIntoConstraints = false + icon.isHidden = true + addSubview(icon) + + let field = NSTextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.isBezeled = false + field.drawsBackground = true + field.isEditable = true + field.isSelectable = true + field.focusRingType = .default + field.usesSingleLineMode = true + field.lineBreakMode = .byTruncatingTail + field.font = .systemFont(ofSize: NSFont.systemFontSize) + (field.cell as? NSTextFieldCell)?.isScrollable = true + field.isHidden = true + field.setAccessibilityIdentifier(editorAccessibilityIdentifier) + addSubview(field) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor), + icon.centerYAnchor.constraint(equalTo: centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 16), + field.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), + field.trailingAnchor.constraint(equalTo: trailingAnchor), + field.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + /// The inherited outlets are what make AppKit colour the field for a selected row and hand + /// it to VoiceOver, so they are set rather than kept as private references. + imageView = icon + textField = field + editorIcon = icon + editorField = field + return field + } +} diff --git a/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift new file mode 100644 index 0000000000..0e0a9181ff --- /dev/null +++ b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift @@ -0,0 +1,118 @@ +// +// ObjectRenameEligibilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Object rename eligibility") +struct ObjectRenameEligibilityTests { + private func context( + activeDatabase: String? = "app", + activeSchema: String? = "public", + table: Bool = true, + database: Bool = true, + schema: Bool = true, + isReadOnly: Bool = false + ) -> ObjectRenameEligibility.Context { + ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: table, + supportsRenameDatabase: database, + supportsRenameSchema: schema, + isReadOnly: isReadOnly + ) + } + + private func table(_ name: String, type: TableInfo.TableType = .table) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: "public") + } + + // MARK: - Tables + + @Test("A table on an engine that can rename one offers it") + func tableOffersRename() { + #expect(ObjectRenameEligibility.canRename(table: table("orders"), context: context())) + } + + @Test("An engine with no table rename never offers it") + func engineWithoutRenameNeverOffers() { + #expect(!ObjectRenameEligibility.canRename(table: table("orders"), context: context(table: false))) + } + + @Test("Read-only safe mode hides rename with the rest of the writes") + func readOnlyHidesRename() { + #expect(!ObjectRenameEligibility.canRename(table: table("orders"), context: context(isReadOnly: true))) + } + + /// A system table belongs to the engine, and renaming one breaks the catalogue it is part of. + @Test("A system table is never renameable") + func systemTableIsNotRenameable() { + #expect(!ObjectRenameEligibility.canRename(table: table("pg_stats", type: .systemTable), context: context())) + } + + @Test("A view is renameable where a table is") + func viewIsRenameable() { + #expect(ObjectRenameEligibility.canRename(table: table("active_users", type: .view), context: context())) + } + + // MARK: - Containers + + @Test("A database the connection is not on is renameable") + func inactiveDatabaseIsRenameable() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == target) + } + + /// Several engines refuse outright, PostgreSQL among them, and the ones that allow it leave + /// the session pointing at a name that has gone. Drop already asks the user to switch away. + @Test("The database the connection is on is never renameable") + func activeDatabaseIsNotRenameable() { + let target = DatabaseContainerRef.database("app", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + @Test("A system database is never renameable") + func systemDatabaseIsNotRenameable() { + let target = DatabaseContainerRef.database("mysql", isSystem: true) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + @Test("An engine with no database rename never offers it") + func engineWithoutDatabaseRenameNeverOffers() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context(database: false)) == nil) + } + + @Test("A schema outside the active database is renameable") + func schemaInAnotherDatabaseIsRenameable() { + let target = DatabaseContainerRef.schema(database: "archive", schema: "public", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == target) + } + + @Test("The schema the connection is on is never renameable") + func activeSchemaIsNotRenameable() { + let target = DatabaseContainerRef.schema(database: "app", schema: "public", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + /// A rename names one new name, so a multi-row selection has nothing to apply. Offering the + /// item over one would silently act on a row the user did not mean. + @Test("A selection of several containers offers no rename") + func multipleContainersOfferNoRename() { + let targets = [ + DatabaseContainerRef.database("archive", isSystem: false), + DatabaseContainerRef.database("staging", isSystem: false), + ] + #expect(ObjectRenameEligibility.renameable(targets, context: context()) == nil) + } + + @Test("Read-only safe mode hides container rename too") + func readOnlyHidesContainerRename() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context(isReadOnly: true)) == nil) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index f86631b9b8..61b4edd4f2 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -30,7 +30,8 @@ struct DatabaseTreeMenuSpecTests { activeSchema: String? = "public", canReachOtherDatabases: Bool = true, canFilterDatabases: Bool = false, - hasDatabaseFilter: Bool = false + hasDatabaseFilter: Bool = false, + supportsRename: Bool = true ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( clicked: clicked, @@ -51,6 +52,14 @@ struct DatabaseTreeMenuSpecTests { supportsDropSchema: true, isReadOnly: isReadOnly ), + renameEligibility: ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: supportsRename, + supportsRenameDatabase: supportsRename, + supportsRenameSchema: supportsRename, + isReadOnly: isReadOnly + ), containerEntityName: "Database", containerEntityNamePlural: "Databases", schemaEntityName: "Schema", @@ -263,6 +272,46 @@ struct DatabaseTreeMenuSpecTests { #expect(!issued.contains(.dropTables(targets: [clicked, elsewhere], ref: clicked))) } + @Test("A table row offers Rename where the engine can do it") + func tableOffersRename() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked)))) + + #expect(issued.contains(.beginRenameTable(clicked))) + } + + /// No ellipsis, because it opens the row's own field rather than a sheet. Finder spells its + /// own inline rename the same way. + @Test("Rename carries no ellipsis") + func renameHasNoEllipsis() { + let clicked = tableRef("orders") + let items = DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked))) + + #expect(titles(items).contains(String(localized: "Rename"))) + } + + /// Omitted rather than dimmed, which is what this menu already does for a Drop the engine + /// cannot perform. + @Test("An engine that cannot rename a table omits the item") + func engineWithoutRenameOmitsTheItem() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(clicked), supportsRename: false) + )) + + #expect(!issued.contains(.beginRenameTable(clicked))) + } + + @Test("Read-only safe mode hides Rename with the other writes") + func readOnlyOmitsRename() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(clicked), isReadOnly: true) + )) + + #expect(!issued.contains(.beginRenameTable(clicked))) + } + @Test("The favourite item names the action it will take") func favouriteItemFlipsItsTitle() { let clicked = tableRef("orders") diff --git a/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift new file mode 100644 index 0000000000..235ecb30d6 --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift @@ -0,0 +1,45 @@ +// +// DatabaseTreeRenameTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// The three answers `RenameNameDecision` gives are separate because two of them are not failures. +@Suite("Object tree rename") +struct DatabaseTreeRenameTests { + @Test("A new name commits") + func newNameCommits() { + #expect(RenameNameDecision.decide(typed: "invoices", original: "orders") == .commit("invoices")) + } + + /// Finishing where you started is not a rename, and sending it would spend a round trip to + /// have the server rename an object to what it is already called. + @Test("The same name is not a rename") + func unchangedNameDoesNothing() { + #expect(RenameNameDecision.decide(typed: "orders", original: "orders") == .unchanged) + } + + @Test("Whitespace around a name is not part of it") + func surroundingWhitespaceIsTrimmed() { + #expect(RenameNameDecision.decide(typed: " invoices ", original: "orders") == .commit("invoices")) + #expect(RenameNameDecision.decide(typed: " orders ", original: "orders") == .unchanged) + } + + /// Clearing the field is how a rename is abandoned, so it is discarded rather than sent for + /// the server to answer with a syntax error. + @Test("An empty name is discarded") + func emptyNameIsDiscarded() { + #expect(RenameNameDecision.decide(typed: "", original: "orders") == .discard) + #expect(RenameNameDecision.decide(typed: " ", original: "orders") == .discard) + } + + /// A name that differs only in case is a real rename on every engine that folds case, because + /// the stored spelling is what the user sees. + @Test("A name differing only in case is a rename") + func caseOnlyChangeIsARename() { + #expect(RenameNameDecision.decide(typed: "Orders", original: "orders") == .commit("Orders")) + } +} diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index 4391291d2a..4bcb3b4f5d 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -29,6 +29,30 @@ Confirming with **Drop** or **Truncate** stages the operation. The row picks up Dropping is irreversible. On MySQL and MariaDB, truncate also resets the auto-increment counter. Back up important data first. +## Rename + +Right-click a table and choose **Rename**. The row's label turns into a field: type the new name and press Return, or press Escape to leave it alone. Clicking anywhere else commits, the way Finder does. + +The rename runs immediately, not on save. Open tabs on that table follow it and keep their filters, sort and column widths; a favourite stays a favourite, and the Recent entry keeps its place. A queued drop or truncate on the same table is cancelled, because the confirmation named an object that no longer answers to that name. + +| Database | Rename table | Rename database | Rename schema | +|----------|--------------|-----------------|---------------| +| MySQL / MariaDB | Yes | No, the statement was removed in 5.1.23 | No schemas | +| PostgreSQL, Redshift, CockroachDB | Yes | Yes | Yes | +| SQLite, LibSQL, Cloudflare D1 | Tables only, not views | No | No schemas | +| ClickHouse | Yes | Yes, on the Atomic engine | No | +| SQL Server | Yes, through `sp_rename` | No | No | +| Oracle, Dameng | Yes | No | No | +| DuckDB, BigQuery | Yes | No | No | +| Snowflake | Yes | Yes | Yes | +| Trino | Depends on the connector | No, a catalog is configuration | Yes | +| Teradata | Yes | No | No | +| MongoDB | Yes, within one database | No | No | + +Where a row cannot be renamed the item is absent rather than dimmed. Redis, Cassandra, DynamoDB, Elasticsearch, Kafka, etcd, SurrealDB and Beancount have no rename at all, and what looks like one in those engines is a copy followed by a delete. + +Rename a database or schema from its own row in the tree, not from the one the connection is on: switch elsewhere first. PostgreSQL refuses outright with "the current database cannot be renamed", and the engines that allow it leave the session pointing at a name that has gone. + ## Maintenance Right-click a table, choose **Maintenance**, and pick an operation. A sheet shows the operation's options and the exact SQL before it runs. From 1affb2e22a4807fb569ac314e28026ff85c08b11 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 16:40:44 +0700 Subject: [PATCH 3/6] fix(plugin-postgresql): implement rename for Redshift and CockroachDB --- .../LibPQDriverCore.swift | 25 +++++++++++++++++++ .../PostgreSQLPluginDriver.swift | 21 ---------------- 2 files changed, 25 insertions(+), 21 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index bc705349ca..cbb9bcbdfe 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -231,6 +231,31 @@ protocol LibPQBackedDriver: PluginDatabaseDriver { } extension LibPQBackedDriver { + /// The new name must be bare. Every libpq engine here rejects a qualified one, because this + /// statement renames in place and never moves the object; `SET SCHEMA` is the separate verb. + /// + /// It lives on the protocol rather than on `PostgreSQLPluginDriver`, because Redshift and + /// CockroachDB are siblings of that class rather than subclasses: an implementation there + /// leaves both of them declaring the capability with nothing behind it. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let target = "\(quoteIdentifier(schema ?? core.currentSchema)).\(quoteIdentifier(name))" + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } + + /// Not the database the connection is on: PostgreSQL, Redshift and CockroachDB all answer that + /// with a refusal, so the app keeps the item off a row it is browsing. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + func connect() async throws { try await core.connect() } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index be5bc827de..0f18aac13d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -953,27 +953,6 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { _ = try await execute(query: "DROP SCHEMA \(quoteIdentifier(name)) CASCADE") } - /// The new name must be bare. PostgreSQL rejects a qualified one, because this statement - /// renames in place and never moves the object; `SET SCHEMA` is the separate verb for that. - func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { - let target = qualifiedTable(name, schema: schema) - _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") - } - - /// Not the database the connection is on: PostgreSQL answers that with "the current database - /// cannot be renamed", so the app keeps the item off a row it is browsing. - func renameDatabase(name: String, to newName: String) async throws { - _ = try await execute( - query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" - ) - } - - func renameSchema(name: String, to newName: String) async throws { - _ = try await execute( - query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" - ) - } - private struct Template1Defaults { let collate: String let ctype: String From 17d910de2afae7e21762e46cab3b42459231e060 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 16:40:44 +0700 Subject: [PATCH 4/6] docs(table-operations): document renaming a table, a database and a schema --- docs/features/table-operations.mdx | 39 +++++++++++++++++++---------- docs/images/rename-table-dark.png | Bin 0 -> 5899 bytes docs/images/rename-table.png | Bin 0 -> 5899 bytes 3 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 docs/images/rename-table-dark.png create mode 100644 docs/images/rename-table.png diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index 4bcb3b4f5d..c65c653b0e 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -31,27 +31,40 @@ Dropping is irreversible. On MySQL and MariaDB, truncate also resets the auto-in ## Rename -Right-click a table and choose **Rename**. The row's label turns into a field: type the new name and press Return, or press Escape to leave it alone. Clicking anywhere else commits, the way Finder does. +Renaming happens in the row itself. Choose **Rename** from a table's right-click menu and the label becomes a field: Return commits, Escape leaves the name alone, and clicking elsewhere commits. The item is absent where the engine cannot rename that kind of object. -The rename runs immediately, not on save. Open tabs on that table follow it and keep their filters, sort and column widths; a favourite stays a favourite, and the Recent entry keeps its place. A queued drop or truncate on the same table is cancelled, because the confirmation named an object that no longer answers to that name. +The statement runs at once instead of joining the drop and truncate queue, so **Preview SQL** never shows it. Everything bound to the table follows the new name: open tabs keep their rows, filters, sort and column widths, a favourite stays a favourite, and the Recent entry holds its place. A drop or truncate already queued against that table comes back out of the queue. + + + A sidebar table row with its label replaced by an editable text field + A sidebar table row with its label replaced by an editable text field + | Database | Rename table | Rename database | Rename schema | |----------|--------------|-----------------|---------------| -| MySQL / MariaDB | Yes | No, the statement was removed in 5.1.23 | No schemas | -| PostgreSQL, Redshift, CockroachDB | Yes | Yes | Yes | -| SQLite, LibSQL, Cloudflare D1 | Tables only, not views | No | No schemas | -| ClickHouse | Yes | Yes, on the Atomic engine | No | -| SQL Server | Yes, through `sp_rename` | No | No | -| Oracle, Dameng | Yes | No | No | -| DuckDB, BigQuery | Yes | No | No | +| MySQL | Yes | No | No schemas | +| MariaDB | Yes | No | No schemas | +| PostgreSQL | Yes | Yes | Yes | +| Redshift | Yes | Yes | Yes | +| CockroachDB | Yes | Yes | Yes | +| PGlite | Yes | No | Yes | +| SQLite | Tables, not views | No | No schemas | +| LibSQL | Tables, not views | No | No schemas | +| Cloudflare D1 | Tables, not views | No | No schemas | +| ClickHouse | Yes | Yes | No | +| SQL Server | Yes | No | No | +| Oracle | Yes | No | No | +| Dameng | Yes | No | No | +| DuckDB | Yes | No | No | +| BigQuery | Yes | No | No | | Snowflake | Yes | Yes | Yes | -| Trino | Depends on the connector | No, a catalog is configuration | Yes | +| Trino | Depends on the connector | No | Yes | | Teradata | Yes | No | No | -| MongoDB | Yes, within one database | No | No | +| MongoDB | Yes | No | No | -Where a row cannot be renamed the item is absent rather than dimmed. Redis, Cassandra, DynamoDB, Elasticsearch, Kafka, etcd, SurrealDB and Beancount have no rename at all, and what looks like one in those engines is a copy followed by a delete. +No other engine has a rename, so the item never appears on Cassandra, DynamoDB, Elasticsearch, etcd, Kafka, Redis, SurrealDB or Beancount. What resembles one there is a copy followed by a delete, and it changes record identity. -Rename a database or schema from its own row in the tree, not from the one the connection is on: switch elsewhere first. PostgreSQL refuses outright with "the current database cannot be renamed", and the engines that allow it leave the session pointing at a name that has gone. +**Rename Database** and **Rename Schema** are absent on the container the connection is browsing. Switch to another one first, then rename the one you left. ## Maintenance diff --git a/docs/images/rename-table-dark.png b/docs/images/rename-table-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..ae569d1ba0cbb4f579986ec7042ee840b2febf22 GIT binary patch literal 5899 zcmeAS@N?(olHy`uVBq!ia0y~yV3S~AU_QXX1QhX5ePYbOAa=>q#WAGf)|(rKybKIH zhc_^-JSLx}*wdh@d}8JncAz?j^&TLyVIm7blEF;~q?8y^!V{#D7&1YH5vYjKp7>5?4HFfea$9d|C-oia%w- zWWfb90r!m-qC^HJsCFePEJs6hG(>?(g_w*_T#g4duV7hvw2g;9T!&eRl2F($0reS( zE1)KU%QE}{36ULU!8w|fh>7&ks3$TXfjiTqAxc!`L|n-<8luEi^rRH_pwT6wS|bvk z;NT~w%qPy3qfKUF!V;opG(?F@q%;oE4P|d;fRb%O!2fHjH#!@%GAK*}Y{&qt2dEw90PcDtYei;b8iS;j0f~)i46;^aHpDeJwBk1gNh<>qy8(ws jAhzNP4-6Mz8nc7r?#=ZoYvZl>K>_IL>gTe~DWM4fq#WAGf)|(rKybKIH zhc|qB9{pCgMQ}q3i{<2RKxGUML^(l}!6d@u0S}PD#E=ZiU_*%^dsILc6GIZ?M2H$< zoCr}f>d?`s2c56GN5Hn?3J**dU>YON XXf9dG!p?cj2NY7Cu6{1-oD!M<$%~a1 literal 0 HcmV?d00001 From f2c137bef4012e709bc2d43ca28ee539bc92231c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 17:43:59 +0700 Subject: [PATCH 5/6] fix(sidebar): let the object selection follow the database being browsed again --- .../Views/Sidebar/DatabaseTreeOutlineCoordinator.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 06851506ce..13c009b7fd 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -372,9 +372,15 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { publishedTables = selectedTables publishedSelectionDatabase = selectionDatabase + /// Matched on the object and scoped to the database on screen, in that order, rather than + /// on the whole reference. The model holds the row the user picked, database included, but + /// browsing elsewhere is meant to move the highlight onto that database's copy of the same + /// object; comparing references pins it to the database it was picked in and leaves the + /// tree with nothing selected the moment the browse cursor moves. + let selectedObjects = Set(selectedTables.map(\.table)) var nodes: [DatabaseTreeNode] = [] for node in nodeCache.values { - guard case .table(let ref) = node.kind, selectedTables.contains(ref) else { continue } + guard case .table(let ref) = node.kind, selectedObjects.contains(ref.table) else { continue } guard selectionDatabase == nil || ref.database == selectionDatabase else { continue } nodes.append(node) } @@ -382,7 +388,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { 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) != selectedTables + isModelSelectionAdoptionPending = Set(lastSelection.map(\.table)) != selectedObjects } private var modelSelectionDatabase: String? { From a0372fb06a2e64b9cf00d1eee4f2cfc3f9f2813d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 27 Aug 2026 18:19:14 +0700 Subject: [PATCH 6/6] fix(sidebar): gate, scope and fully retarget every rename --- .../CloudflareD1Plugin.swift | 1 + Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift | 1 + .../MSSQLPluginDriver+Rename.swift | 6 +- .../MongoDBPluginDriver.swift | 11 +- Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 1 + .../SnowflakePlugin.swift | 5 +- Plugins/TableProPluginKit/DriverPlugin.swift | 4 + .../Plugins/PluginManager+Registration.swift | 5 + ...uginMetadataRegistry+CuratedDefaults.swift | 649 ++++++++++++++++++ .../Core/Plugins/PluginMetadataRegistry.swift | 627 +---------------- .../Query/MetadataConnectionPool.swift | 15 + .../Core/Storage/ColumnLayoutPersister.swift | 33 + .../Core/Storage/FilterSettingsStorage.swift | 38 + .../Core/Storage/Preferences/TableScope.swift | 15 +- TablePro/Core/Storage/RecentTablesStore.swift | 79 ++- .../Database/ObjectRenameEligibility.swift | 15 +- TablePro/Models/UI/SharedSidebarState.swift | 36 +- .../MainContentCoordinator+Rename.swift | 121 +++- ...ainContentCoordinator+RenameAdoption.swift | 153 ++++- ...abaseTreeOutlineCoordinator+Commands.swift | 4 +- .../DatabaseTreeOutlineCoordinator+Menu.swift | 1 + ...atabaseTreeOutlineCoordinator+Rename.swift | 7 +- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 31 +- .../Sidebar/Menu/SidebarMenuCommand.swift | 2 +- .../ObjectRenameEligibilityTests.swift | 14 +- .../Sidebar/DatabaseTreeMenuSpecTests.swift | 41 +- docs/features/table-operations.mdx | 2 +- 27 files changed, 1196 insertions(+), 721 deletions(-) create mode 100644 TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 336b3c6940..231d727576 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -16,6 +16,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "Cloudflare D1" static let supportsRenameTable = true + static let supportsRenameView = false static let databaseDisplayName = "Cloudflare D1" static let iconName = "cloudflare-d1-icon" static let defaultPort = 0 diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index 177b20f8d6..465575c840 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -16,6 +16,7 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseTypeId = "libSQL" static let supportsRenameTable = true + static let supportsRenameView = false static let additionalDatabaseTypeIds = ["Turso"] static let databaseDisplayName = "libSQL / Turso" static let iconName = "libsql-icon" diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift index 3bd60f2b19..8da14129ec 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift @@ -10,8 +10,12 @@ extension MSSQLPluginDriver { /// `sp_rename` takes names as string literals rather than identifiers, and the new one must be /// a single part: passing `schema.new` renames the object to something literally called /// "schema.new". Its object type argument is what tells the procedure this is not a column. + /// + /// Each half of the old name is bracketed before the two are joined, because `@objname` is + /// parsed as a multipart name: a table legitimately called `quarter.1` would otherwise be read + /// as the object `1` in the schema `quarter`. func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { - let qualified = [schema, name].compactMap { $0 }.joined(separator: ".") + let qualified = [schema, name].compactMap { $0 }.map(quoteIdentifier).joined(separator: ".") _ = try await execute( query: "EXEC sp_rename \(literal(qualified)), \(literal(newName)), 'OBJECT'" ) diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 419b503353..297800cb6d 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -643,21 +643,14 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { throw MongoDBPluginError.notConnected } let database = schema ?? currentDb - let from = Self.jsonString("\(database).\(name)") - let to = Self.jsonString("\(database).\(newName)") + let from = "\"\(escapeJsonString("\(database).\(name)"))\"" + let to = "\"\(escapeJsonString("\(database).\(newName)"))\"" _ = try await conn.runCommand( "{\"renameCollection\": \(from), \"to\": \(to)}", database: "admin" ) } - private static func jsonString(_ value: String) -> String { - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - return "\"\(escaped)\"" - } - func dropDatabase(name: String) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 7261bfdea6..f2b2278c3f 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -38,6 +38,7 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false static let supportsRenameTable = true + static let supportsRenameView = false static let supportsTriggers = true static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 7423dc8931..55a1b4c350 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -21,7 +21,10 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsRenameTable = true - static let supportsRenameDatabase = true + /// Off, and not because Snowflake refuses: `ALTER DATABASE ... RENAME TO` works and the driver + /// implements it. This tree hangs tables off schemas and draws no database rows at all, so + /// there is nowhere to raise the command from. Turn it back on with the row that reaches it. + static let supportsRenameDatabase = false static let supportsRenameSchema = true static let databaseDisplayName = "Snowflake" diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index ee8b4e367f..342f6b9852 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -64,6 +64,7 @@ public protocol DriverPlugin: TableProPlugin { static var supportsDropDatabase: Bool { get } static var supportsDropSchema: Bool { get } static var supportsRenameTable: Bool { get } + static var supportsRenameView: Bool { get } static var supportsRenameDatabase: Bool { get } static var supportsRenameSchema: Bool { get } @@ -150,6 +151,9 @@ public extension DriverPlugin { static var supportsDropDatabase: Bool { false } static var supportsDropSchema: Bool { false } static var supportsRenameTable: Bool { false } + /// SQLite's `ALTER TABLE ... RENAME` refuses a view, and the engines built on it inherit that. + /// Everywhere else a view renames the way a table does. + static var supportsRenameView: Bool { supportsRenameTable } static var supportsRenameDatabase: Bool { false } static var supportsRenameSchema: Bool { false } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 962c68ca47..04081972a8 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -551,6 +551,11 @@ extension PluginManager { .capabilities.supportsRenameTable ?? false } + func supportsRenameView(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameView ?? false + } + func supportsRenameDatabase(for databaseType: DatabaseType) -> Bool { PluginMetadataRegistry.shared.snapshot(for: databaseType)? .capabilities.supportsRenameDatabase ?? false diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift new file mode 100644 index 0000000000..aeb904a312 --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -0,0 +1,649 @@ +// +// PluginMetadataRegistry+CuratedDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// What the app knows about a database type before its plugin loads. +/// +/// The primary type ids here are overwritten by `buildMetadataSnapshot` the moment the plugin +/// registers, so these are the pre-load answer for those. For a variant id they are the whole +/// answer: `registerVariant` keeps the curated entry and ignores the plugin's own statics, which +/// is the only reason MariaDB, Redshift, CockroachDB and PGlite can differ from the plugin that +/// drives them. +extension PluginMetadataRegistry { + // swiftlint:disable:next function_body_length + static func curatedDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", + "UNION", "INTERSECT", "EXCEPT", + "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", + "SHOW", "DESCRIBE", "EXPLAIN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", + "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", + "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", + "CAST", "CONVERT" + ], + dataTypes: [ + "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", + "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", + "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", + "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" + ], + tableOptions: [ + "ENGINE=InnoDB", "DEFAULT CHARSET=utf8mb4", "COLLATE=utf8mb4_unicode_ci", + "AUTO_INCREMENT=", "COMMENT=", "ROW_FORMAT=" + ], + regexSyntax: .regexp, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, + paginationStyle: .limit, + requiresBackslashEscaping: true, + caseSensitivityStyle: .collationDefined + ) + + let mysqlColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], + "String": ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "ENUM", "SET"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], + "Binary": ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "BIT"], + "Boolean": ["BOOLEAN", "BOOL"], + "JSON": ["JSON"], + "Spatial": ["GEOMETRY", "POINT", "LINESTRING", "POLYGON"] + ] + + let postgresqlDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "RETURNING", "WITH", "RECURSIVE", "MATERIALIZED", + "EXPLAIN", "ANALYZE", "VERBOSE", + "WINDOW", "OVER", "PARTITION", + "LATERAL", "ORDINALITY" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + "CAST", "TO_NUMBER", "TO_TIMESTAMP", + "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" + ], + dataTypes: [ + "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", + "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", + "CHAR", "CHARACTER", "VARCHAR", "TEXT", + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" + ], + tableOptions: [ + "INHERITS", "PARTITION BY", "TABLESPACE", "WITH", "WITHOUT OIDS" + ], + regexSyntax: .tilde, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + caseSensitivityStyle: .ilikeOperator + ) + + // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. + let redshiftDialect = postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction) + + let postgresqlColumnTypes: [String: [String]] = [ + "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], + "Float": ["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], + "String": ["CHARACTER VARYING", "VARCHAR", "CHARACTER", "CHAR", "TEXT", "NAME"], + "Date": [ + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE" + ], + "Binary": ["BYTEA"], + "Boolean": ["BOOLEAN"], + "JSON": ["JSON", "JSONB"], + "UUID": ["UUID"], + "Array": ["ARRAY"], + "Network": ["INET", "CIDR", "MACADDR", "MACADDR8"], + "Geometric": ["POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE"], + "Range": ["INT4RANGE", "INT8RANGE", "NUMRANGE", "TSRANGE", "TSTZRANGE", "DATERANGE"], + "Text Search": ["TSVECTOR", "TSQUERY"], + "XML": ["XML"] + ] + + let sqliteDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ], + tableOptions: [ + "WITHOUT ROWID", "STRICT" + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + caseSensitivityStyle: .collationDefined + ) + + let sqliteColumnTypes: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], + "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"] + ] + + let pgpassField = ConnectionField( + id: "usePgpass", + label: String(localized: "Use Password File"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ) + + let connectionOptionsField = ConnectionField( + id: "connectionOptions", + label: String(localized: "Connection Options"), + placeholder: "--cluster=my-cluster", + fieldType: .text, + section: .advanced + ) + + let awsIAMFields = AWSAuthFields.standard() + [AWSAuthFields.rdsEndpointField()] + + let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ + ("MySQL", PluginMetadataSnapshot( + displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "mysql", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["mysql"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#FF9500", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement, + .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: mysqlDialect, + statementCompletions: [], + columnTypesByCategory: mysqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, + category: .relational, + tagline: String(localized: "Most popular open-source SQL database"), + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + ) + )), + ("MariaDB", PluginMetadataSnapshot( + displayName: "MariaDB", iconName: "mariadb-icon", defaultPort: 3_306, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "mariadb", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["mariadb"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#00B4D8", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement, + .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: mysqlDialect, + statementCompletions: [], + columnTypesByCategory: mysqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, + category: .relational, + tagline: String(localized: "Open-source fork of MySQL"), + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + ) + )), + ("PostgreSQL", PluginMetadataSnapshot( + displayName: "PostgreSQL", iconName: "postgresql-icon", defaultPort: 5_432, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "postgresql", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["postgresql", "postgres"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#336791", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField] + awsIAMFields, + category: .relational, + tagline: String(localized: "Advanced object-relational SQL"), + defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432" + ) + )), + ("Redshift", PluginMetadataSnapshot( + displayName: "Redshift", iconName: "redshift-icon", defaultPort: 5_439, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "redshift", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["redshift"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#205B8E", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["padb_harvest"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: redshiftDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField], + category: .analytical, + tagline: String(localized: "Amazon's columnar warehouse on Postgres") + ) + )), + ("CockroachDB", PluginMetadataSnapshot( + displayName: "CockroachDB", iconName: "cockroachdb-icon", defaultPort: 26_257, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar, + navigationModel: .standard, + explainVariants: [ + ExplainVariant( + id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText + ), + ExplainVariant( + id: "analyze", + label: "EXPLAIN ANALYZE", + sqlPrefix: "EXPLAIN ANALYZE", + format: .cockroachText + ), + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#6933FF", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["system"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField], + category: .relational, + tagline: String(localized: "Distributed SQL, PostgreSQL-compatible") + ) + )), + ("PGlite", PluginMetadataSnapshot( + displayName: "PGlite", iconName: "postgresql-icon", defaultPort: 5_432, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "pglite", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["pglite"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#F4B942", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + defaultSSLMode: .disabled, + supportsCloudflareTunnel: false, + supportsConnectionPooling: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [], + category: .relational, + tagline: String(localized: "Embedded WASM Postgres over a socket server"), + hidesBuiltInPassword: true, + defaultHost: "127.0.0.1" + ) + )), + ("SQLite", PluginMetadataSnapshot( + displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "sqlite", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + supportsHealthMonitor: false, urlSchemes: ["sqlite"], postConnectActions: [], + brandColorHex: "#003B57", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameTable: true, + supportsRenameView: false, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsModifyColumn: false, + supportsRenameColumn: true, + supportsModifyPrimaryKey: false, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsDatabaseTriggerBrowse: true, + supportsCloudflareTunnel: false, + localFilePathField: .database, + supportsRemoteDatabaseFile: true + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"], + databaseGroupingStrategy: .flat, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: sqliteDialect, + statementCompletions: [], + columnTypesByCategory: sqliteColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .relational, + tagline: String(localized: "Embedded zero-config SQL database") + ) + )) + ] + return defaults + } +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index eca85a030e..7abc3b45ab 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -48,6 +48,7 @@ struct PluginMetadataSnapshot: Sendable { let requiresReconnectForDatabaseSwitch: Bool let supportsDropDatabase: Bool var supportsRenameTable: Bool = false + var supportsRenameView: Bool = false var supportsRenameDatabase: Bool = false var supportsRenameSchema: Bool = false // `var` with defaults so existing call sites compile without passing these fields @@ -334,631 +335,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { registerBuiltInDefaults() } - // swiftlint:disable function_body_length private func registerBuiltInDefaults() { - let mysqlDialect = SQLDialectDescriptor( - identifierQuote: "`", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", - "UNION", "INTERSECT", "EXCEPT", - "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", - "SHOW", "DESCRIBE", "EXPLAIN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", - "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", - "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", - "CAST", "CONVERT" - ], - dataTypes: [ - "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", - "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", - "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", - "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", - "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" - ], - tableOptions: [ - "ENGINE=InnoDB", "DEFAULT CHARSET=utf8mb4", "COLLATE=utf8mb4_unicode_ci", - "AUTO_INCREMENT=", "COMMENT=", "ROW_FORMAT=" - ], - regexSyntax: .regexp, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .implicit, - paginationStyle: .limit, - requiresBackslashEscaping: true, - caseSensitivityStyle: .collationDefined - ) - - let mysqlColumnTypes: [String: [String]] = [ - "Integer": ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT"], - "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], - "String": ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "ENUM", "SET"], - "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], - "Binary": ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "BIT"], - "Boolean": ["BOOLEAN", "BOOL"], - "JSON": ["JSON"], - "Spatial": ["GEOMETRY", "POINT", "LINESTRING", "POLYGON"] - ] - - let postgresqlDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "RETURNING", "WITH", "RECURSIVE", "MATERIALIZED", - "EXPLAIN", "ANALYZE", "VERBOSE", - "WINDOW", "OVER", "PARTITION", - "LATERAL", "ORDINALITY" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", - "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", - "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", - "CAST", "TO_NUMBER", "TO_TIMESTAMP", - "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" - ], - dataTypes: [ - "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", - "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", - "CHAR", "CHARACTER", "VARCHAR", "TEXT", - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", - "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" - ], - tableOptions: [ - "INHERITS", "PARTITION BY", "TABLESPACE", "WITH", "WITHOUT OIDS" - ], - regexSyntax: .tilde, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .limit, - caseSensitivityStyle: .ilikeOperator - ) - - // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. - let redshiftDialect = postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction) - - let postgresqlColumnTypes: [String: [String]] = [ - "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], - "Float": ["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], - "String": ["CHARACTER VARYING", "VARCHAR", "CHARACTER", "CHAR", "TEXT", "NAME"], - "Date": [ - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", - "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE" - ], - "Binary": ["BYTEA"], - "Boolean": ["BOOLEAN"], - "JSON": ["JSON", "JSONB"], - "UUID": ["UUID"], - "Array": ["ARRAY"], - "Network": ["INET", "CIDR", "MACADDR", "MACADDR8"], - "Geometric": ["POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE"], - "Range": ["INT4RANGE", "INT8RANGE", "NUMRANGE", "TSRANGE", "TSTZRANGE", "DATERANGE"], - "Text Search": ["TSVECTOR", "TSQUERY"], - "XML": ["XML"] - ] - - let sqliteDialect = SQLDialectDescriptor( - identifierQuote: "`", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", - "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", - "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", - "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", - "REPLACE", "INSTR", "PRINTF", - "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", - "ABS", "ROUND", "RANDOM", - "CAST", "TYPEOF", - "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" - ], - dataTypes: [ - "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", - "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "UNSIGNED", "BIG", "INT2", "INT8", - "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", - "NVARCHAR", "CLOB", - "DOUBLE", "PRECISION", "FLOAT", - "DECIMAL", "BOOLEAN", "DATE", "DATETIME" - ], - tableOptions: [ - "WITHOUT ROWID", "STRICT" - ], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .limit, - caseSensitivityStyle: .collationDefined - ) - - let sqliteColumnTypes: [String: [String]] = [ - "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], - "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], - "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], - "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], - "Binary": ["BLOB"], - "Boolean": ["BOOLEAN"] - ] - - let pgpassField = ConnectionField( - id: "usePgpass", - label: String(localized: "Use Password File"), - defaultValue: "false", - fieldType: .toggle, - section: .authentication, - hidesPassword: true - ) - - let connectionOptionsField = ConnectionField( - id: "connectionOptions", - label: String(localized: "Connection Options"), - placeholder: "--cluster=my-cluster", - fieldType: .text, - section: .advanced - ) - - let awsIAMFields = AWSAuthFields.standard() + [AWSAuthFields.rdsEndpointField()] - - let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ - ("MySQL", PluginMetadataSnapshot( - displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "mysql", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["mysql"], postConnectActions: [.selectDatabaseFromLastSession], - brandColorHex: "#FF9500", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: true, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: false, - supportsRenameSchema: false, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .byDatabase, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement, - .comment, .charset, .collation - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: mysqlDialect, - statementCompletions: [], - columnTypesByCategory: mysqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields, - category: .relational, - tagline: String(localized: "Most popular open-source SQL database"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" - ) - )), - ("MariaDB", PluginMetadataSnapshot( - displayName: "MariaDB", iconName: "mariadb-icon", defaultPort: 3_306, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "mariadb", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["mariadb"], postConnectActions: [.selectDatabaseFromLastSession], - brandColorHex: "#00B4D8", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: true, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: false, - supportsRenameSchema: false, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .byDatabase, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement, - .comment, .charset, .collation - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: mysqlDialect, - statementCompletions: [], - columnTypesByCategory: mysqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields, - category: .relational, - tagline: String(localized: "Open-source fork of MySQL"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" - ) - )), - ("PostgreSQL", PluginMetadataSnapshot( - displayName: "PostgreSQL", iconName: "postgresql-icon", defaultPort: 5_432, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "postgresql", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["postgresql", "postgres"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#336791", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: true, - supportsRenameSchema: true, - supportsDropSchema: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField] + awsIAMFields, - category: .relational, - tagline: String(localized: "Advanced object-relational SQL"), - defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432" - ) - )), - ("Redshift", PluginMetadataSnapshot( - displayName: "Redshift", iconName: "redshift-icon", defaultPort: 5_439, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "redshift", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["redshift"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#205B8E", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: true, - supportsRenameSchema: true, - supportsDropSchema: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["padb_harvest"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: redshiftDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField], - category: .analytical, - tagline: String(localized: "Amazon's columnar warehouse on Postgres") - ) - )), - ("CockroachDB", PluginMetadataSnapshot( - displayName: "CockroachDB", iconName: "cockroachdb-icon", defaultPort: 26_257, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar, - navigationModel: .standard, - explainVariants: [ - ExplainVariant( - id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText - ), - ExplainVariant( - id: "analyze", - label: "EXPLAIN ANALYZE", - sqlPrefix: "EXPLAIN ANALYZE", - format: .cockroachText - ), - ], - pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#6933FF", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: true, - supportsRenameSchema: true, - supportsDropSchema: true, - supportsAddColumn: false, - supportsModifyColumn: false, - supportsDropColumn: false, - supportsRenameColumn: false, - supportsAddIndex: false, - supportsDropIndex: false, - supportsModifyPrimaryKey: false, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["system"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField], - category: .relational, - tagline: String(localized: "Distributed SQL, PostgreSQL-compatible") - ) - )), - ("PGlite", PluginMetadataSnapshot( - displayName: "PGlite", iconName: "postgresql-icon", defaultPort: 5_432, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "pglite", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["pglite"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#F4B942", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsRenameTable: true, - supportsRenameDatabase: false, - supportsRenameSchema: true, - supportsDropSchema: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - defaultSSLMode: .disabled, - supportsCloudflareTunnel: false, - supportsConnectionPooling: false - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [], - category: .relational, - tagline: String(localized: "Embedded WASM Postgres over a socket server"), - hidesBuiltInPassword: true, - defaultHost: "127.0.0.1" - ) - )), - ("SQLite", PluginMetadataSnapshot( - displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0, - requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "sqlite", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, - supportsHealthMonitor: false, urlSchemes: ["sqlite"], postConnectActions: [], - brandColorHex: "#003B57", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .fileBased, supportsDatabaseSwitching: false, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsRenameTable: true, - supportsRenameDatabase: false, - supportsRenameSchema: false, - supportsModifyColumn: false, - supportsRenameColumn: true, - supportsModifyPrimaryKey: false, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsDatabaseTriggerBrowse: true, - supportsCloudflareTunnel: false, - localFilePathField: .database, - supportsRemoteDatabaseFile: true - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"], - databaseGroupingStrategy: .flat, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: sqliteDialect, - statementCompletions: [], - columnTypesByCategory: sqliteColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - category: .relational, - tagline: String(localized: "Embedded zero-config SQL database") - ) - )) - ] - // swiftlint:enable function_body_length - let allDefaults = defaults + registryPluginDefaults() + let allDefaults = Self.curatedDefaults() + registryPluginDefaults() for entry in allDefaults { snapshots[entry.typeId] = entry.snapshot defaultSnapshots[entry.typeId] = entry.snapshot @@ -1189,6 +567,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, supportsDropDatabase: driverType.supportsDropDatabase, supportsRenameTable: driverType.supportsRenameTable, + supportsRenameView: driverType.supportsRenameView, supportsRenameDatabase: driverType.supportsRenameDatabase, supportsRenameSchema: driverType.supportsRenameSchema, supportsDropSchema: driverType.supportsDropSchema, diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 3ac5dc6d92..672a08e09f 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -76,6 +76,21 @@ final class MetadataConnectionPool { return try await entry.runSerially(body) } + /// Closes only the leases attached to one database, which is what a rename of that database + /// needs: PostgreSQL refuses `ALTER DATABASE ... RENAME` while any backend is connected to it, + /// and an expanded row or a tab that ran a query there leaves one here. + func closeAll(connectionId: UUID, database: String) { + for key in pending.keys + where key.scope.connectionId == connectionId && key.scope.database == database { + pending[key]?.cancel() + pending.removeValue(forKey: key) + } + for key in entries.keys + where key.scope.connectionId == connectionId && key.scope.database == database { + closeOrDeferEntry(forKey: key) + } + } + func closeAll(connectionId: UUID) { for key in pending.keys where key.scope.connectionId == connectionId { pending[key]?.cancel() diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 5d70d65fbd..8e73b13750 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -141,6 +141,39 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey.storageKey)) } + /// Moves every table's saved layout from one container to another. Same prefix rewrite as the + /// filter store, and for the same reason: the tables that have a layout are whatever the user + /// has opened over the life of the connection, not what is loaded now. + func renameScope( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: fromDatabase, schema: fromSchema + ) + let newPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: toDatabase, schema: toSchema + ) + guard oldPrefix != newPrefix else { return } + + var entries = loadEntries(for: connectionId) + let moving = entries.keys.filter { $0.hasPrefix(oldPrefix) } + guard !moving.isEmpty else { return } + for key in moving { + let moved = newPrefix + key.dropFirst(oldPrefix.count) + entries[moved] = entries.removeValue(forKey: key) + } + cache[connectionId] = entries + writeEntries(entries, for: connectionId) + for key in moving { + syncTracker.markDirty(.settings, id: Self.syncCategory(for: newPrefix + key.dropFirst(oldPrefix.count))) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key)) + } + } + func clear(for key: ColumnLayoutTableKey) { removeLegacyHidden(for: key) diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index f6d1251b30..edb873cff8 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -285,6 +285,44 @@ final class FilterSettingsStorage { } } + /// Moves every table's saved filters from one container to another, by rewriting the part of + /// each key that names the container. Keyed by prefix rather than by walking the table list, + /// because that list is loaded lazily and a table nobody opened this session still has a file. + func renameScope( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: fromDatabase, schema: fromSchema + ) + let newPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: toDatabase, schema: toSchema + ) + guard oldPrefix != newPrefix else { return } + + for key in lastFiltersCache.keys where key.hasPrefix(oldPrefix) { + let moved = newPrefix + key.dropFirst(oldPrefix.count) + lastFiltersCache[moved] = lastFiltersCache.removeValue(forKey: key) + } + + let directory = filterStateDirectory + ioQueue.async { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + else { return } + for file in files where file.pathExtension == "json" { + let key = file.deletingPathExtension().lastPathComponent + guard key.hasPrefix(oldPrefix) else { continue } + let moved = directory.appendingPathComponent("\(newPrefix + key.dropFirst(oldPrefix.count)).json") + try? fm.removeItem(at: moved) + try? fm.moveItem(at: file, to: moved) + } + } + } + func waitForPendingDiskWrites() { ioQueue.sync {} } diff --git a/TablePro/Core/Storage/Preferences/TableScope.swift b/TablePro/Core/Storage/Preferences/TableScope.swift index f31c43e881..6e2560db7e 100644 --- a/TablePro/Core/Storage/Preferences/TableScope.swift +++ b/TablePro/Core/Storage/Preferences/TableScope.swift @@ -19,7 +19,20 @@ struct TableScope: Hashable, Codable, Sendable { } var storageComponent: String { - [connectionId.uuidString, database ?? "", schema ?? "", table] + Self.encode([connectionId.uuidString, database ?? "", schema ?? "", table]) + } + + /// Everything a key for this container starts with, so a container that is renamed can move + /// every table's saved state without knowing which tables exist. The list is loaded lazily and + /// a table nobody has opened this session still has settings on disk. + static func storagePrefix(connectionId: UUID, database: String?, schema: String?) -> String { + var parts = [connectionId.uuidString, database ?? ""] + if let schema { parts.append(schema) } + return encode(parts) + "." + } + + private static func encode(_ parts: [String]) -> String { + parts .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } .joined(separator: ".") } diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index f0e442c065..2409fb53e6 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -85,32 +85,77 @@ final class RecentTablesStore { return updated } + /// A renamed table keeps its position rather than being dropped and re-added, which would look + /// like the user had just opened it. Any stale entry already sitting on the new name is removed + /// first: two entries with one id map to a single cached node and the outline draws neither. func rename(connectionId: UUID, entry: RecentTableEntry, to newName: String) -> [RecentTableEntry] { - var entries = self.entries(connectionId: connectionId) - guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return entries } - let existing = entries[index] - entries[index] = RecentTableEntry( - database: existing.database, schema: existing.schema, name: newName, - isView: existing.isView, openedAt: existing.openedAt - ) - persist(entries, connectionId: connectionId) - return entries + mutate(connectionId: connectionId) { entries in + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return false } + let existing = entries[index] + let renamed = RecentTableEntry( + database: existing.database, schema: existing.schema, name: newName, + isView: existing.isView, openedAt: existing.openedAt + ) + entries.removeAll { $0.id == renamed.id } + guard let insertion = entries.firstIndex(where: { $0.id == existing.id }) else { return false } + entries[insertion] = renamed + return true + } } func renameDatabase(connectionId: UUID, from oldName: String, to newName: String) -> [RecentTableEntry] { - var entries = self.entries(connectionId: connectionId) - guard entries.contains(where: { $0.database == oldName }) else { return entries } - entries = entries.map { entry in - guard entry.database == oldName else { return entry } - return RecentTableEntry( - database: newName, schema: entry.schema, name: entry.name, - isView: entry.isView, openedAt: entry.openedAt - ) + mutate(connectionId: connectionId) { entries in + guard entries.contains(where: { $0.database == oldName }) else { return false } + entries = entries.map { entry in + guard entry.database == oldName else { return entry } + return RecentTableEntry( + database: newName, schema: entry.schema, name: entry.name, + isView: entry.isView, openedAt: entry.openedAt + ) + } + return Self.deduplicate(&entries) } + } + + func renameSchema( + connectionId: UUID, + database: String?, + from oldName: String, + to newName: String + ) -> [RecentTableEntry] { + mutate(connectionId: connectionId) { entries in + guard entries.contains(where: { $0.database == database && $0.schema == oldName }) else { return false } + entries = entries.map { entry in + guard entry.database == database, entry.schema == oldName else { return entry } + return RecentTableEntry( + database: entry.database, schema: newName, name: entry.name, + isView: entry.isView, openedAt: entry.openedAt + ) + } + return Self.deduplicate(&entries) + } + } + + /// Reads, mutates and persists in one place, so a rename lands on disk whether or not the + /// Recent section is on screen. The live list is empty while Show Recent Tables is off, and + /// renaming only that left a dead entry to reappear under the old name when it came back on. + private func mutate( + connectionId: UUID, + _ body: (inout [RecentTableEntry]) -> Bool + ) -> [RecentTableEntry] { + var entries = self.entries(connectionId: connectionId) + guard body(&entries) else { return entries } persist(entries, connectionId: connectionId) return entries } + @discardableResult + private static func deduplicate(_ entries: inout [RecentTableEntry]) -> Bool { + var seen = Set() + entries = entries.filter { seen.insert($0.id).inserted } + return true + } + func removeEntries(for connectionId: UUID) { defaults.removeObject(forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) defaults.removeObject(forKey: legacyKeyPrefix + connectionId.uuidString) diff --git a/TablePro/Models/Database/ObjectRenameEligibility.swift b/TablePro/Models/Database/ObjectRenameEligibility.swift index 4fcd11c333..4a0b76f625 100644 --- a/TablePro/Models/Database/ObjectRenameEligibility.swift +++ b/TablePro/Models/Database/ObjectRenameEligibility.swift @@ -16,14 +16,25 @@ enum ObjectRenameEligibility { let activeDatabase: String? let activeSchema: String? let supportsRenameTable: Bool + let supportsRenameView: Bool let supportsRenameDatabase: Bool let supportsRenameSchema: Bool let isReadOnly: Bool } + /// Asked per object kind, not per engine. SQLite's one rename statement refuses a view and + /// the engines built on it inherit that, so offering the item on a view there guarantees a + /// failure alert for something the menu promised. static func canRename(table: TableInfo, context: Context) -> Bool { - guard !context.isReadOnly, context.supportsRenameTable else { return false } - return table.type != .systemTable + guard !context.isReadOnly else { return false } + switch table.type { + case .systemTable: + return false + case .view, .materializedView: + return context.supportsRenameView + case .table, .foreignTable, .partitionedTable, .externalTable: + return context.supportsRenameTable + } } static func renameable(_ targets: [DatabaseContainerRef], context: Context) -> DatabaseContainerRef? { diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index f0980bf98c..2e7cbfcb3e 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -68,27 +68,35 @@ final class SharedSidebarState { recentTables = RecentTablesStore.shared.remove(connectionId: connectionId, entry: entry) } - /// A renamed table keeps its place in Recent. Dropping it instead would look like the entry - /// aged out, and re-adding it under the new name would move it to the top of a list the user - /// did not open anything from. + /// A renamed table keeps its place in Recent. The store is asked directly rather than the live + /// list, because that list is empty while Show Recent Tables is off and the entry is still on + /// disk: renaming only what is on screen left a dead entry to reappear under the old name. func renameRecentTable(database: String?, schema: String?, from oldName: String, to newName: String) { let scope = normalizedDatabase(database) - guard let index = recentTables.firstIndex(where: { + let existing = RecentTablesStore.shared.entries(connectionId: connectionId).first { $0.database == scope && $0.schema == schema && $0.name == oldName - }) else { return } - recentTables = RecentTablesStore.shared.rename( - connectionId: connectionId, - entry: recentTables[index], - to: newName - ) + } + guard let existing else { return } + publish(RecentTablesStore.shared.rename(connectionId: connectionId, entry: existing, to: newName)) } - /// Every Recent entry in a renamed database follows it, because the entries are keyed by the - /// database's name and would otherwise all point at one that has gone. + /// Every Recent entry in a renamed container follows it, because the entries are keyed by the + /// container's name and would otherwise all point at one that has gone. func renameRecentDatabase(from oldName: String, to newName: String) { - recentTables = RecentTablesStore.shared.renameDatabase( + publish(RecentTablesStore.shared.renameDatabase( connectionId: connectionId, from: oldName, to: newName - ) + )) + } + + func renameRecentSchema(database: String?, from oldName: String, to newName: String) { + publish(RecentTablesStore.shared.renameSchema( + connectionId: connectionId, database: normalizedDatabase(database), from: oldName, to: newName + )) + } + + private func publish(_ entries: [RecentTableEntry]) { + guard AppSettingsManager.shared.general.showRecentTables else { return } + recentTables = entries } func clearRecentTables(inDatabase database: String?) { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift index cdc0fe60f1..051df1355b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift @@ -18,27 +18,38 @@ private let renameLogger = Logger(subsystem: "com.TablePro", category: "Rename") extension MainContentCoordinator { func renameTable(_ ref: DatabaseTreeTableRef, to newName: String) { let objectType = TableObjectKeyword.forDDL(ref.table.type) + let oldName = ref.table.name + let schema = ref.qualifyingSchema + Task { [weak self] in guard let self else { return } + /// The scope comes from the row, not from whichever database the session is on. + /// `activateThen` switches the browse cursor first but reports no success, so a switch + /// that failed would otherwise leave this running an unqualified statement against the + /// database still in front, renaming a same-named object there. + guard let scope = DatabaseManager.shared.resolvedScope( + database: ref.database, schema: schema, for: connectionId + ) else { + presentRenameFailure(DatabaseError.notConnected) + return + } + guard await authorizeRename( + describing: String( + format: String(localized: "Rename %1$@ to %2$@"), qualifiedLabel(ref), newName + ) + ) else { return } + do { - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected + let route = DatabaseManager.shared.executionRoute(for: scope) + try await DatabaseManager.shared.withScopedDriver( + scope: scope, route: route, cancellation: .protectedWrite + ) { driver in + try await driver.renameTable( + name: oldName, schema: schema, to: newName, objectType: objectType + ) } - try await driver.renameTable( - name: ref.table.name, - schema: ref.qualifyingSchema, - to: newName, - objectType: objectType - ) } catch { - renameLogger.error( - "Rename failed for \(ref.id, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - AlertHelper.showErrorSheet( - title: String(localized: "Rename Failed"), - message: error.localizedDescription, - window: contentWindow - ) + presentRenameFailure(error, object: ref.id) return } adoptTableRename(ref, to: newName) @@ -49,17 +60,14 @@ extension MainContentCoordinator { func renameContainer(_ ref: DatabaseContainerRef, to newName: String) { Task { [weak self] in guard let self else { return } + guard await authorizeRename( + describing: String(format: String(localized: "Rename %1$@ to %2$@"), ref.name, newName) + ) else { return } + do { try await performContainerRename(ref, to: newName) } catch { - renameLogger.error( - "Rename failed for \(ref.id, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - AlertHelper.showErrorSheet( - title: String(localized: "Rename Failed"), - message: error.localizedDescription, - window: contentWindow - ) + presentRenameFailure(error, object: ref.id) return } adoptContainerRename(ref, to: newName) @@ -79,10 +87,21 @@ extension MainContentCoordinator { private func performContainerRename(_ ref: DatabaseContainerRef, to newName: String) async throws { switch ref.kind { case .database: - guard let driver = DatabaseManager.shared.driver(for: connectionId) else { - throw DatabaseError.notConnected + /// Renaming a database runs from a connection that is not on it, which the menu already + /// guarantees by keeping the item off the browsed row. The metadata pool is the other + /// way a backend stays attached to it, and PostgreSQL refuses the statement while one + /// is, so its leases on that database are closed first. + if let database = ref.database { + MetadataConnectionPool.shared.closeAll(connectionId: connectionId, database: database) + } + guard let scope = browseScope else { throw DatabaseError.notConnected } + let route = DatabaseManager.shared.executionRoute(for: scope) + let name = ref.name + try await DatabaseManager.shared.withScopedDriver( + scope: scope, route: route, cancellation: .protectedWrite + ) { driver in + try await driver.renameDatabase(name: name, to: newName) } - try await driver.renameDatabase(name: ref.name, to: newName) case .schema: guard let scope = DatabaseManager.shared.resolvedScope( database: ref.database, schema: nil, for: connectionId @@ -95,6 +114,54 @@ extension MainContentCoordinator { } } } + + /// A rename is a schema mutation, so it goes through the same gate as every other one. The + /// menu only hides the item under read-only safe mode; the Alert and Touch ID levels are the + /// gate's to enforce, and the audit record is written from here too. + /// + /// No SQL travels with the request because the driver runs the rename rather than generating a + /// statement, and for MongoDB and SQL Server there is no statement to show. The two names are + /// what the user is being asked to approve, and the description carries both. + private func authorizeRename(describing description: String) async -> Bool { + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: connection.type, + sql: nil, + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: description + ) + ) + guard case .authorized = decision else { + if let reason = decision.deniedReason { + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: reason, + window: contentWindow + ) + } + return false + } + return true + } + + private func presentRenameFailure(_ error: Error, object: String = "") { + renameLogger.error( + "Rename failed for \(object, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: error.localizedDescription, + window: contentWindow + ) + } + + private func qualifiedLabel(_ ref: DatabaseTreeTableRef) -> String { + guard let schema = ref.qualifyingSchema else { return ref.table.name } + return "\(schema).\(ref.table.name)" + } } /// The `DROP` and `ALTER` keyword for an object kind, in one place because the rename and the drop diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift index 249f667efe..3bcfabbfb6 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift @@ -27,19 +27,39 @@ extension MainContentCoordinator { unstagePendingOperations(for: ref) } + /// A container's new name has to reach everything keyed by its old one, or the next page, save + /// or reconnect targets something that is gone. A schema is not exempt: its tabs, its queued + /// operations and its per-table settings are all keyed by it too. func adoptContainerRename(_ ref: DatabaseContainerRef, to newName: String) { - guard ref.kind == .database, let oldDatabase = ref.database else { return } - SharedSidebarState.forConnection(connectionId) - .renameRecentDatabase(from: oldDatabase, to: newName) - FavoriteDatabasesStorage.shared.rename( - database: oldDatabase, to: newName, connectionId: connectionId - ) + switch ref.kind { + case .database: + guard let oldDatabase = ref.database else { return } + retargetContainer(database: oldDatabase, schema: nil, toDatabase: newName, toSchema: nil) + SharedSidebarState.forConnection(connectionId) + .renameRecentDatabase(from: oldDatabase, to: newName) + FavoriteDatabasesStorage.shared.rename( + database: oldDatabase, to: newName, connectionId: connectionId + ) + retargetDatabaseFilter(from: oldDatabase, to: newName) + retargetSavedConnectionDatabase(from: oldDatabase, to: newName) + retargetBrowseCursor(from: oldDatabase, to: newName) + case .schema: + guard let oldSchema = ref.schema else { return } + let database = ref.database ?? browseDatabaseName + retargetContainer( + database: database, schema: oldSchema, toDatabase: database, toSchema: newName + ) + SharedSidebarState.forConnection(connectionId) + .renameRecentSchema(database: database, from: oldSchema, to: newName) + } } private func retitleTabs(matching identity: TableTabIdentity, to newName: String) { let browseDatabase = browseDatabaseName + var renamedSelectedTab = false for index in tabManager.tabs.indices where tabManager.tabs[index].tableIdentity(browsing: browseDatabase) == identity { + if tabManager.tabs[index].id == tabManager.selectedTabId { renamedSelectedTab = true } tabManager.mutate(at: index) { tab in tab.tableContext.tableName = newName tab.title = newName @@ -49,11 +69,12 @@ extension MainContentCoordinator { /// run against a name the server no longer has. rebuildTableQuery(at: index) } - /// The change manager serves the whole window and holds the name its statements target, - /// so a save started after the rename would still write to the old one. - if changeManager.tableName == identity.table { - changeManager.tableName = newName - } + /// One change manager serves the whole window and holds the name its statements target, so + /// a save started after the rename would still write to the old one. It moves only when the + /// tab it is serving is one of the tabs that was renamed: comparing its bare name would + /// point `public.orders`'s pending edits at whatever `billing.orders` just became. + guard renamedSelectedTab else { return } + changeManager.tableName = newName } private func movePerTableSettings( @@ -98,6 +119,116 @@ extension MainContentCoordinator { ) } + // MARK: - Containers + + private func retargetContainer( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + retargetTabs(database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema) + retargetPendingOperations( + database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema + ) + FilterSettingsStorage.shared.renameScope( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) + FileColumnLayoutPersister.shared.renameScope( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) + retargetFavoriteTables( + database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema + ) + } + + private func retargetTabs(database: String, schema: String?, toDatabase: String, toSchema: String?) { + let browseDatabase = browseDatabaseName + for index in tabManager.tabs.indices { + let context = tabManager.tabs[index].tableContext + guard context.resolvedDatabaseName(browsing: browseDatabase) == database else { continue } + if let schema, context.schemaName != schema { continue } + tabManager.mutate(at: index) { tab in + tab.tableContext.databaseName = toDatabase + if schema != nil { tab.tableContext.schemaName = toSchema } + } + rebuildTableQuery(at: index) + } + } + + /// A queued Truncate or Drop names the container it was raised in, so one left behind either + /// misses at Save or, once something takes the old name, reaches the wrong object. + private func retargetPendingOperations( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + guard let viewModel = sidebarViewModel else { return } + func moved(_ ref: DatabaseTreeTableRef) -> DatabaseTreeTableRef { + guard ref.database == database else { return ref } + if let schema, ref.qualifyingSchema != schema { return ref } + return DatabaseTreeTableRef( + database: toDatabase, + schema: schema == nil ? ref.schema : toSchema, + table: ref.table + ) + } + let options = viewModel.tableOperationOptions + var movedOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] + for (ref, value) in options { movedOptions[moved(ref)] = value } + viewModel.pendingTruncates = Set(viewModel.pendingTruncates.map(moved)) + viewModel.pendingDeletes = Set(viewModel.pendingDeletes.map(moved)) + viewModel.tableOperationOptions = movedOptions + } + + private func retargetFavoriteTables( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + let storage = FavoriteTablesStorage.shared + for entry in storage.favorites(for: connectionId) where entry.database == database { + if let schema, entry.schema != schema { continue } + storage.removeFavorite( + name: entry.name, schema: entry.schema, database: entry.database, connectionId: connectionId + ) + storage.addFavorite( + name: entry.name, + schema: schema == nil ? entry.schema : toSchema, + database: toDatabase, + connectionId: connectionId + ) + } + } + + private func retargetDatabaseFilter(from oldName: String, to newName: String) { + let state = SharedSidebarState.forConnection(connectionId) + var selected = state.databaseFilterSelected + guard selected.remove(oldName) != nil else { return } + selected.insert(newName) + state.databaseFilterSelected = selected + } + + /// The connection's saved default is what a reconnect and Reopen Last Session both use, so a + /// database renamed out from under it leaves the connection opening onto nothing. + private func retargetSavedConnectionDatabase(from oldName: String, to newName: String) { + guard connection.database == oldName else { return } + var updated = connection + updated.database = newName + ConnectionStorage.shared.updateConnection(updated) + } + + /// Only reachable when another window is browsing the renamed database, because the menu keeps + /// Rename off the container this window is on. + private func retargetBrowseCursor(from oldName: String, to newName: String) { + guard browseDatabaseName == oldName else { return } + Task { await switchContainers(database: newName, schema: nil) } + } + /// A queued Truncate or Drop against the old name would either miss or, once a new table takes /// that name, reach the wrong object. The queue is dropped rather than moved, because the /// confirmation the user gave named the object they were looking at. diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 11ff5071ee..450791e61f 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -68,9 +68,9 @@ extension DatabaseTreeOutlineCoordinator { activateThen(ref) { [weak self] in self?.viewModel?.batchToggleDelete(refs: targets) } - case .beginRenameTable(let ref): + case .beginRenameTable(let ref, let isRecentRow): activateThen(ref) { [weak self] in - self?.beginRename(.table(ref)) + self?.beginRename(.table(ref), isRecentRow: isRecentRow) } case .renameContainer(let ref): beginRename(.container(ref)) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 620a007aaf..b9dcc2bc80 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -57,6 +57,7 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { activeDatabase: activeDatabase, activeSchema: activeSchema, supportsRenameTable: PluginManager.shared.supportsRenameTable(for: databaseType), + supportsRenameView: PluginManager.shared.supportsRenameView(for: databaseType), supportsRenameDatabase: PluginManager.shared.supportsRenameDatabase(for: databaseType), supportsRenameSchema: PluginManager.shared.supportsRenameSchema(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift index a49dd809fc..904deb4bd0 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift @@ -10,7 +10,10 @@ import AppKit /// The `NSTextFieldDelegate` conformance itself lives on the main declaration, because the /// callbacks are `@objc` and reach the coordinator rather than this extension. extension DatabaseTreeOutlineCoordinator { - internal func beginRename(_ target: DatabaseTreeRenameSession.Target) { + /// `isRecentRow` is the clicked row, not the object. A table is drawn twice when it is also in + /// Recent, and editing the section row instead would put the field on a row the user did not + /// click, or on no row at all while that section is collapsed. + internal func beginRename(_ target: DatabaseTreeRenameSession.Target, isRecentRow: Bool = false) { guard let outlineView else { return } endRename(commit: false) @@ -18,7 +21,7 @@ extension DatabaseTreeOutlineCoordinator { let name: String switch target { case .table(let ref): - nodeId = DatabaseTreeNode.tableId(ref) + nodeId = isRecentRow ? DatabaseTreeNode.recentTableId(ref) : DatabaseTreeNode.tableId(ref) name = ref.table.name case .container(let ref): nodeId = ref.kind == .schema diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index dcb8add862..e6f98ebc96 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -61,7 +61,7 @@ internal enum DatabaseTreeMenuSpec { guard let clicked = context.clicked else { return backgroundItems(context) } switch clicked { case .recentTable(let ref): - return tableItems(ref, context: context) + [ + return tableItems(ref, context: context, isRecentRow: true) + [ .separator, .command(String(localized: "Remove from Recent"), .removeRecent(ref)), .command(String(localized: "Clear Recent Tables"), .clearRecents) @@ -84,7 +84,7 @@ internal enum DatabaseTreeMenuSpec { case .containerObjectKindSection(let group): return [.command(String(localized: "Refresh"), .refreshContainerObjectKind(group))] case .hierarchicalSchemaSection(let schema): - return [.command(String(localized: "Refresh"), .refreshHierarchicalSchema(schema))] + return hierarchicalSchemaItems(schema, context: context) case .redisNode(let node): return redisItems(node) case .status, .recentSection, .redisKeysSection: @@ -96,7 +96,8 @@ internal enum DatabaseTreeMenuSpec { private static func tableItems( _ ref: DatabaseTreeTableRef, - context: DatabaseTreeMenuContext + context: DatabaseTreeMenuContext, + isRecentRow: Bool = false ) -> [DatabaseTreeMenuItem] { /// 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 @@ -146,7 +147,7 @@ internal enum DatabaseTreeMenuSpec { guard !context.isReadOnly else { return items } items.append(.separator) if ObjectRenameEligibility.canRename(table: ref.table, context: context.renameEligibility) { - items.append(.command(String(localized: "Rename"), .beginRenameTable(ref))) + items.append(.command(String(localized: "Rename"), .beginRenameTable(ref: ref, isRecentRow: isRecentRow))) } items.append(.command(String(localized: "Create New View…"), .createView)) if SidebarContextMenuLogic.truncateVisible(clickedTable: ref.table) { @@ -226,6 +227,28 @@ internal enum DatabaseTreeMenuSpec { return items } + /// An engine whose tree hangs tables off schemas draws no database rows at all, so its schemas + /// arrive here rather than as `.schema`. Without this the rename an engine declares and + /// implements is unreachable on Snowflake and Trino, which are the two that do. + private static func hierarchicalSchemaItems( + _ schema: String, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuItem] { + var items: [DatabaseTreeMenuItem] = [ + .command(String(localized: "Refresh"), .refreshHierarchicalSchema(schema)) + ] + let ref = DatabaseContainerRef.schema( + database: context.activeDatabase, + schema: schema, + isSystem: context.systemSchemas.contains(schema) + ) + guard let renameable = ObjectRenameEligibility.renameable([ref], context: context.renameEligibility) + else { return items } + items.append(.separator) + items.append(.command(renameTitle(for: renameable, context: context), .renameContainer(renameable))) + return items + } + // MARK: - Containers private static func containerItems( diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 42df57a0a1..66e7013f20 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -36,7 +36,7 @@ internal enum SidebarMenuCommand: Equatable { /// Renaming runs at once rather than joining the queue, because the row's label is what the /// user edits: a queued rename would leave the tree showing a name the server does not have, /// and every later command on that row would name an object that does not exist. - case beginRenameTable(DatabaseTreeTableRef) + case beginRenameTable(ref: DatabaseTreeTableRef, isRecentRow: Bool) case renameContainer(DatabaseContainerRef) case toggleFavorite(DatabaseTreeTableRef) case removeRecent(DatabaseTreeTableRef) diff --git a/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift index 0e0a9181ff..ec34a5bebb 100644 --- a/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift +++ b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift @@ -13,6 +13,7 @@ struct ObjectRenameEligibilityTests { activeDatabase: String? = "app", activeSchema: String? = "public", table: Bool = true, + view: Bool = true, database: Bool = true, schema: Bool = true, isReadOnly: Bool = false @@ -21,6 +22,7 @@ struct ObjectRenameEligibilityTests { activeDatabase: activeDatabase, activeSchema: activeSchema, supportsRenameTable: table, + supportsRenameView: view, supportsRenameDatabase: database, supportsRenameSchema: schema, isReadOnly: isReadOnly @@ -54,11 +56,21 @@ struct ObjectRenameEligibilityTests { #expect(!ObjectRenameEligibility.canRename(table: table("pg_stats", type: .systemTable), context: context())) } - @Test("A view is renameable where a table is") + @Test("A view is renameable where the engine renames one") func viewIsRenameable() { #expect(ObjectRenameEligibility.canRename(table: table("active_users", type: .view), context: context())) } + /// SQLite's one rename statement refuses a view, and the engines built on it inherit that. + /// Offering the item there guarantees a failure alert for something the menu promised. + @Test("An engine that renames tables but not views omits it on a view") + func viewIsNotRenameableWhereTheEngineRefusesOne() { + #expect(!ObjectRenameEligibility.canRename( + table: table("active_users", type: .view), context: context(view: false) + )) + #expect(ObjectRenameEligibility.canRename(table: table("orders"), context: context(view: false))) + } + // MARK: - Containers @Test("A database the connection is not on is renameable") diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 61b4edd4f2..2ef6484fd3 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -56,6 +56,7 @@ struct DatabaseTreeMenuSpecTests { activeDatabase: activeDatabase, activeSchema: activeSchema, supportsRenameTable: supportsRename, + supportsRenameView: supportsRename, supportsRenameDatabase: supportsRename, supportsRenameSchema: supportsRename, isReadOnly: isReadOnly @@ -277,7 +278,7 @@ struct DatabaseTreeMenuSpecTests { let clicked = tableRef("orders") let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked)))) - #expect(issued.contains(.beginRenameTable(clicked))) + #expect(issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) } /// No ellipsis, because it opens the row's own field rather than a sheet. Finder spells its @@ -299,7 +300,7 @@ struct DatabaseTreeMenuSpecTests { for: context(clicked: .table(clicked), supportsRename: false) )) - #expect(!issued.contains(.beginRenameTable(clicked))) + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) } @Test("Read-only safe mode hides Rename with the other writes") @@ -309,7 +310,41 @@ struct DatabaseTreeMenuSpecTests { for: context(clicked: .table(clicked), isReadOnly: true) )) - #expect(!issued.contains(.beginRenameTable(clicked))) + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + /// A table drawn twice, once in its section and once under Recent, is one object with two + /// rows. The rename editor belongs on the row that was clicked; opening it on the section row + /// puts the field somewhere the user did not click, or nowhere while that section is collapsed. + @Test("Rename from a Recent row says so, so the editor lands on the clicked row") + func renameFromARecentRowCarriesThatRow() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .recentTable(clicked)))) + + #expect(issued.contains(.beginRenameTable(ref: clicked, isRecentRow: true))) + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + /// Snowflake and Trino hang tables off schemas and draw no database rows, so their schemas + /// arrive as a hierarchical section. Both declare and implement a schema rename, and without + /// this the command has no row to be raised from. + @Test("A hierarchical schema row offers Rename") + func hierarchicalSchemaOffersRename() { + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .hierarchicalSchemaSection(schema: "reporting")) + )) + let expected = DatabaseContainerRef.schema(database: "app", schema: "reporting", isSystem: false) + + #expect(issued.contains(.renameContainer(expected))) + } + + @Test("A hierarchical schema row omits Rename where the engine has none") + func hierarchicalSchemaWithoutRenameOmitsIt() { + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .hierarchicalSchemaSection(schema: "reporting"), supportsRename: false) + )) + + #expect(!issued.contains { if case .renameContainer = $0 { return true } else { return false } }) } @Test("The favourite item names the action it will take") diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index c65c653b0e..f113d29459 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -57,7 +57,7 @@ The statement runs at once instead of joining the drop and truncate queue, so ** | Dameng | Yes | No | No | | DuckDB | Yes | No | No | | BigQuery | Yes | No | No | -| Snowflake | Yes | Yes | Yes | +| Snowflake | Yes | No | Yes | | Trino | Depends on the connector | No | Yes | | Teradata | Yes | No | No | | MongoDB | Yes | No | No |