diff --git a/CHANGELOG.md b/CHANGELOG.md index 59efb4532..933452df5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Maintenance tools via table context menu (VACUUM, ANALYZE, OPTIMIZE, REINDEX, CHECK TABLE, etc.) + ### Fixed - Fix macOS HIG compliance: system colors, accessibility labels, theme tokens, localization diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index cc422de73..1de81547b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -605,6 +605,26 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - Maintenance + + func supportedMaintenanceOperations() -> [String]? { + ["OPTIMIZE TABLE", "ANALYZE TABLE", "CHECK TABLE", "REPAIR TABLE"] + } + + func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { + guard let table else { return nil } + let quoted = quoteIdentifier(table) + switch operation { + case "OPTIMIZE TABLE": return ["OPTIMIZE TABLE \(quoted)"] + case "ANALYZE TABLE": return ["ANALYZE TABLE \(quoted)"] + case "CHECK TABLE": + let mode = options["mode"] ?? "MEDIUM" + return ["CHECK TABLE \(quoted) \(mode)"] + case "REPAIR TABLE": return ["REPAIR TABLE \(quoted)"] + default: return nil + } + } + // MARK: - Create Table DDL func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 6d0a893e1..55f9a555b 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -166,6 +166,33 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - Maintenance + + func supportedMaintenanceOperations() -> [String]? { + ["VACUUM", "ANALYZE", "REINDEX", "CLUSTER"] + } + + func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { + let target = table.map { quoteIdentifier($0) } + switch operation { + case "VACUUM": + var opts: [String] = [] + if options["full"] == "true" { opts.append("FULL") } + if options["analyze"] == "true" { opts.append("ANALYZE") } + if options["verbose"] == "true" { opts.append("VERBOSE") } + let optClause = opts.isEmpty ? "" : "(\(opts.joined(separator: ", "))) " + return [target.map { "VACUUM \(optClause)\($0)" } ?? "VACUUM"] + case "ANALYZE": + return [target.map { "ANALYZE \($0)" } ?? "ANALYZE"] + case "REINDEX": + return [target.map { "REINDEX TABLE \($0)" } ?? "REINDEX DATABASE CONCURRENTLY"] + case "CLUSTER": + return target.map { ["CLUSTER \($0)"] } + default: + return nil + } + } + // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 3f5e48868..d3db2e5e3 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -432,6 +432,22 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN QUERY PLAN \(sql)" } + // MARK: - Maintenance + + func supportedMaintenanceOperations() -> [String]? { + ["VACUUM", "ANALYZE", "REINDEX", "Integrity Check"] + } + + func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { + switch operation { + case "VACUUM": return ["VACUUM"] + case "ANALYZE": return table.map { ["ANALYZE \(quoteIdentifier($0))"] } ?? ["ANALYZE"] + case "REINDEX": return table.map { ["REINDEX \(quoteIdentifier($0))"] } ?? ["REINDEX"] + case "Integrity Check": return ["PRAGMA integrity_check"] + default: return nil + } + } + // MARK: - View Templates func createViewTemplate() -> String? { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index ff907be02..783b172c8 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -112,6 +112,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func foreignKeyDisableStatements() -> [String]? func foreignKeyEnableStatements() -> [String]? + // Maintenance operations (optional — return nil if not supported) + func supportedMaintenanceOperations() -> [String]? + func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? + // EXPLAIN query building (optional) func buildExplainQuery(_ sql: String) -> String? @@ -244,6 +248,9 @@ public extension PluginDatabaseDriver { func foreignKeyDisableStatements() -> [String]? { nil } func foreignKeyEnableStatements() -> [String]? { nil } + func supportedMaintenanceOperations() -> [String]? { nil } + func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { nil } + func buildExplainQuery(_ sql: String) -> String? { nil } func createViewTemplate() -> String? { nil } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 527414859..d452d4698 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -121,6 +121,15 @@ protocol DatabaseDriver: AnyObject { /// Create a new database func createDatabase(name: String, charset: String, collation: String?) async throws + // MARK: - Maintenance + + /// Returns the list of supported maintenance operations (e.g. "VACUUM", "ANALYZE"). + /// Returns nil if maintenance is not supported. + func supportedMaintenanceOperations() -> [String]? + + /// Generates SQL statements for a maintenance operation. + func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? + // MARK: - Query Cancellation /// Cancel the currently running query, if any. @@ -309,6 +318,9 @@ extension DatabaseDriver { func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func supportedMaintenanceOperations() -> [String]? { nil } + func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? { nil } + /// Default: no schema support (MySQL/SQLite don't use schemas in the same way) func fetchSchemas() async throws -> [String] { [] } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 19a9eb894..17b7d924e 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -395,6 +395,16 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.foreignKeyEnableStatements() } + // MARK: - Maintenance Operations + + func supportedMaintenanceOperations() -> [String]? { + pluginDriver.supportedMaintenanceOperations() + } + + func maintenanceStatements(operation: String, table: String?, options: [String: String]) -> [String]? { + pluginDriver.maintenanceStatements(operation: operation, table: table, schema: pluginDriver.currentSchema, options: options) + } + // MARK: - All Tables Metadata SQL func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index cbea44ec9..f5c9fb03a 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -140,4 +140,44 @@ extension MainContentCoordinator { self?.activeSheet = .importDialog } } + + // MARK: - Maintenance + + func supportedMaintenanceOperations() -> [String] { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return [] } + return driver.supportedMaintenanceOperations() ?? [] + } + + func showMaintenanceSheet(operation: String, tableName: String) { + activeSheet = .maintenance(operation: operation, tableName: tableName) + } + + func executeMaintenance(operation: String, tableName: String, options: [String: String]) { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + guard let statements = driver.maintenanceStatements( + operation: operation, table: tableName, options: options + ) else { return } + + Task { @MainActor [weak self] in + guard let self else { return } + do { + var lastResult: QueryResult? + for sql in statements { + lastResult = try await driver.execute(query: sql) + } + await AlertHelper.showInfoSheet( + title: String(format: String(localized: "%@ completed"), operation), + message: lastResult?.statusMessage + ?? String(format: String(localized: "%@ on %@ completed successfully."), operation, tableName), + window: self.contentWindow + ) + } catch { + await AlertHelper.showErrorSheet( + title: String(format: String(localized: "%@ failed"), operation), + message: error.localizedDescription, + window: self.contentWindow + ) + } + } + } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 93644099b..69128b88e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -37,8 +37,18 @@ enum ActiveSheet: Identifiable { case importDialog case quickSwitcher case exportQueryResults - - var id: Self { self } + case maintenance(operation: String, tableName: String) + + var id: String { + switch self { + case .databaseSwitcher: "databaseSwitcher" + case .exportDialog: "exportDialog" + case .importDialog: "importDialog" + case .quickSwitcher: "quickSwitcher" + case .exportQueryResults: "exportQueryResults" + case .maintenance: "maintenance" + } + } } /// Coordinator managing MainContentView business logic diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 9c549a939..e2af02106 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -182,6 +182,13 @@ struct MainContentView: View { coordinator.handleQuickSwitcherSelection(item) } ) + case .maintenance(let operation, let tableName): + MaintenanceSheet( + operation: operation, + tableName: tableName, + databaseType: connection.type, + onExecute: coordinator.executeMaintenance + ) } } diff --git a/TablePro/Views/Sidebar/MaintenanceSheet.swift b/TablePro/Views/Sidebar/MaintenanceSheet.swift new file mode 100644 index 000000000..5dd6ed4cf --- /dev/null +++ b/TablePro/Views/Sidebar/MaintenanceSheet.swift @@ -0,0 +1,129 @@ +// +// MaintenanceSheet.swift +// TablePro +// +// Confirmation sheet for database maintenance operations +// (VACUUM, ANALYZE, OPTIMIZE, REINDEX, etc.) +// + +import SwiftUI + +struct MaintenanceSheet: View { + @Environment(\.dismiss) private var dismiss + + let operation: String + let tableName: String + let databaseType: DatabaseType + let onExecute: (String, String, [String: String]) -> Void + + @State private var fullVacuum = false + @State private var analyzeAfterVacuum = false + @State private var verbose = false + @State private var checkMode = "MEDIUM" + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + // Header + HStack { + Image(systemName: "wrench.and.screwdriver") + .font(.title2) + .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 2) { + Text(operation) + .font(.headline) + Text(tableName) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + } + + Divider() + + // Operation-specific options + operationOptions + + // SQL preview + VStack(alignment: .leading, spacing: 4) { + Text(String(localized: "SQL Preview")) + .font(.caption) + .foregroundStyle(.secondary) + Text(sqlPreview) + .font(.system(.body, design: .monospaced)) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: ThemeEngine.shared.activeTheme.cornerRadius.small)) + } + + Divider() + + // Buttons + HStack { + Spacer() + Button(String(localized: "Cancel")) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Execute")) { + onExecute(operation, tableName, buildOptions()) + dismiss() + } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + } + } + .padding(20) + .frame(width: 420) + } + + // MARK: - Options + + @ViewBuilder + private var operationOptions: some View { + switch operation { + case "VACUUM" where databaseType == .postgresql || databaseType == .redshift: + Toggle(String(localized: "FULL (rewrites entire table, blocks access)"), isOn: $fullVacuum) + Toggle(String(localized: "ANALYZE (update statistics after vacuum)"), isOn: $analyzeAfterVacuum) + Toggle(String(localized: "VERBOSE (print progress)"), isOn: $verbose) + case "CHECK TABLE": + Picker(String(localized: "Check mode:"), selection: $checkMode) { + Text("QUICK").tag("QUICK") + Text("FAST").tag("FAST") + Text("MEDIUM").tag("MEDIUM") + Text("EXTENDED").tag("EXTENDED") + Text("CHANGED").tag("CHANGED") + } + .pickerStyle(.menu) + .frame(width: 200) + default: + EmptyView() + } + } + + // MARK: - SQL Preview + + private var sqlPreview: String { + let options = buildOptions() + switch operation { + case "VACUUM" where databaseType == .postgresql || databaseType == .redshift: + var opts: [String] = [] + if options["full"] == "true" { opts.append("FULL") } + if options["analyze"] == "true" { opts.append("ANALYZE") } + if options["verbose"] == "true" { opts.append("VERBOSE") } + let optClause = opts.isEmpty ? "" : "(\(opts.joined(separator: ", "))) " + return "VACUUM \(optClause)\(tableName)" + case "CHECK TABLE": + return "CHECK TABLE \(tableName) \(checkMode)" + default: + return "\(operation) \(tableName)" + } + } + + private func buildOptions() -> [String: String] { + var options: [String: String] = [:] + if fullVacuum { options["full"] = "true" } + if analyzeAfterVacuum { options["analyze"] = "true" } + if verbose { options["verbose"] = "true" } + if operation == "CHECK TABLE" { options["mode"] = checkMode } + return options + } +} diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index a59024241..89bf8cc56 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -111,6 +111,19 @@ struct SidebarContextMenu: View { .disabled(isReadOnly) } + if let ops = coordinator?.supportedMaintenanceOperations(), !ops.isEmpty, hasSelection { + Menu(String(localized: "Maintenance")) { + ForEach(ops, id: \.self) { op in + Button(op) { + if let table = clickedTable?.name { + coordinator?.showMaintenanceSheet(operation: op, tableName: table) + } + } + } + } + .disabled(isReadOnly) + } + Divider() if !isView { diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index b2f79a122..ff64ac7d0 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -1,11 +1,11 @@ --- title: Table Operations -description: Drop, truncate, duplicate tables, create views, and switch databases from the sidebar +description: Drop, truncate, maintenance, create views, and switch databases from the sidebar --- # Table Operations -Right-click tables in the sidebar to drop, truncate, or duplicate them. Create and manage views. Switch between databases on the same connection. +Right-click tables in the sidebar to drop, truncate, run maintenance, or manage views. Switch between databases on the same connection. ## Drop Table @@ -63,6 +63,24 @@ Truncate is faster than `DELETE FROM table` because it skips per-row delete logs /> +## Maintenance + +Right-click a table > **Maintenance** to run database maintenance operations. A confirmation sheet shows operation options and a SQL preview before executing. + +| Database | Operations | +|----------|-----------| +| PostgreSQL | VACUUM, ANALYZE, REINDEX, CLUSTER | +| MySQL/MariaDB | OPTIMIZE TABLE, ANALYZE TABLE, CHECK TABLE, REPAIR TABLE | +| SQLite | VACUUM, ANALYZE, REINDEX, Integrity Check | + +PostgreSQL VACUUM has options for FULL (rewrites the table, blocks access), ANALYZE (updates statistics), and VERBOSE (prints progress). + +MySQL CHECK TABLE supports check modes: QUICK, FAST, MEDIUM, EXTENDED, CHANGED. + + +Maintenance is disabled in read-only safe mode. Databases without maintenance support (Redis, MongoDB, etc.) do not show this submenu. + + ## Batch Operations Hold `Cmd` and click to select multiple tables, then right-click and choose **Delete** or **Truncate**. The same options apply to all selected tables.