Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
27 changes: 27 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
16 changes: 16 additions & 0 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
7 changes: 7 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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 }
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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] { [] }

Expand Down
10 changes: 10 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
}
}
14 changes: 12 additions & 2 deletions TablePro/Views/Main/MainContentCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Views/Main/MainContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}

Expand Down
129 changes: 129 additions & 0 deletions TablePro/Views/Sidebar/MaintenanceSheet.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
13 changes: 13 additions & 0 deletions TablePro/Views/Sidebar/SidebarContextMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading