diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index fcd265419..42df53271 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -157,7 +157,9 @@ struct ContentView: View { showAllTablesMetadata() }, pendingTruncates: sessionPendingTruncatesBinding, - pendingDeletes: sessionPendingDeletesBinding + pendingDeletes: sessionPendingDeletesBinding, + tableOperationOptions: sessionTableOperationOptionsBinding, + databaseType: currentSession?.connection.type ?? .sqlite ) } .navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 350) @@ -169,6 +171,7 @@ struct ContentView: View { selectedTables: sessionSelectedTablesBinding, pendingTruncates: sessionPendingTruncatesBinding, pendingDeletes: sessionPendingDeletesBinding, + tableOperationOptions: sessionTableOperationOptionsBinding, isInspectorPresented: $isInspectorPresented ) .id(currentSession!.id) @@ -249,6 +252,14 @@ struct ContentView: View { ) } + private var sessionTableOperationOptionsBinding: Binding<[String: TableOperationOptions]> { + createSessionBinding( + get: { $0.tableOperationOptions }, + set: { $0.tableOperationOptions = $1 }, + defaultValue: [:] + ) + } + // MARK: - Actions private func connectToDatabase(_ connection: DatabaseConnection) { diff --git a/TablePro/Models/ConnectionSession.swift b/TablePro/Models/ConnectionSession.swift index f277850aa..1074538be 100644 --- a/TablePro/Models/ConnectionSession.swift +++ b/TablePro/Models/ConnectionSession.swift @@ -22,6 +22,7 @@ struct ConnectionSession: Identifiable { var selectedTabId: UUID? var pendingTruncates: Set = [] var pendingDeletes: Set = [] + var tableOperationOptions: [String: TableOperationOptions] = [:] // Metadata let connectedAt: Date diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index d084eff30..8e52ca86c 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -92,10 +92,13 @@ enum DatabaseType: String, CaseIterable, Identifiable, Codable { } } - /// Quote an identifier (table or column name) for this database type + /// Quote an identifier (table or column name) for this database type. + /// Escapes embedded quote characters to prevent SQL injection. func quoteIdentifier(_ name: String) -> String { let q = identifierQuote - return "\(q)\(name)\(q)" + // Escape embedded quotes by doubling them (SQL standard) + let escaped = name.replacingOccurrences(of: q, with: q + q) + return "\(q)\(escaped)\(q)" } } diff --git a/TablePro/Models/TableOperationOptions.swift b/TablePro/Models/TableOperationOptions.swift new file mode 100644 index 000000000..0b6d51f41 --- /dev/null +++ b/TablePro/Models/TableOperationOptions.swift @@ -0,0 +1,21 @@ +// +// TableOperationOptions.swift +// TablePro +// +// Model for table delete/truncate operation options. +// Supports foreign key constraint handling and cascade operations. +// + +import Foundation + +/// Options for table delete/truncate operations +struct TableOperationOptions: Codable, Equatable { + var ignoreForeignKeys: Bool = false + var cascade: Bool = false +} + +/// Type of table operation +enum TableOperationType: String, Codable { + case truncate + case drop +} diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 6942c9fbc..1cc35a321 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -554,7 +554,8 @@ final class MainContentCoordinator: ObservableObject { func saveChanges( pendingTruncates: inout Set, - pendingDeletes: inout Set + pendingDeletes: inout Set, + tableOperationOptions: inout [String: TableOperationOptions] ) { let hasEditedCells = changeManager.hasChanges let hasPendingTableOps = !pendingTruncates.isEmpty || !pendingDeletes.isEmpty @@ -562,20 +563,48 @@ final class MainContentCoordinator: ObservableObject { guard hasEditedCells || hasPendingTableOps else { return } var allStatements: [String] = [] + let dbType = connection.type + + // Check if any table operation needs FK disabled (must be outside transaction) + let needsDisableFK = dbType != .postgresql && pendingTruncates.union(pendingDeletes).contains { tableName in + tableOperationOptions[tableName]?.ignoreForeignKeys == true + } + + // FK disable must be FIRST, before any transaction begins + if needsDisableFK { + allStatements.append(contentsOf: fkDisableStatements(for: dbType)) + } + + // Wrap all operations in a single transaction when we have multiple operations + let needsTransaction = hasEditedCells && hasPendingTableOps + if needsTransaction { + allStatements.append("BEGIN") + } if hasEditedCells { + // changeManager.generateSQL() does NOT include transaction statements allStatements.append(contentsOf: changeManager.generateSQL()) } if hasPendingTableOps { - for tableName in pendingTruncates { - let quotedName = connection.type.quoteIdentifier(tableName) - allStatements.append("TRUNCATE TABLE \(quotedName)") - } - for tableName in pendingDeletes { - let quotedName = connection.type.quoteIdentifier(tableName) - allStatements.append("DROP TABLE \(quotedName)") - } + // Generate table operation SQL WITHOUT FK handling (already done above) + let tableOpStatements = generateTableOperationSQL( + truncates: pendingTruncates, + deletes: pendingDeletes, + options: tableOperationOptions, + wrapInTransaction: !needsTransaction, + includeFKHandling: false // FK handling done at this level + ) + allStatements.append(contentsOf: tableOpStatements) + } + + if needsTransaction { + allStatements.append("COMMIT") + } + + // FK re-enable must be LAST, after transaction commits + if needsDisableFK { + allStatements.append(contentsOf: fkEnableStatements(for: dbType)) } guard !allStatements.isEmpty else { @@ -585,20 +614,187 @@ final class MainContentCoordinator: ObservableObject { return } - let sql = allStatements.joined(separator: ";\n") - executeCommitSQL(sql, clearTableOps: hasPendingTableOps, pendingTruncates: &pendingTruncates, pendingDeletes: &pendingDeletes) + // Pass statements as array to avoid SQL injection via semicolon splitting + executeCommitStatements( + allStatements, + clearTableOps: hasPendingTableOps, + pendingTruncates: &pendingTruncates, + pendingDeletes: &pendingDeletes, + tableOperationOptions: &tableOperationOptions + ) + } + + /// Generates SQL statements for table truncate/drop operations. + /// - Parameters: + /// - truncates: Set of table names to truncate + /// - deletes: Set of table names to drop + /// - options: Per-table options for FK and cascade handling + /// - wrapInTransaction: Whether to wrap statements in BEGIN/COMMIT + /// - includeFKHandling: Whether to include FK disable/enable statements (set false when caller handles FK) + /// - Returns: Array of SQL statements to execute + private func generateTableOperationSQL( + truncates: Set, + deletes: Set, + options: [String: TableOperationOptions], + wrapInTransaction: Bool = true, + includeFKHandling: Bool = true + ) -> [String] { + var statements: [String] = [] + let dbType = connection.type + + // Sort tables for consistent execution order + let sortedTruncates = truncates.sorted() + let sortedDeletes = deletes.sorted() + + // Check if any operation needs FK disabled (not applicable to PostgreSQL) + let needsDisableFK = includeFKHandling && dbType != .postgresql && truncates.union(deletes).contains { tableName in + options[tableName]?.ignoreForeignKeys == true + } + + // FK disable must be OUTSIDE transaction to ensure it takes effect even on rollback + if needsDisableFK { + statements.append(contentsOf: fkDisableStatements(for: dbType)) + } + + // Wrap in transaction for atomicity + let needsTransaction = wrapInTransaction && (sortedTruncates.count + sortedDeletes.count) > 1 + if needsTransaction { + statements.append("BEGIN") + } + + for tableName in sortedTruncates { + let quotedName = dbType.quoteIdentifier(tableName) + let tableOptions = options[tableName] ?? TableOperationOptions() + statements.append(contentsOf: truncateStatements(tableName: tableName, quotedName: quotedName, options: tableOptions, dbType: dbType)) + } + + for tableName in sortedDeletes { + let quotedName = dbType.quoteIdentifier(tableName) + let tableOptions = options[tableName] ?? TableOperationOptions() + statements.append(dropTableStatement(quotedName: quotedName, options: tableOptions, dbType: dbType)) + } + + if needsTransaction { + statements.append("COMMIT") + } + + // FK re-enable must be OUTSIDE transaction to ensure it runs even on rollback + if needsDisableFK { + statements.append(contentsOf: fkEnableStatements(for: dbType)) + } + + return statements + } + + /// Returns SQL statements to disable foreign key checks for the database type. + /// - Note: PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead. + private func fkDisableStatements(for dbType: DatabaseType) -> [String] { + switch dbType { + case .mysql, .mariadb: + return ["SET FOREIGN_KEY_CHECKS=0"] + case .postgresql: + // PostgreSQL doesn't support globally disabling non-deferrable FKs. + // Use CASCADE option for reliable FK handling. + return [] + case .sqlite: + return ["PRAGMA foreign_keys = OFF"] + } + } + + /// Returns SQL statements to re-enable foreign key checks for the database type. + private func fkEnableStatements(for dbType: DatabaseType) -> [String] { + switch dbType { + case .mysql, .mariadb: + return ["SET FOREIGN_KEY_CHECKS=1"] + case .postgresql: + return [] + case .sqlite: + return ["PRAGMA foreign_keys = ON"] + } } - private func executeCommitSQL( - _ sql: String, + /// Generates TRUNCATE/DELETE statements for a table. + /// - Note: SQLite uses DELETE and resets auto-increment via sqlite_sequence. + private func truncateStatements(tableName: String, quotedName: String, options: TableOperationOptions, dbType: DatabaseType) -> [String] { + switch dbType { + case .mysql, .mariadb: + return ["TRUNCATE TABLE \(quotedName)"] + case .postgresql: + let cascade = options.cascade ? " CASCADE" : "" + return ["TRUNCATE TABLE \(quotedName)\(cascade)"] + case .sqlite: + // DELETE FROM + reset auto-increment counter for true TRUNCATE semantics. + // Note: quotedName uses backticks (via quoteIdentifier) for SQL identifiers, + // while escapedName uses single-quote escaping for string literals in the + // sqlite_sequence query. These are different SQL quoting mechanisms for + // different purposes (identifier vs string literal). + let escapedName = tableName.replacingOccurrences(of: "'", with: "''") + return [ + "DELETE FROM \(quotedName)", + // sqlite_sequence may not exist if no table has AUTOINCREMENT. + // This DELETE will succeed silently if the table isn't in sqlite_sequence. + "DELETE FROM sqlite_sequence WHERE name = '\(escapedName)'" + ] + } + } + + /// Generates DROP TABLE statement with optional CASCADE. + private func dropTableStatement(quotedName: String, options: TableOperationOptions, dbType: DatabaseType) -> String { + return switch dbType { + case .postgresql: + "DROP TABLE \(quotedName)\(options.cascade ? " CASCADE" : "")" + case .mysql, .mariadb, .sqlite: + "DROP TABLE \(quotedName)" + } + } + + /// Executes an array of SQL statements sequentially. + /// This approach prevents SQL injection by avoiding semicolon-based string splitting. + /// - Parameters: + /// - statements: Pre-segmented array of SQL statements to execute + /// - clearTableOps: Whether to clear pending table operations on success + /// - pendingTruncates: Inout binding to pending truncate operations (restored on failure) + /// - pendingDeletes: Inout binding to pending delete operations (restored on failure) + /// - tableOperationOptions: Inout binding to operation options (restored on failure) + private func executeCommitStatements( + _ statements: [String], clearTableOps: Bool, pendingTruncates: inout Set, - pendingDeletes: inout Set + pendingDeletes: inout Set, + tableOperationOptions: inout [String: TableOperationOptions] ) { - guard !sql.isEmpty else { return } + let validStatements = statements.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + guard !validStatements.isEmpty else { return } let deletedTables = Set(pendingDeletes) + let truncatedTables = Set(pendingTruncates) let conn = connection + let dbType = connection.type + + // Track if FK checks were disabled (need to re-enable on failure) + let fkWasDisabled = dbType != .postgresql && deletedTables.union(truncatedTables).contains { tableName in + tableOperationOptions[tableName]?.ignoreForeignKeys == true + } + + // Capture options before clearing (for potential restore on failure) + var capturedOptions: [String: TableOperationOptions] = [:] + for table in deletedTables.union(truncatedTables) { + capturedOptions[table] = tableOperationOptions[table] + } + + // Clear operations immediately (to prevent double-execution) + // Store references to restore synchronously on failure + if clearTableOps { + pendingTruncates.removeAll() + pendingDeletes.removeAll() + for table in deletedTables.union(truncatedTables) { + tableOperationOptions.removeValue(forKey: table) + } + } + + // Capture inout references for async restoration via notification + // This avoids the race condition of async updateSession + let restoreNotificationName = Notification.Name("RestorePendingTableOperations_\(conn.id)") Task { let overallStartTime = Date() @@ -613,11 +809,7 @@ final class MainContentCoordinator: ObservableObject { throw DatabaseError.notConnected } - let statements = sql.components(separatedBy: ";").filter { - !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - for statement in statements { + for statement in validStatements { let statementStartTime = Date() _ = try await driver.execute(query: statement) let executionTime = Date().timeIntervalSince(statementStartTime) @@ -667,9 +859,21 @@ final class MainContentCoordinator: ObservableObject { } catch { let executionTime = Date().timeIntervalSince(overallStartTime) + // Try to re-enable FK checks if they were disabled + if fkWasDisabled, let driver = DatabaseManager.shared.activeDriver { + for statement in self.fkEnableStatements(for: dbType) { + do { + try await driver.execute(query: statement) + } catch { + print("Warning: Failed to re-enable foreign key checks with statement '\(statement)': \(error)") + } + } + } + await MainActor.run { + let allSQL = validStatements.joined(separator: "; ") QueryHistoryManager.shared.recordQuery( - query: sql, + query: allSQL, connectionId: conn.id, databaseName: conn.database ?? "", executionTime: executionTime, @@ -681,6 +885,29 @@ final class MainContentCoordinator: ObservableObject { if let index = tabManager.selectedTabIndex { tabManager.tabs[index].errorMessage = "Save failed: \(error.localizedDescription)" } + + // Restore operations on failure so user can retry. + // Use notification to restore via MainContentView's bindings for synchronous update. + if clearTableOps { + NotificationCenter.default.post( + name: restoreNotificationName, + object: nil, + userInfo: [ + "truncates": truncatedTables, + "deletes": deletedTables, + "options": capturedOptions + ] + ) + + // Also update session for persistence + DatabaseManager.shared.updateSession(conn.id) { session in + session.pendingTruncates = truncatedTables + session.pendingDeletes = deletedTables + for (table, opts) in capturedOptions { + session.tableOperationOptions[table] = opts + } + } + } } } } diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index d9ee21197..bf530e502 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -26,6 +26,7 @@ final class MainContentNotificationHandler: ObservableObject { private let selectedTables: Binding> private let pendingTruncates: Binding> private let pendingDeletes: Binding> + private let tableOperationOptions: Binding<[String: TableOperationOptions]> private let isInspectorPresented: Binding private let editingCell: Binding @@ -43,6 +44,7 @@ final class MainContentNotificationHandler: ObservableObject { selectedTables: Binding>, pendingTruncates: Binding>, pendingDeletes: Binding>, + tableOperationOptions: Binding<[String: TableOperationOptions]>, isInspectorPresented: Binding, editingCell: Binding ) { @@ -53,6 +55,7 @@ final class MainContentNotificationHandler: ObservableObject { self.selectedTables = selectedTables self.pendingTruncates = pendingTruncates self.pendingDeletes = pendingDeletes + self.tableOperationOptions = tableOperationOptions self.isInspectorPresented = isInspectorPresented self.editingCell = editingCell @@ -308,9 +311,15 @@ final class MainContentNotificationHandler: ObservableObject { private func handleSaveChanges() { var truncates = pendingTruncates.wrappedValue var deletes = pendingDeletes.wrappedValue - coordinator?.saveChanges(pendingTruncates: &truncates, pendingDeletes: &deletes) + var options = tableOperationOptions.wrappedValue + coordinator?.saveChanges( + pendingTruncates: &truncates, + pendingDeletes: &deletes, + tableOperationOptions: &options + ) pendingTruncates.wrappedValue = truncates pendingDeletes.wrappedValue = deletes + tableOperationOptions.wrappedValue = options } // MARK: - UI Operations diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index 493559aaf..6a862ea9b 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -21,6 +21,7 @@ struct MainContentView: View { @Binding var selectedTables: Set @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set + @Binding var tableOperationOptions: [String: TableOperationOptions] @Binding var isInspectorPresented: Bool // MARK: - State Objects @@ -49,6 +50,7 @@ struct MainContentView: View { selectedTables: Binding>, pendingTruncates: Binding>, pendingDeletes: Binding>, + tableOperationOptions: Binding<[String: TableOperationOptions]>, isInspectorPresented: Binding ) { self.connection = connection @@ -56,6 +58,7 @@ struct MainContentView: View { self._selectedTables = selectedTables self._pendingTruncates = pendingTruncates self._pendingDeletes = pendingDeletes + self._tableOperationOptions = tableOperationOptions self._isInspectorPresented = isInspectorPresented // Create state objects @@ -223,6 +226,7 @@ struct MainContentView: View { selectedTables: $selectedTables, pendingTruncates: $pendingTruncates, pendingDeletes: $pendingDeletes, + tableOperationOptions: $tableOperationOptions, isInspectorPresented: $isInspectorPresented, editingCell: $editingCell ) @@ -394,6 +398,7 @@ struct MainContentView: View { selectedTables: .constant([]), pendingTruncates: .constant([]), pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), isInspectorPresented: .constant(false) ) .frame(width: 1000, height: 600) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index bbb3cad16..72040f787 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -20,6 +20,8 @@ struct SidebarView: View { // Pending table operations @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set + @Binding var tableOperationOptions: [String: TableOperationOptions] + let databaseType: DatabaseType @State private var isLoading = false @State private var errorMessage: String? @@ -31,6 +33,11 @@ struct SidebarView: View { /// Whether the tables section is expanded @State private var isTablesExpanded = true + /// State for table operation confirmation dialog + @State private var showOperationDialog = false + @State private var pendingOperationType: TableOperationType? + @State private var pendingOperationTables: [String] = [] + /// Filtered tables based on search text private var filteredTables: [TableInfo] { guard !searchText.isEmpty else { return tables } @@ -96,6 +103,28 @@ struct SidebarView: View { } } } + .sheet(isPresented: $showOperationDialog) { + if let operationType = pendingOperationType { + let tables = pendingOperationTables + if let firstTable = tables.first { + let tableName = tables.count > 1 + ? "\(tables.count) tables" + : firstTable + TableOperationDialog( + isPresented: $showOperationDialog, + tableName: tableName, + operationType: operationType, + databaseType: databaseType, + onConfirm: { options in + confirmOperation(options: options) + } + ) + } + } + } + .onChange(of: showOperationDialog) { _, isPresented in + AppState.shared.isSheetPresented = isPresented + } } // MARK: - Search Field @@ -257,40 +286,81 @@ struct SidebarView: View { /// Batch toggle truncate for all selected tables private func batchToggleTruncate() { - var updatedDeletes = pendingDeletes - var updatedTruncates = pendingTruncates - - let tablesToToggle = selectedTables.isEmpty ? [] : selectedTables - for table in tablesToToggle { - updatedDeletes.remove(table.name) - if updatedTruncates.contains(table.name) { - updatedTruncates.remove(table.name) - } else { - updatedTruncates.insert(table.name) + let tablesToToggle = selectedTables.isEmpty ? [] : Array(selectedTables.map { $0.name }) + guard !tablesToToggle.isEmpty else { return } + + // Check if all tables are already pending truncate - if so, remove them + // Cancellation doesn't require confirmation since it's a safe operation that + // simply removes the pending state. The stored options are intentionally discarded. + 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 { + // Show dialog to confirm operation + pendingOperationType = .truncate + pendingOperationTables = tablesToToggle + showOperationDialog = true } - - pendingDeletes = updatedDeletes - pendingTruncates = updatedTruncates } - + /// Batch toggle delete for all selected tables private func batchToggleDelete() { - var updatedDeletes = pendingDeletes + let tablesToToggle = selectedTables.isEmpty ? [] : Array(selectedTables.map { $0.name }) + guard !tablesToToggle.isEmpty else { return } + + // Check if all tables are already pending delete - if so, remove them + // Cancellation doesn't require confirmation since it's a safe operation that + // simply removes the pending state. The stored options are intentionally discarded. + 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 { + // Show dialog to confirm operation + pendingOperationType = .drop + pendingOperationTables = tablesToToggle + showOperationDialog = true + } + } + + /// Confirm the pending operation with the given options + private func confirmOperation(options: TableOperationOptions) { + guard let operationType = pendingOperationType else { return } + var updatedTruncates = pendingTruncates - - let tablesToToggle = selectedTables.isEmpty ? [] : selectedTables - for table in tablesToToggle { - updatedTruncates.remove(table.name) - if updatedDeletes.contains(table.name) { - updatedDeletes.remove(table.name) + var updatedDeletes = pendingDeletes + var updatedOptions = tableOperationOptions + + for tableName in pendingOperationTables { + // Remove from opposite set if present + if operationType == .truncate { + updatedDeletes.remove(tableName) + updatedTruncates.insert(tableName) } else { - updatedDeletes.insert(table.name) + updatedTruncates.remove(tableName) + updatedDeletes.insert(tableName) } + + // Store options for this table + updatedOptions[tableName] = options } - + pendingTruncates = updatedTruncates pendingDeletes = updatedDeletes + tableOperationOptions = updatedOptions + + // Reset dialog state + pendingOperationType = nil + pendingOperationTables = [] } // MARK: - Actions @@ -371,7 +441,7 @@ struct TableRow: View { ZStack(alignment: .bottomTrailing) { Image(systemName: table.type == .view ? "eye" : "tablecells") .foregroundStyle(iconColor) - .frame(width: 20) + .frame(width: 14) // Pending operation indicator if isPendingDelete { @@ -388,7 +458,7 @@ struct TableRow: View { } Text(table.name) - .font(.system(.body, design: .monospaced)) + .font(.system(size: 12, design: .monospaced)) .lineLimit(1) .foregroundStyle(textColor) } @@ -415,7 +485,9 @@ struct TableRow: View { tables: .constant([]), selectedTables: .constant([]), pendingTruncates: .constant([]), - pendingDeletes: .constant([]) + pendingDeletes: .constant([]), + tableOperationOptions: .constant([:]), + databaseType: .mysql ) .frame(width: 250, height: 400) } diff --git a/TablePro/Views/Sidebar/TableOperationDialog.swift b/TablePro/Views/Sidebar/TableOperationDialog.swift new file mode 100644 index 000000000..f0d25458e --- /dev/null +++ b/TablePro/Views/Sidebar/TableOperationDialog.swift @@ -0,0 +1,222 @@ +// +// TableOperationDialog.swift +// TablePro +// +// Confirmation dialog for table delete/truncate operations. +// Provides options for foreign key constraint handling and cascade operations. +// + +import SwiftUI + +/// Confirmation dialog for table delete/truncate operations +struct TableOperationDialog: View { + + // MARK: - Properties + + @Binding var isPresented: Bool + let tableName: String + let operationType: TableOperationType + let databaseType: DatabaseType + let onConfirm: (TableOperationOptions) -> Void + + // MARK: - State + + @State private var ignoreForeignKeys = false + @State private var cascade = false + + // MARK: - Computed Properties + + private var title: String { + switch operationType { + case .drop: + return "Drop table '\(tableName)'" + case .truncate: + return "Truncate table '\(tableName)'" + } + } + + private var cascadeSupported: Bool { + // PostgreSQL supports CASCADE for both DROP and TRUNCATE. + // MySQL, MariaDB, and SQLite do not support CASCADE for these operations. + switch databaseType { + case .postgresql: + return true + default: + return false + } + } + + private var isMultipleTables: Bool { + tableName.contains("tables") + } + + private var cascadeDescription: String { + switch operationType { + case .drop: + return "Drop all tables that depend on this table" + case .truncate: + if databaseType == .mysql || databaseType == .mariadb { + return "Not supported for TRUNCATE in MySQL/MariaDB" + } + return "Truncate all tables linked by foreign keys" + } + } + + private var cascadeDisabled: Bool { + // MySQL/MariaDB don't support CASCADE for TRUNCATE + if operationType == .truncate && (databaseType == .mysql || databaseType == .mariadb) { + return true + } + return !cascadeSupported + } + + /// PostgreSQL doesn't support globally disabling FK checks; use CASCADE instead + private var ignoreFKDisabled: Bool { + databaseType == .postgresql + } + + private var ignoreFKDescription: String? { + if databaseType == .postgresql { + return "Not supported for PostgreSQL. Use CASCADE instead." + } + return nil + } + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + // Header + Text(title) + .font(.system(size: 13, weight: .semibold)) + .padding(.vertical, 16) + .padding(.horizontal, 20) + + Divider() + + // Options + VStack(alignment: .leading, spacing: 16) { + // Note for multiple tables + if isMultipleTables { + Text("Same options will be applied to all selected tables.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + + // Ignore foreign key checks + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $ignoreForeignKeys) { + Text("Ignore foreign key checks") + .font(.system(size: 13)) + } + .toggleStyle(.checkbox) + .disabled(ignoreFKDisabled) + + if let description = ignoreFKDescription { + Text(description) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + } + .opacity(ignoreFKDisabled ? 0.6 : 1.0) + + // Cascade option + VStack(alignment: .leading, spacing: 4) { + Toggle(isOn: $cascade) { + Text("Cascade") + .font(.system(size: 13)) + } + .toggleStyle(.checkbox) + .disabled(cascadeDisabled) + + Text(cascadeDescription) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .padding(.leading, 20) + } + .opacity(cascadeDisabled ? 0.6 : 1.0) + } + .padding(.horizontal, 20) + .padding(.vertical, 20) + + Divider() + + // Footer buttons + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button("OK") { + confirmAndDismiss() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + } + .padding(12) + } + .frame(width: 320) + .background(Color(nsColor: .windowBackgroundColor)) + .onAppear { + // Reset state when dialog opens + ignoreForeignKeys = false + cascade = false + } + .onExitCommand { + isPresented = false + } + } + + private func confirmAndDismiss() { + // Values are already reset when their toggles become disabled, + // so we can pass them directly without override checks + let options = TableOperationOptions( + ignoreForeignKeys: ignoreForeignKeys, + cascade: cascade + ) + onConfirm(options) + isPresented = false + } +} + +// MARK: - Preview + +#Preview("Drop Table - MySQL") { + TableOperationDialog( + isPresented: .constant(true), + tableName: "users", + operationType: .drop, + databaseType: .mysql, + onConfirm: { options in + print("Options: \(options)") + } + ) +} + +#Preview("Truncate Table - PostgreSQL") { + TableOperationDialog( + isPresented: .constant(true), + tableName: "orders", + operationType: .truncate, + databaseType: .postgresql, + onConfirm: { options in + print("Options: \(options)") + } + ) +} + +#Preview("Drop Table - SQLite") { + TableOperationDialog( + isPresented: .constant(true), + tableName: "products", + operationType: .drop, + databaseType: .sqlite, + onConfirm: { options in + print("Options: \(options)") + } + ) +}