From 19e3d3895b897b91754f152ba30b6598e18411fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 28 Dec 2025 19:54:03 +0700 Subject: [PATCH 01/35] feat: add table export functionality with CSV, JSON, and SQL formats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ExportDialog with table selection tree and format options - Support CSV export with configurable delimiter, quoting, line breaks - Support JSON export with pretty print and null value options - Support SQL export with structure, data, drop statements, and gzip compression - Add real-time progress dialog with row count and cancellation support - Add success dialog with "Open containing folder" and "Don't show this again" options - Add Export option to sidebar context menu and Database menu (⌘⇧E) - Optimize export for large tables with throttled progress updates - Run compression and file writes on background threads --- TablePro/Core/Services/ExportService.swift | 534 ++++++++++++++++ TablePro/Models/ExportModels.swift | 231 +++++++ TablePro/TableProApp.swift | 11 + .../Views/Export/ExportCSVOptionsView.swift | 102 +++ TablePro/Views/Export/ExportDialog.swift | 597 ++++++++++++++++++ .../Views/Export/ExportJSONOptionsView.swift | 33 + .../Views/Export/ExportProgressView.swift | 90 +++ .../Views/Export/ExportSQLOptionsView.swift | 41 ++ TablePro/Views/Export/ExportSuccessView.swift | 72 +++ .../Views/Export/ExportTableTreeView.swift | 156 +++++ .../Views/Main/Child/MainContentAlerts.swift | 21 +- .../Main/Child/QueryTabContentView.swift | 4 +- .../Views/Main/MainContentCoordinator.swift | 1 + .../Main/MainContentNotificationHandler.swift | 11 + TablePro/Views/MainContentView.swift | 4 +- TablePro/Views/Sidebar/SidebarView.swift | 20 +- 16 files changed, 1922 insertions(+), 6 deletions(-) create mode 100644 TablePro/Core/Services/ExportService.swift create mode 100644 TablePro/Models/ExportModels.swift create mode 100644 TablePro/Views/Export/ExportCSVOptionsView.swift create mode 100644 TablePro/Views/Export/ExportDialog.swift create mode 100644 TablePro/Views/Export/ExportJSONOptionsView.swift create mode 100644 TablePro/Views/Export/ExportProgressView.swift create mode 100644 TablePro/Views/Export/ExportSQLOptionsView.swift create mode 100644 TablePro/Views/Export/ExportSuccessView.swift create mode 100644 TablePro/Views/Export/ExportTableTreeView.swift diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift new file mode 100644 index 000000000..b82e5b33d --- /dev/null +++ b/TablePro/Core/Services/ExportService.swift @@ -0,0 +1,534 @@ +// +// ExportService.swift +// TablePro +// +// Service responsible for exporting table data to CSV, JSON, and SQL formats. +// Supports configurable options for each format including compression. +// + +import Combine +import Compression +import Foundation + +// MARK: - Export Error + +/// Errors that can occur during export operations +enum ExportError: LocalizedError { + case notConnected + case noTablesSelected + case exportFailed(String) + case compressionFailed + case fileWriteFailed(String) + + var errorDescription: String? { + switch self { + case .notConnected: + return "Not connected to database" + case .noTablesSelected: + return "No tables selected for export" + case .exportFailed(let message): + return "Export failed: \(message)" + case .compressionFailed: + return "Failed to compress data" + case .fileWriteFailed(let path): + return "Failed to write file: \(path)" + } + } +} + +// MARK: - Export Service + +/// Service responsible for exporting table data to various formats +@MainActor +final class ExportService: ObservableObject { + + // MARK: - Published State + + @Published var isExporting: Bool = false + @Published var progress: Double = 0.0 + @Published var currentTable: String = "" + @Published var currentTableIndex: Int = 0 + @Published var totalTables: Int = 0 + @Published var processedRows: Int = 0 + @Published var totalRows: Int = 0 + @Published var statusMessage: String = "" + @Published var errorMessage: String? + + // MARK: - Cancellation + + private var isCancelled: Bool = false + + // MARK: - Progress Throttling + + /// Number of rows to process before updating UI + private let progressUpdateInterval: Int = 1000 + /// Internal counter for processed rows (updated every row) + private var internalProcessedRows: Int = 0 + + // MARK: - Dependencies + + private let driver: DatabaseDriver + private let databaseType: DatabaseType + + // MARK: - Initialization + + init(driver: DatabaseDriver, databaseType: DatabaseType) { + self.driver = driver + self.databaseType = databaseType + } + + // MARK: - Public API + + /// Cancel the current export operation + func cancelExport() { + isCancelled = true + } + + /// Export selected tables to the specified URL + /// - Parameters: + /// - tables: Array of table items to export (with SQL options for SQL format) + /// - config: Export configuration with format and options + /// - url: Destination file URL + func export( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + guard !tables.isEmpty else { + throw ExportError.noTablesSelected + } + + // Reset state + isExporting = true + isCancelled = false + progress = 0.0 + processedRows = 0 + internalProcessedRows = 0 + totalRows = 0 + totalTables = tables.count + currentTableIndex = 0 + statusMessage = "" + errorMessage = nil + + defer { + isExporting = false + isCancelled = false + statusMessage = "" + } + + // Fetch total row counts for all tables + totalRows = await fetchTotalRowCount(for: tables) + + do { + switch config.format { + case .csv: + try await exportToCSV(tables: tables, config: config, to: url) + case .json: + try await exportToJSON(tables: tables, config: config, to: url) + case .sql: + try await exportToSQL(tables: tables, config: config, to: url) + } + } catch { + // Clean up partial file on cancellation or error + try? FileManager.default.removeItem(at: url) + errorMessage = error.localizedDescription + throw error + } + } + + /// Fetch total row count for all tables + private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { + var total = 0 + for table in tables { + let tableRef = qualifiedTableRef(for: table) + do { + let result = try await driver.execute(query: "SELECT COUNT(*) FROM \(tableRef)") + if let countStr = result.rows.first?.first, let count = Int(countStr ?? "0") { + total += count + } + } catch { + // If count fails, estimate based on 0 (progress will be less accurate) + } + } + return total + } + + /// Check if export was cancelled and throw if so + private func checkCancellation() throws { + if isCancelled { + throw ExportError.exportFailed("Export cancelled") + } + } + + /// Increment processed rows with throttled UI updates + /// Only updates @Published properties every `progressUpdateInterval` rows + /// Uses Task.yield() to allow UI to refresh + private func incrementProgress() async { + internalProcessedRows += 1 + + // Only update UI every N rows + if internalProcessedRows % progressUpdateInterval == 0 { + processedRows = internalProcessedRows + if totalRows > 0 { + progress = Double(internalProcessedRows) / Double(totalRows) + } + // Yield to allow UI to update + await Task.yield() + } + } + + /// Finalize progress for current table (ensures UI shows final count) + private func finalizeTableProgress() async { + processedRows = internalProcessedRows + if totalRows > 0 { + progress = Double(internalProcessedRows) / Double(totalRows) + } + // Yield to allow UI to update + await Task.yield() + } + + // MARK: - Helpers + + /// Build fully qualified and quoted table reference (database.table or just table) + private func qualifiedTableRef(for table: ExportTableItem) -> String { + if table.databaseName.isEmpty { + return databaseType.quoteIdentifier(table.name) + } else { + let quotedDb = databaseType.quoteIdentifier(table.databaseName) + let quotedTable = databaseType.quoteIdentifier(table.name) + return "\(quotedDb).\(quotedTable)" + } + } + + // MARK: - CSV Export + + private func exportToCSV( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + var output = "" + + for (index, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = index + 1 + currentTable = table.qualifiedName + + // Add table header comment if multiple tables + if tables.count > 1 { + output += "# Table: \(table.qualifiedName)\n" + } + + // Fetch all data from table + let tableRef = qualifiedTableRef(for: table) + let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") + + // Build CSV content with row tracking + output += try await buildCSVContentWithProgress( + columns: result.columns, + rows: result.rows, + options: config.csvOptions + ) + + if index < tables.count - 1 { + output += config.csvOptions.lineBreak.value + output += config.csvOptions.lineBreak.value + } + } + + try checkCancellation() + try output.write(to: url, atomically: true, encoding: .utf8) + progress = 1.0 + } + + private func buildCSVContentWithProgress( + columns: [String], + rows: [[String?]], + options: CSVExportOptions + ) async throws -> String { + var lines: [String] = [] + let delimiter = options.delimiter.actualValue + let lineBreak = options.lineBreak.value + + // Header row + if options.includeFieldNames { + let headerLine = columns + .map { escapeCSVField($0, options: options) } + .joined(separator: delimiter) + lines.append(headerLine) + } + + // Data rows with progress tracking + for row in rows { + try checkCancellation() + + let rowLine = row.map { value -> String in + guard let val = value else { + return options.convertNullToEmpty ? "" : "NULL" + } + + var processed = val + + // Convert line breaks to space + if options.convertLineBreakToSpace { + processed = processed + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\r", with: " ") + .replacingOccurrences(of: "\n", with: " ") + } + + // Handle decimal format + if options.decimalFormat == .comma, + Double(processed) != nil { + processed = processed.replacingOccurrences(of: ".", with: ",") + } + + return escapeCSVField(processed, options: options) + }.joined(separator: delimiter) + + lines.append(rowLine) + + // Update progress (throttled) + await incrementProgress() + } + + // Ensure final count is shown + await finalizeTableProgress() + + return lines.joined(separator: lineBreak) + } + + private func escapeCSVField(_ field: String, options: CSVExportOptions) -> String { + switch options.quoteHandling { + case .always: + let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + case .never: + return field + case .asNeeded: + let needsQuotes = field.contains(options.delimiter.actualValue) || + field.contains("\"") || + field.contains("\n") || + field.contains("\r") + if needsQuotes { + let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + return field + } + } + + // MARK: - JSON Export + + private func exportToJSON( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + var exportData: [String: [[String: Any]]] = [:] + + for (index, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = index + 1 + currentTable = table.qualifiedName + + let tableRef = qualifiedTableRef(for: table) + let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") + + var tableData: [[String: Any]] = [] + for row in result.rows { + try checkCancellation() + + var rowDict: [String: Any] = [:] + for (colIndex, column) in result.columns.enumerated() { + if colIndex < row.count { + let value = row[colIndex] + if config.jsonOptions.includeNullValues || value != nil { + rowDict[column] = value ?? NSNull() + } + } + } + tableData.append(rowDict) + + // Update progress (throttled) + await incrementProgress() + } + + // Ensure final count is shown for this table + await finalizeTableProgress() + + exportData[table.qualifiedName] = tableData + } + + try checkCancellation() + + let options: JSONSerialization.WritingOptions = config.jsonOptions.prettyPrint + ? [.prettyPrinted, .sortedKeys] + : [.sortedKeys] + + let jsonData = try JSONSerialization.data(withJSONObject: exportData, options: options) + try jsonData.write(to: url) + progress = 1.0 + } + + // MARK: - SQL Export + + private func exportToSQL( + tables: [ExportTableItem], + config: ExportConfiguration, + to url: URL + ) async throws { + var output = "" + + // Add header comment + let dateFormatter = ISO8601DateFormatter() + output += "-- TablePro SQL Export\n" + output += "-- Generated: \(dateFormatter.string(from: Date()))\n" + output += "-- Database Type: \(databaseType.rawValue)\n\n" + + for (index, table) in tables.enumerated() { + try checkCancellation() + + currentTableIndex = index + 1 + currentTable = table.qualifiedName + + let sqlOptions = table.sqlOptions + let tableRef = qualifiedTableRef(for: table) + + output += "-- --------------------------------------------------------\n" + output += "-- Table: \(table.qualifiedName)\n" + output += "-- --------------------------------------------------------\n\n" + + // DROP statement + if sqlOptions.includeDrop { + output += "DROP TABLE IF EXISTS \(tableRef);\n\n" + } + + // CREATE TABLE (structure) + if sqlOptions.includeStructure { + // For cross-database, we need the full reference + let ddl = try await driver.fetchTableDDL(table: table.name) + output += ddl + if !ddl.hasSuffix(";") { + output += ";" + } + output += "\n\n" + } + + // INSERT statements (data) + if sqlOptions.includeData { + let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") + + if !result.rows.isEmpty { + output += try await buildInsertStatementsWithProgress( + table: table, + columns: result.columns, + rows: result.rows + ) + output += "\n" + } + } + } + + try checkCancellation() + + // Handle gzip compression + if config.sqlOptions.compressWithGzip { + statusMessage = "Compressing..." + await Task.yield() + + guard let data = output.data(using: .utf8) else { + throw ExportError.exportFailed("Failed to encode SQL content") + } + + // Compress directly to destination file (much faster than piping) + try await compressToFile(data, destination: url) + } else { + statusMessage = "Writing file..." + await Task.yield() + + let outputCopy = output + try await Task.detached { + try outputCopy.write(to: url, atomically: true, encoding: .utf8) + }.value + } + + progress = 1.0 + } + + private func buildInsertStatementsWithProgress( + table: ExportTableItem, + columns: [String], + rows: [[String?]] + ) async throws -> String { + var output = "" + let tableRef = qualifiedTableRef(for: table) + let quotedColumns = columns + .map { databaseType.quoteIdentifier($0) } + .joined(separator: ", ") + + for row in rows { + try checkCancellation() + + let values = row.map { value -> String in + guard let val = value else { return "NULL" } + // Escape single quotes by doubling them + let escaped = val.replacingOccurrences(of: "'", with: "''") + return "'\(escaped)'" + }.joined(separator: ", ") + + output += "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES (\(values));\n" + + // Update progress (throttled) + await incrementProgress() + } + + // Ensure final count is shown + await finalizeTableProgress() + + return output + } + + // MARK: - Compression + + private func compressToFile(_ data: Data, destination: URL) async throws { + // Run compression on background thread to avoid blocking main thread + try await Task.detached(priority: .userInitiated) { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent(UUID().uuidString + ".sql") + + defer { + try? FileManager.default.removeItem(at: tempFile) + // gzip creates tempFile.gz, clean it up if it exists + let gzFile = tempFile.appendingPathExtension("gz") + try? FileManager.default.removeItem(at: gzFile) + } + + // Write uncompressed data to temp file + try data.write(to: tempFile) + + // Use gzip to compress the file in place (creates .sql.gz) + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") + process.arguments = ["-f", tempFile.path] + + try process.run() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw ExportError.compressionFailed + } + + // gzip creates file with .gz extension + let compressedFile = tempFile.appendingPathExtension("gz") + + // Move compressed file to destination + if FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.removeItem(at: destination) + } + try FileManager.default.moveItem(at: compressedFile, to: destination) + }.value + } +} diff --git a/TablePro/Models/ExportModels.swift b/TablePro/Models/ExportModels.swift new file mode 100644 index 000000000..6e96ee798 --- /dev/null +++ b/TablePro/Models/ExportModels.swift @@ -0,0 +1,231 @@ +// +// ExportModels.swift +// TablePro +// +// Models for table export functionality. +// Supports CSV, JSON, and SQL export formats with configurable options. +// + +import Foundation + +// MARK: - Export Format + +/// Supported export file formats +enum ExportFormat: String, CaseIterable, Identifiable { + case csv = "CSV" + case json = "JSON" + case sql = "SQL" + + var id: String { rawValue } + + /// File extension for this format + var fileExtension: String { + switch self { + case .csv: return "csv" + case .json: return "json" + case .sql: return "sql" + } + } +} + +// MARK: - CSV Options + +/// CSV field delimiter options +enum CSVDelimiter: String, CaseIterable, Identifiable { + case comma = "," + case semicolon = ";" + case tab = "\\t" + case pipe = "|" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .comma: return "," + case .semicolon: return ";" + case .tab: return "\\t" + case .pipe: return "|" + } + } + + /// Actual character(s) to use as delimiter + var actualValue: String { + self == .tab ? "\t" : rawValue + } +} + +/// CSV field quoting behavior +enum CSVQuoteHandling: String, CaseIterable, Identifiable { + case always = "Always" + case asNeeded = "Quote if needed" + case never = "Never" + + var id: String { rawValue } +} + +/// Line break format for CSV export +enum CSVLineBreak: String, CaseIterable, Identifiable { + case lf = "\\n" + case crlf = "\\r\\n" + case cr = "\\r" + + var id: String { rawValue } + + /// Actual line break characters + var value: String { + switch self { + case .lf: return "\n" + case .crlf: return "\r\n" + case .cr: return "\r" + } + } +} + +/// Decimal separator format +enum CSVDecimalFormat: String, CaseIterable, Identifiable { + case period = "." + case comma = "," + + var id: String { rawValue } + + var separator: String { rawValue } +} + +/// Options for CSV export +struct CSVExportOptions: Equatable { + var convertNullToEmpty: Bool = true + var convertLineBreakToSpace: Bool = false + var includeFieldNames: Bool = true + var delimiter: CSVDelimiter = .comma + var quoteHandling: CSVQuoteHandling = .asNeeded + var lineBreak: CSVLineBreak = .lf + var decimalFormat: CSVDecimalFormat = .period +} + +// MARK: - JSON Options + +/// Options for JSON export +struct JSONExportOptions: Equatable { + var prettyPrint: Bool = true + var includeNullValues: Bool = true +} + +// MARK: - SQL Options + +/// Per-table SQL export options (Structure, Drop, Data checkboxes) +struct SQLTableExportOptions: Equatable { + var includeStructure: Bool = true + var includeDrop: Bool = true + var includeData: Bool = true +} + +/// Global options for SQL export +struct SQLExportOptions: Equatable { + var compressWithGzip: Bool = false +} + +// MARK: - Export Configuration + +/// Complete export configuration combining format, selection, and options +struct ExportConfiguration { + var format: ExportFormat = .csv + var fileName: String = "export" + var csvOptions: CSVExportOptions = CSVExportOptions() + var jsonOptions: JSONExportOptions = JSONExportOptions() + var sqlOptions: SQLExportOptions = SQLExportOptions() + + /// Full file name including extension + var fullFileName: String { + let ext = compressedExtension ?? format.fileExtension + return "\(fileName).\(ext)" + } + + private var compressedExtension: String? { + if format == .sql && sqlOptions.compressWithGzip { + return "sql.gz" + } + return nil + } +} + +// MARK: - Tree View Models + +/// Represents a table item in the export tree view +struct ExportTableItem: Identifiable, Hashable { + let id: UUID + let name: String + let databaseName: String + let type: TableInfo.TableType + var isSelected: Bool = false + var sqlOptions: SQLTableExportOptions = SQLTableExportOptions() + + init( + id: UUID = UUID(), + name: String, + databaseName: String = "", + type: TableInfo.TableType, + isSelected: Bool = false, + sqlOptions: SQLTableExportOptions = SQLTableExportOptions() + ) { + self.id = id + self.name = name + self.databaseName = databaseName + self.type = type + self.isSelected = isSelected + self.sqlOptions = sqlOptions + } + + /// Fully qualified table name (database.table) + var qualifiedName: String { + databaseName.isEmpty ? name : "\(databaseName).\(name)" + } + + // Hashable conformance excluding mutable state + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + + static func == (lhs: ExportTableItem, rhs: ExportTableItem) -> Bool { + lhs.id == rhs.id + } +} + +/// Represents a database item in the export tree view (contains tables) +struct ExportDatabaseItem: Identifiable { + let id: UUID + let name: String + var tables: [ExportTableItem] + var isExpanded: Bool = true + + init( + id: UUID = UUID(), + name: String, + tables: [ExportTableItem], + isExpanded: Bool = true + ) { + self.id = id + self.name = name + self.tables = tables + self.isExpanded = isExpanded + } + + /// Number of selected tables + var selectedCount: Int { + tables.filter { $0.isSelected }.count + } + + /// Whether all tables are selected + var allSelected: Bool { + !tables.isEmpty && tables.allSatisfy { $0.isSelected } + } + + /// Whether no tables are selected + var noneSelected: Bool { + tables.allSatisfy { !$0.isSelected } + } + + /// Get all selected table items + var selectedTables: [ExportTableItem] { + tables.filter { $0.isSelected } + } +} diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index be08be432..2fbcd69b4 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -185,6 +185,14 @@ struct TableProApp: App { } .keyboardShortcut("r", modifiers: .command) .disabled(!appState.isConnected) + + Divider() + + Button("Export...") { + NotificationCenter.default.post(name: .exportTables, object: nil) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + .disabled(!appState.isConnected) } // Edit menu - Undo/Redo (smart handling for both text editor and data grid) @@ -319,6 +327,9 @@ extension Notification.Name { // Table creation notifications static let createTable = Notification.Name("createTable") + // Export notifications + static let exportTables = Notification.Name("exportTables") + // Window lifecycle notifications static let mainWindowWillClose = Notification.Name("mainWindowWillClose") } diff --git a/TablePro/Views/Export/ExportCSVOptionsView.swift b/TablePro/Views/Export/ExportCSVOptionsView.swift new file mode 100644 index 000000000..c3f38f69e --- /dev/null +++ b/TablePro/Views/Export/ExportCSVOptionsView.swift @@ -0,0 +1,102 @@ +// +// ExportCSVOptionsView.swift +// TablePro +// +// Options panel for CSV export format. +// Provides controls for delimiter, quoting, NULL handling, and formatting. +// + +import SwiftUI + +/// Options panel for CSV export +struct ExportCSVOptionsView: View { + @Binding var options: CSVExportOptions + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + // Checkboxes section + VStack(alignment: .leading, spacing: 8) { + Toggle("Convert NULL to EMPTY", isOn: $options.convertNullToEmpty) + .toggleStyle(.checkbox) + + Toggle("Convert line break to space", isOn: $options.convertLineBreakToSpace) + .toggleStyle(.checkbox) + + Toggle("Put field names in the first row", isOn: $options.includeFieldNames) + .toggleStyle(.checkbox) + } + + Divider() + .padding(.vertical, 4) + + // Dropdowns section + VStack(alignment: .leading, spacing: 10) { + optionRow("Delimiter") { + Picker("", selection: $options.delimiter) { + ForEach(CSVDelimiter.allCases) { delimiter in + Text(delimiter.displayName).tag(delimiter) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Swap") { + Picker("", selection: $options.quoteHandling) { + ForEach(CSVQuoteHandling.allCases) { handling in + Text(handling.rawValue).tag(handling) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Line break") { + Picker("", selection: $options.lineBreak) { + ForEach(CSVLineBreak.allCases) { lineBreak in + Text(lineBreak.rawValue).tag(lineBreak) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + + optionRow("Decimal") { + Picker("", selection: $options.decimalFormat) { + ForEach(CSVDecimalFormat.allCases) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 140, alignment: .trailing) + } + } + } + .font(.system(size: 13)) + } + + private func optionRow( + _ label: String, + @ViewBuilder content: () -> Content + ) -> some View { + HStack { + Text(label) + .foregroundStyle(.primary) + .frame(width: 80, alignment: .leading) + Spacer() + content() + } + } +} + +// MARK: - Preview + +#Preview { + ExportCSVOptionsView(options: .constant(CSVExportOptions())) + .padding() + .frame(width: 280) +} diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift new file mode 100644 index 000000000..c42ea4c78 --- /dev/null +++ b/TablePro/Views/Export/ExportDialog.swift @@ -0,0 +1,597 @@ +// +// ExportDialog.swift +// TablePro +// +// Main export dialog for exporting tables to CSV, JSON, or SQL formats. +// Features a split layout with table selection tree on the left and format options on the right. +// + +import AppKit +import Combine +import SwiftUI +import UniformTypeIdentifiers + +/// Main export dialog view +struct ExportDialog: View { + @Binding var isPresented: Bool + let connection: DatabaseConnection + let preselectedTables: Set + + // MARK: - State + + @State private var config = ExportConfiguration() + @State private var databaseItems: [ExportDatabaseItem] = [] + @State private var isLoading = true + @State private var isExporting = false + @State private var showError = false + @State private var errorMessage = "" + @State private var showProgressDialog = false + @State private var showSuccessDialog = false + @State private var exportedFileURL: URL? + @State private var currentExportTable = "" + + // MARK: - User Preferences + + @AppStorage("hideExportSuccessDialog") private var hideSuccessDialog = false + + // MARK: - Export Service + + @StateObject private var exportServiceState = ExportServiceState() + + // MARK: - Body + + var body: some View { + VStack(spacing: 0) { + // Content + HStack(spacing: 0) { + // Left: Table tree view + tableSelectionView + .frame(width: leftPanelWidth) + + Divider() + + // Right: Export options + exportOptionsView + .frame(width: 280) + } + .frame(height: 420) + + Divider() + + // Footer + footerView + } + .frame(width: dialogWidth) + .background(Color(nsColor: .windowBackgroundColor)) + .task { + await loadDatabaseItems() + } + .onExitCommand { + if !isExporting { + isPresented = false + } + } + .alert("Export Error", isPresented: $showError) { + Button("OK") { } + } message: { + Text(errorMessage) + } + .sheet(isPresented: $showProgressDialog) { + ExportProgressView( + tableName: exportServiceState.currentTable, + tableIndex: exportServiceState.currentTableIndex, + totalTables: exportServiceState.totalTables, + processedRows: exportServiceState.processedRows, + totalRows: exportServiceState.totalRows, + statusMessage: exportServiceState.statusMessage, + onStop: { + exportServiceState.service?.cancelExport() + } + ) + .interactiveDismissDisabled() + } + .sheet(isPresented: $showSuccessDialog) { + ExportSuccessView( + onOpenFolder: { + openContainingFolder() + showSuccessDialog = false + isPresented = false + }, + onClose: { + showSuccessDialog = false + isPresented = false + } + ) + } + } + + // MARK: - Layout Constants + + private var leftPanelWidth: CGFloat { + config.format == .sql ? 380 : 240 + } + + private var dialogWidth: CGFloat { + leftPanelWidth + 280 + } + + // MARK: - Table Selection View + + private var tableSelectionView: some View { + VStack(spacing: 0) { + // Header with title and selection count + HStack { + Text("Items") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + + Spacer() + + // Format-specific column headers for SQL + if config.format == .sql { + Text("Structure") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 56, alignment: .center) + + Text("Drop") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .center) + + Text("Data") + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: 44, alignment: .center) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(Color(nsColor: .windowBackgroundColor)) + + Divider() + + // Tree view or loading indicator + if isLoading { + VStack { + Spacer() + ProgressView() + .scaleEffect(0.8) + Text("Loading databases...") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .padding(.top, 8) + Spacer() + } + } else { + ExportTableTreeView( + databaseItems: $databaseItems, + format: config.format + ) + } + } + } + + // MARK: - Export Options View + + private var exportOptionsView: some View { + VStack(alignment: .leading, spacing: 0) { + // Format picker with selection count + VStack(alignment: .leading, spacing: 12) { + HStack { + Spacer() + + Picker("", selection: $config.format) { + ForEach(ExportFormat.allCases) { format in + Text(format.rawValue).tag(format) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 180) + + Spacer() + } + + // Selection count + Text("\(selectedCount) table\(selectedCount == 1 ? "" : "s") selected") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) + + Divider() + + // Format-specific options + ScrollView { + VStack(alignment: .leading, spacing: 0) { + switch config.format { + case .csv: + ExportCSVOptionsView(options: $config.csvOptions) + case .json: + ExportJSONOptionsView(options: $config.jsonOptions) + case .sql: + ExportSQLOptionsView(options: $config.sqlOptions) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + Spacer(minLength: 0) + + Divider() + + // File name section + VStack(alignment: .leading, spacing: 6) { + Text("File name") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + + HStack(spacing: 4) { + TextField("export", text: $config.fileName) + .textFieldStyle(.roundedBorder) + .font(.system(size: DesignConstants.FontSize.body)) + + Text(".\(fileExtension)") + .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.body, design: .monospaced)) + .lineLimit(1) + .fixedSize() + } + } + .padding(16) + } + } + + // MARK: - Footer + + private var footerView: some View { + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.cancelAction) + .disabled(isExporting) + + Spacer() + + if isExporting { + HStack(spacing: 8) { + ProgressView() + .scaleEffect(0.7) + + Text(currentExportTable) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .frame(maxWidth: 120) + } + } + + Button("Export...") { + performExport() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.return, modifiers: []) + .disabled(selectedCount == 0 || isExporting) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + + // MARK: - Computed Properties + + private var selectedCount: Int { + databaseItems.reduce(0) { $0 + $1.selectedCount } + } + + private var selectedTables: [ExportTableItem] { + databaseItems.flatMap { $0.selectedTables } + } + + private var fileExtension: String { + if config.format == .sql && config.sqlOptions.compressWithGzip { + return "sql.gz" + } + return config.format.fileExtension + } + + // MARK: - Actions + + @MainActor + private func loadDatabaseItems() async { + guard let driver = DatabaseManager.shared.activeDriver else { + isLoading = false + errorMessage = "Not connected to database" + showError = true + return + } + + do { + var items: [ExportDatabaseItem] = [] + + switch connection.type { + case .postgresql: + // PostgreSQL: fetch schemas within current database (can't query across databases) + let schemas = try await fetchPostgreSQLSchemas(driver: driver) + for schema in schemas { + let tables = try await fetchTablesForSchema(schema, driver: driver) + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: schema, // schema name for PostgreSQL + type: table.type, + isSelected: schema == "public" && preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: schema, + tables: tableItems, + isExpanded: schema == "public" + )) + } + } + // Sort: public schema first + items.sort { item1, item2 in + if item1.name == "public" { return true } + if item2.name == "public" { return false } + return item1.name < item2.name + } + + case .sqlite: + // SQLite: only one database, fetch tables directly + let tables = try await driver.fetchTables() + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: "", + type: table.type, + isSelected: preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: connection.database.isEmpty ? "main" : connection.database, + tables: tableItems, + isExpanded: true + )) + } + + case .mysql, .mariadb: + // MySQL/MariaDB: fetch all databases and their tables + let databases = try await driver.fetchDatabases() + for dbName in databases { + let tables = try await fetchTablesForDatabase(dbName, driver: driver) + let tableItems = tables.map { table in + ExportTableItem( + name: table.name, + databaseName: dbName, + type: table.type, + isSelected: dbName == connection.database && preselectedTables.contains(table.name) + ) + } + if !tableItems.isEmpty { + items.append(ExportDatabaseItem( + name: dbName, + tables: tableItems, + isExpanded: dbName == connection.database + )) + } + } + // Sort: current database first + items.sort { item1, item2 in + if item1.name == connection.database { return true } + if item2.name == connection.database { return false } + return item1.name < item2.name + } + } + + databaseItems = items + isLoading = false + + // Set default filename based on selection + if preselectedTables.count == 1, let first = preselectedTables.first { + config.fileName = first + } else if !connection.database.isEmpty { + config.fileName = connection.database + } + + } catch { + isLoading = false + errorMessage = "Failed to load databases: \(error.localizedDescription)" + showError = true + } + } + + private func fetchPostgreSQLSchemas(driver: DatabaseDriver) async throws -> [String] { + let query = """ + SELECT schema_name + FROM information_schema.schemata + WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ORDER BY schema_name + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { $0[0] } + } + + private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { + let query = """ + SELECT table_name, table_type + FROM information_schema.tables + WHERE table_schema = '\(schema)' + ORDER BY table_name + """ + let result = try await driver.execute(query: query) + return result.rows.compactMap { row in + guard let name = row[0] else { return nil } + let typeStr = row.count > 1 ? (row[1] ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { + // MySQL/MariaDB: query information_schema for tables in specific database + let query = """ + SELECT TABLE_NAME, TABLE_TYPE + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = '\(database)' + ORDER BY TABLE_NAME + """ + let result = try await driver.execute(query: query) + + return result.rows.compactMap { row in + guard let name = row[0] else { return nil } + let typeStr = row.count > 1 ? (row[1] ?? "BASE TABLE") : "BASE TABLE" + let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table + return TableInfo(name: name, type: type, rowCount: nil) + } + } + + private func performExport() { + // Show save panel + let savePanel = NSSavePanel() + savePanel.canCreateDirectories = true + savePanel.showsTagField = false + + // Configure allowed file types + if config.format == .sql && config.sqlOptions.compressWithGzip { + savePanel.allowedContentTypes = [UTType(filenameExtension: "gz") ?? .data] + savePanel.nameFieldStringValue = "\(config.fileName).sql.gz" + } else { + let utType = UTType(filenameExtension: config.format.fileExtension) ?? .plainText + savePanel.allowedContentTypes = [utType] + savePanel.nameFieldStringValue = config.fullFileName + } + + savePanel.message = "Export \(selectedCount) table(s) to \(config.format.rawValue)" + + savePanel.begin { response in + guard response == .OK, let url = savePanel.url else { return } + + Task { + await startExport(to: url) + } + } + } + + @MainActor + private func startExport(to url: URL) async { + guard let driver = DatabaseManager.shared.activeDriver else { + errorMessage = "Not connected to database" + showError = true + return + } + + isExporting = true + exportedFileURL = url + + let service = ExportService( + driver: driver, + databaseType: connection.type + ) + exportServiceState.service = service + + // Show progress dialog + showProgressDialog = true + + do { + try await service.export( + tables: selectedTables, + config: config, + to: url + ) + + // Export completed successfully + showProgressDialog = false + isExporting = false + + // Show success dialog or close directly based on preference + if hideSuccessDialog { + isPresented = false + } else { + showSuccessDialog = true + } + + } catch { + showProgressDialog = false + isExporting = false + errorMessage = error.localizedDescription + showError = true + } + } + + private func openContainingFolder() { + guard let url = exportedFileURL else { return } + NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: "") + } +} + +// MARK: - Export Service State + +/// Observable wrapper for ExportService to enable SwiftUI bindings +@MainActor +final class ExportServiceState: ObservableObject { + @Published var currentTable: String = "" + @Published var currentTableIndex: Int = 0 + @Published var totalTables: Int = 0 + @Published var processedRows: Int = 0 + @Published var totalRows: Int = 0 + @Published var statusMessage: String = "" + + private var cancellables = Set() + + var service: ExportService? { + didSet { + cancellables.removeAll() + guard let service = service else { return } + + service.$currentTable + .receive(on: DispatchQueue.main) + .assign(to: &$currentTable) + + service.$currentTableIndex + .receive(on: DispatchQueue.main) + .assign(to: &$currentTableIndex) + + service.$totalTables + .receive(on: DispatchQueue.main) + .assign(to: &$totalTables) + + service.$processedRows + .receive(on: DispatchQueue.main) + .assign(to: &$processedRows) + + service.$totalRows + .receive(on: DispatchQueue.main) + .assign(to: &$totalRows) + + service.$statusMessage + .receive(on: DispatchQueue.main) + .assign(to: &$statusMessage) + } + } +} + +// MARK: - Preview + +#Preview { + let connection = DatabaseConnection( + name: "Local MySQL", + host: "localhost", + database: "my_database", + type: .mysql + ) + + return ExportDialog( + isPresented: .constant(true), + connection: connection, + preselectedTables: ["users"] + ) +} diff --git a/TablePro/Views/Export/ExportJSONOptionsView.swift b/TablePro/Views/Export/ExportJSONOptionsView.swift new file mode 100644 index 000000000..dc9488257 --- /dev/null +++ b/TablePro/Views/Export/ExportJSONOptionsView.swift @@ -0,0 +1,33 @@ +// +// ExportJSONOptionsView.swift +// TablePro +// +// Options panel for JSON export format. +// Provides controls for formatting and NULL value handling. +// + +import SwiftUI + +/// Options panel for JSON export +struct ExportJSONOptionsView: View { + @Binding var options: JSONExportOptions + + var body: some View { + VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { + Toggle("Pretty print (formatted output)", isOn: $options.prettyPrint) + .toggleStyle(.checkbox) + + Toggle("Include NULL values", isOn: $options.includeNullValues) + .toggleStyle(.checkbox) + } + .font(.system(size: DesignConstants.FontSize.body)) + } +} + +// MARK: - Preview + +#Preview { + ExportJSONOptionsView(options: .constant(JSONExportOptions())) + .padding() + .frame(width: 300) +} diff --git a/TablePro/Views/Export/ExportProgressView.swift b/TablePro/Views/Export/ExportProgressView.swift new file mode 100644 index 000000000..f7a513ee8 --- /dev/null +++ b/TablePro/Views/Export/ExportProgressView.swift @@ -0,0 +1,90 @@ +// +// ExportProgressView.swift +// TablePro +// +// Progress dialog shown during table export. +// Displays table name, row progress, progress bar, and stop button. +// + +import SwiftUI + +/// Progress dialog shown during export operation +struct ExportProgressView: View { + let tableName: String + let tableIndex: Int + let totalTables: Int + let processedRows: Int + let totalRows: Int + let statusMessage: String + let onStop: () -> Void + + var body: some View { + VStack(spacing: 20) { + // Title + Text(totalTables > 1 ? "Export multiple tables" : "Export table") + .font(.system(size: 15, weight: .semibold)) + + // Table info and row count + VStack(spacing: 8) { + HStack { + // Show status message if set, otherwise show table name + if !statusMessage.isEmpty { + Text(statusMessage) + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } else { + Text("\(tableName) (\(tableIndex)/\(totalTables))") + .font(.system(size: 13)) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer() + + if statusMessage.isEmpty { + Text("\(processedRows.formatted())/\(totalRows.formatted()) rows") + .font(.system(size: 13, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + + // Progress bar - indeterminate when status message is shown + if !statusMessage.isEmpty { + ProgressView() + .progressViewStyle(.linear) + } else { + ProgressView(value: progressValue) + .progressViewStyle(.linear) + } + } + + // Stop button + Button("Stop") { + onStop() + } + .frame(width: 80) + } + .padding(24) + .frame(width: 400) + .background(Color(nsColor: .windowBackgroundColor)) + } + + private var progressValue: Double { + guard totalRows > 0 else { return 0 } + return Double(processedRows) / Double(totalRows) + } +} + +// MARK: - Preview + +#Preview { + ExportProgressView( + tableName: "users", + tableIndex: 1, + totalTables: 3, + processedRows: 95500, + totalRows: 175787, + statusMessage: "", + onStop: {} + ) +} diff --git a/TablePro/Views/Export/ExportSQLOptionsView.swift b/TablePro/Views/Export/ExportSQLOptionsView.swift new file mode 100644 index 000000000..1c49202ae --- /dev/null +++ b/TablePro/Views/Export/ExportSQLOptionsView.swift @@ -0,0 +1,41 @@ +// +// ExportSQLOptionsView.swift +// TablePro +// +// Options panel for SQL export format. +// Note: Structure, Drop, and Data options are per-table (shown in tree view). +// This view only contains global options like compression. +// + +import SwiftUI + +/// Options panel for SQL export (global options only) +struct ExportSQLOptionsView: View { + @Binding var options: SQLExportOptions + + var body: some View { + VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { + // Info text about per-table options + Text("Structure, Drop, and Data options are configured per table in the table list.") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .padding(.vertical, DesignConstants.Spacing.xxs) + + // Global compression option + Toggle("Compress the file using Gzip", isOn: $options.compressWithGzip) + .toggleStyle(.checkbox) + .font(.system(size: DesignConstants.FontSize.body)) + } + } +} + +// MARK: - Preview + +#Preview { + ExportSQLOptionsView(options: .constant(SQLExportOptions())) + .padding() + .frame(width: 300) +} diff --git a/TablePro/Views/Export/ExportSuccessView.swift b/TablePro/Views/Export/ExportSuccessView.swift new file mode 100644 index 000000000..778c53bb1 --- /dev/null +++ b/TablePro/Views/Export/ExportSuccessView.swift @@ -0,0 +1,72 @@ +// +// ExportSuccessView.swift +// TablePro +// +// Success dialog shown after export completes. +// Provides option to open containing folder in Finder. +// + +import SwiftUI + +/// Success dialog shown after export completes +struct ExportSuccessView: View { + let onOpenFolder: () -> Void + let onClose: () -> Void + + @AppStorage("hideExportSuccessDialog") private var dontShowAgain = false + @State private var localDontShowAgain = false + + var body: some View { + VStack(spacing: 20) { + // Success icon + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 48)) + .foregroundStyle(.green) + + // Title and message + VStack(spacing: 6) { + Text("Success") + .font(.system(size: 15, weight: .semibold)) + + Text("Export completed successfully") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + + // Buttons + VStack(spacing: 10) { + Button("Open containing folder") { + onOpenFolder() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + + Button("Close") { + if localDontShowAgain { + dontShowAgain = true + } + onClose() + } + .controlSize(.large) + } + + // Don't show again checkbox + Toggle("Don't show this again", isOn: $localDontShowAgain) + .toggleStyle(.checkbox) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .padding(24) + .frame(width: 300) + .background(Color(nsColor: .windowBackgroundColor)) + } +} + +// MARK: - Preview + +#Preview { + ExportSuccessView( + onOpenFolder: {}, + onClose: {} + ) +} diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift new file mode 100644 index 000000000..80df54d0d --- /dev/null +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -0,0 +1,156 @@ +// +// ExportTableTreeView.swift +// TablePro +// +// Tree view for selecting tables to export. +// Shows database hierarchy with checkbox selection. +// When SQL format is selected, displays additional columns for Structure, Drop, and Data options. +// + +import SwiftUI + +/// Tree view for selecting tables to export +struct ExportTableTreeView: View { + @Binding var databaseItems: [ExportDatabaseItem] + let format: ExportFormat + + var body: some View { + List { + ForEach($databaseItems) { $database in + DisclosureGroup(isExpanded: $database.isExpanded) { + ForEach($database.tables) { $table in + tableRow(table: $table) + } + } label: { + databaseRow(database: $database) + } + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + } + + // MARK: - Database Row + + private func databaseRow(database: Binding) -> some View { + HStack(spacing: 8) { + // Native tristate checkbox using sources binding + Toggle(sources: database.tables, isOn: \.isSelected) { + EmptyView() + } + .toggleStyle(.checkbox) + .labelsHidden() + + // Database icon + Image(systemName: "cylinder") + .foregroundStyle(.blue) + .font(.system(size: 12)) + + // Database name + Text(database.wrappedValue.name) + .font(.system(size: 13, weight: .medium)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + // SQL-specific checkboxes placeholder (hidden for database row) + if format == .sql { + HStack(spacing: 0) { + Color.clear.frame(width: 56) + Color.clear.frame(width: 44) + Color.clear.frame(width: 44) + } + } + } + .contentShape(Rectangle()) + } + + // MARK: - Table Row + + private func tableRow(table: Binding) -> some View { + HStack(spacing: 8) { + // Selection checkbox + Toggle("", isOn: table.isSelected) + .toggleStyle(.checkbox) + .labelsHidden() + + // Table icon + Image(systemName: table.wrappedValue.type == .view ? "eye" : "tablecells") + .foregroundStyle(table.wrappedValue.type == .view ? .purple : .secondary) + .font(.system(size: 12)) + + // Table name + Text(table.wrappedValue.name) + .font(.system(size: 13, design: .monospaced)) + .lineLimit(1) + .truncationMode(.middle) + + Spacer() + + // SQL-specific checkboxes + if format == .sql { + HStack(spacing: 0) { + // Structure checkbox + Toggle("", isOn: table.sqlOptions.includeStructure) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 56, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + + // Drop checkbox + Toggle("", isOn: table.sqlOptions.includeDrop) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 44, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + + // Data checkbox + Toggle("", isOn: table.sqlOptions.includeData) + .toggleStyle(.checkbox) + .labelsHidden() + .frame(width: 44, alignment: .center) + .disabled(!table.wrappedValue.isSelected) + } + .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) + } + } + } + +} + +// MARK: - Preview + +#Preview("CSV Format") { + let tables = [ + ExportTableItem(name: "users", type: .table, isSelected: true), + ExportTableItem(name: "posts", type: .table, isSelected: false), + ExportTableItem(name: "comments", type: .table, isSelected: true), + ExportTableItem(name: "user_stats", type: .view, isSelected: false) + ] + + return ExportTableTreeView( + databaseItems: .constant([ + ExportDatabaseItem(name: "my_database", tables: tables) + ]), + format: .csv + ) + .frame(width: 240, height: 400) +} + +#Preview("SQL Format") { + let tables = [ + ExportTableItem(name: "users", type: .table, isSelected: true), + ExportTableItem(name: "posts", type: .table, isSelected: false), + ExportTableItem(name: "comments", type: .table, isSelected: true), + ExportTableItem(name: "user_stats", type: .view, isSelected: false) + ] + + return ExportTableTreeView( + databaseItems: .constant([ + ExportDatabaseItem(name: "my_database", tables: tables) + ]), + format: .sql + ) + .frame(width: 380, height: 400) +} diff --git a/TablePro/Views/Main/Child/MainContentAlerts.swift b/TablePro/Views/Main/Child/MainContentAlerts.swift index 524d59bbe..94248901c 100644 --- a/TablePro/Views/Main/Child/MainContentAlerts.swift +++ b/TablePro/Views/Main/Child/MainContentAlerts.swift @@ -20,6 +20,8 @@ struct MainContentAlerts: ViewModifier { @Binding var pendingTruncates: Set @Binding var pendingDeletes: Set + let tables: [TableInfo] + let selectedTables: Set // MARK: - Environment @@ -55,6 +57,17 @@ struct MainContentAlerts: ViewModifier { .onChange(of: coordinator.showDatabaseSwitcher) { _, isPresented in appState.isSheetPresented = isPresented } + + .sheet(isPresented: $coordinator.showExportDialog) { + ExportDialog( + isPresented: $coordinator.showExportDialog, + connection: connection, + preselectedTables: Set(selectedTables.map { $0.name }) + ) + } + .onChange(of: coordinator.showExportDialog) { _, isPresented in + appState.isSheetPresented = isPresented + } } // MARK: - Computed Properties @@ -85,13 +98,17 @@ extension View { coordinator: MainContentCoordinator, connection: DatabaseConnection, pendingTruncates: Binding>, - pendingDeletes: Binding> + pendingDeletes: Binding>, + tables: [TableInfo], + selectedTables: Set ) -> some View { modifier(MainContentAlerts( coordinator: coordinator, connection: connection, pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes + pendingDeletes: pendingDeletes, + tables: tables, + selectedTables: selectedTables )) } } diff --git a/TablePro/Views/Main/Child/QueryTabContentView.swift b/TablePro/Views/Main/Child/QueryTabContentView.swift index 0d3dbdd3c..f8d2df071 100644 --- a/TablePro/Views/Main/Child/QueryTabContentView.swift +++ b/TablePro/Views/Main/Child/QueryTabContentView.swift @@ -42,7 +42,8 @@ struct QueryTabContentView: View { let onLimitChange: (Int) -> Void let onOffsetChange: (Int) -> Void let onPaginationGo: () -> Void - + let onDismissError: () -> Void + @Binding var sortState: SortState @Binding var showStructure: Bool @@ -85,6 +86,7 @@ struct QueryTabContentView: View { onLimitChange: onLimitChange, onOffsetChange: onOffsetChange, onPaginationGo: onPaginationGo, + onDismissError: onDismissError, sortState: $sortState, showStructure: $showStructure ) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index e6dbdcb49..05886ede0 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -45,6 +45,7 @@ final class MainContentCoordinator: ObservableObject { @Published var pendingDiscardAction: DiscardAction? // Removed: showErrorAlert and errorAlertMessage - errors now display inline @Published var showDatabaseSwitcher = false + @Published var showExportDialog = false @Published var needsLazyLoad = false // MARK: - Internal State diff --git a/TablePro/Views/Main/MainContentNotificationHandler.swift b/TablePro/Views/Main/MainContentNotificationHandler.swift index a3e0ae6d4..5201a638e 100644 --- a/TablePro/Views/Main/MainContentNotificationHandler.swift +++ b/TablePro/Views/Main/MainContentNotificationHandler.swift @@ -314,6 +314,13 @@ final class MainContentNotificationHandler: ObservableObject { self?.handleSaveChanges() } .store(in: &cancellables) + + NotificationCenter.default.publisher(for: .exportTables) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + self?.handleExportTables() + } + .store(in: &cancellables) } private func handleRefreshData() { @@ -344,6 +351,10 @@ final class MainContentNotificationHandler: ObservableObject { tableOperationOptions.wrappedValue = options } + private func handleExportTables() { + coordinator?.showExportDialog = true + } + // MARK: - UI Operations private func setupUIOperationObservers() { diff --git a/TablePro/Views/MainContentView.swift b/TablePro/Views/MainContentView.swift index b4145d6bb..f06124552 100644 --- a/TablePro/Views/MainContentView.swift +++ b/TablePro/Views/MainContentView.swift @@ -92,7 +92,9 @@ struct MainContentView: View { coordinator: coordinator, connection: connection, pendingTruncates: $pendingTruncates, - pendingDeletes: $pendingDeletes + pendingDeletes: $pendingDeletes, + tables: tables, + selectedTables: selectedTables ) .task { await initializeAndRestoreTabs() } .onChange(of: tabManager.selectedTabId) { oldTabId, newTabId in diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 366a7771a..827f06700 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -286,6 +286,15 @@ struct SidebarView: View { } .keyboardShortcut("c", modifiers: .command) + Button("Export...") { + // Select the table if not already selected + if selectedTables.isEmpty { + selectedTables.insert(table) + } + NotificationCenter.default.post(name: .exportTables, object: nil) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + Divider() Button("Truncate") { @@ -305,10 +314,17 @@ struct SidebarView: View { NotificationCenter.default.post(name: .createTable, object: nil) } .keyboardShortcut("n", modifiers: [.command, .shift]) - + + Divider() + + Button("Export...") { + NotificationCenter.default.post(name: .exportTables, object: nil) + } + .keyboardShortcut("e", modifiers: [.command, .shift]) + if !selectedTables.isEmpty { Divider() - + Button("Copy Name") { let names = selectedTables.map { $0.name }.sorted() NSPasteboard.general.clearContents() From d88070a81e52b5c424996a7e1e92430d68457447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 20:01:10 +0700 Subject: [PATCH 02/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index b82e5b33d..ad92965c3 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -156,7 +156,11 @@ final class ExportService: ObservableObject { /// Check if export was cancelled and throw if so private func checkCancellation() throws { if isCancelled { - throw ExportError.exportFailed("Export cancelled") + throw NSError( + domain: "ExportService", + code: NSUserCancelledError, + userInfo: [NSLocalizedDescriptionKey: "Export cancelled"] + ) } } From 94d73c06388199461bc998659d8cd73e54f3d519 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 20:01:19 +0700 Subject: [PATCH 03/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index ad92965c3..2c125fd7e 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -413,7 +413,7 @@ final class ExportService: ObservableObject { // CREATE TABLE (structure) if sqlOptions.includeStructure { // For cross-database, we need the full reference - let ddl = try await driver.fetchTableDDL(table: table.name) + let ddl = try await driver.fetchTableDDL(table: tableRef) output += ddl if !ddl.hasSuffix(";") { output += ";" From 9901b82e1f87fbe611f9bf2017583f75767bb9c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 20:01:26 +0700 Subject: [PATCH 04/35] Update TablePro/Views/Export/ExportCSVOptionsView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportCSVOptionsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/Export/ExportCSVOptionsView.swift b/TablePro/Views/Export/ExportCSVOptionsView.swift index c3f38f69e..3944c7f9e 100644 --- a/TablePro/Views/Export/ExportCSVOptionsView.swift +++ b/TablePro/Views/Export/ExportCSVOptionsView.swift @@ -42,7 +42,7 @@ struct ExportCSVOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow("Swap") { + optionRow("Quote") { Picker("", selection: $options.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) From 9d619993058cd683607b5cf8514c3d72caa7a680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 20:01:34 +0700 Subject: [PATCH 05/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 2c125fd7e..224e5ae0c 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -56,7 +56,20 @@ final class ExportService: ObservableObject { // MARK: - Cancellation - private var isCancelled: Bool = false + private let isCancelledLock = NSLock() + private var _isCancelled: Bool = false + private var isCancelled: Bool { + get { + isCancelledLock.lock() + defer { isCancelledLock.unlock() } + return _isCancelled + } + set { + isCancelledLock.lock() + _isCancelled = newValue + isCancelledLock.unlock() + } + } // MARK: - Progress Throttling From 85cb44acfdc54bc2e5c12cf8fd5ce33cf1f30629 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 28 Dec 2025 20:05:54 +0700 Subject: [PATCH 06/35] fix: address code review feedback - Remove duplicate keyboard shortcuts from context menus (keep only in main menu) - Fix SQL injection in schema/database queries by escaping single quotes - Optimize memory usage: stream CSV/SQL exports directly to file instead of building in memory - Use file-to-file gzip compression for better performance --- TablePro/Core/Services/ExportService.swift | 206 +++++++++++---------- TablePro/Views/Export/ExportDialog.swift | 8 +- TablePro/Views/Sidebar/SidebarView.swift | 2 - 3 files changed, 111 insertions(+), 105 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 224e5ae0c..f4f3c413c 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -224,7 +224,12 @@ final class ExportService: ObservableObject { config: ExportConfiguration, to url: URL ) async throws { - var output = "" + // Create file and get handle for streaming writes + FileManager.default.createFile(atPath: url.path, contents: nil) + let fileHandle = try FileHandle(forWritingTo: url) + defer { try? fileHandle.close() } + + let lineBreak = config.csvOptions.lineBreak.value for (index, table) in tables.enumerated() { try checkCancellation() @@ -234,37 +239,36 @@ final class ExportService: ObservableObject { // Add table header comment if multiple tables if tables.count > 1 { - output += "# Table: \(table.qualifiedName)\n" + try fileHandle.write(contentsOf: "# Table: \(table.qualifiedName)\n".data(using: .utf8)!) } // Fetch all data from table let tableRef = qualifiedTableRef(for: table) let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") - // Build CSV content with row tracking - output += try await buildCSVContentWithProgress( + // Stream CSV content directly to file + try await writeCSVContentWithProgress( columns: result.columns, rows: result.rows, - options: config.csvOptions + options: config.csvOptions, + to: fileHandle ) if index < tables.count - 1 { - output += config.csvOptions.lineBreak.value - output += config.csvOptions.lineBreak.value + try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".data(using: .utf8)!) } } try checkCancellation() - try output.write(to: url, atomically: true, encoding: .utf8) progress = 1.0 } - private func buildCSVContentWithProgress( + private func writeCSVContentWithProgress( columns: [String], rows: [[String?]], - options: CSVExportOptions - ) async throws -> String { - var lines: [String] = [] + options: CSVExportOptions, + to fileHandle: FileHandle + ) async throws { let delimiter = options.delimiter.actualValue let lineBreak = options.lineBreak.value @@ -273,10 +277,10 @@ final class ExportService: ObservableObject { let headerLine = columns .map { escapeCSVField($0, options: options) } .joined(separator: delimiter) - lines.append(headerLine) + try fileHandle.write(contentsOf: (headerLine + lineBreak).data(using: .utf8)!) } - // Data rows with progress tracking + // Data rows with progress tracking - stream directly to file for row in rows { try checkCancellation() @@ -304,7 +308,8 @@ final class ExportService: ObservableObject { return escapeCSVField(processed, options: options) }.joined(separator: delimiter) - lines.append(rowLine) + // Write row directly to file + try fileHandle.write(contentsOf: (rowLine + lineBreak).data(using: .utf8)!) // Update progress (throttled) await incrementProgress() @@ -312,8 +317,6 @@ final class ExportService: ObservableObject { // Ensure final count is shown await finalizeTableProgress() - - return lines.joined(separator: lineBreak) } private func escapeCSVField(_ field: String, options: CSVExportOptions) -> String { @@ -397,90 +400,107 @@ final class ExportService: ObservableObject { config: ExportConfiguration, to url: URL ) async throws { - var output = "" + // For gzip, write to temp file first then compress + // For non-gzip, stream directly to destination + let targetURL: URL + let tempFileURL: URL? - // Add header comment - let dateFormatter = ISO8601DateFormatter() - output += "-- TablePro SQL Export\n" - output += "-- Generated: \(dateFormatter.string(from: Date()))\n" - output += "-- Database Type: \(databaseType.rawValue)\n\n" + if config.sqlOptions.compressWithGzip { + tempFileURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sql") + targetURL = tempFileURL! + } else { + tempFileURL = nil + targetURL = url + } - for (index, table) in tables.enumerated() { - try checkCancellation() + // Create file and get handle for streaming writes + FileManager.default.createFile(atPath: targetURL.path, contents: nil) + let fileHandle = try FileHandle(forWritingTo: targetURL) - currentTableIndex = index + 1 - currentTable = table.qualifiedName + do { + // Add header comment + let dateFormatter = ISO8601DateFormatter() + try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".data(using: .utf8)!) - let sqlOptions = table.sqlOptions - let tableRef = qualifiedTableRef(for: table) + for (index, table) in tables.enumerated() { + try checkCancellation() - output += "-- --------------------------------------------------------\n" - output += "-- Table: \(table.qualifiedName)\n" - output += "-- --------------------------------------------------------\n\n" + currentTableIndex = index + 1 + currentTable = table.qualifiedName - // DROP statement - if sqlOptions.includeDrop { - output += "DROP TABLE IF EXISTS \(tableRef);\n\n" - } + let sqlOptions = table.sqlOptions + let tableRef = qualifiedTableRef(for: table) - // CREATE TABLE (structure) - if sqlOptions.includeStructure { - // For cross-database, we need the full reference - let ddl = try await driver.fetchTableDDL(table: tableRef) - output += ddl - if !ddl.hasSuffix(";") { - output += ";" + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- Table: \(table.qualifiedName)\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".data(using: .utf8)!) + + // DROP statement + if sqlOptions.includeDrop { + try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".data(using: .utf8)!) } - output += "\n\n" - } - // INSERT statements (data) - if sqlOptions.includeData { - let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") - - if !result.rows.isEmpty { - output += try await buildInsertStatementsWithProgress( - table: table, - columns: result.columns, - rows: result.rows - ) - output += "\n" + // CREATE TABLE (structure) + if sqlOptions.includeStructure { + let ddl = try await driver.fetchTableDDL(table: tableRef) + try fileHandle.write(contentsOf: ddl.data(using: .utf8)!) + if !ddl.hasSuffix(";") { + try fileHandle.write(contentsOf: ";".data(using: .utf8)!) + } + try fileHandle.write(contentsOf: "\n\n".data(using: .utf8)!) + } + + // INSERT statements (data) - stream directly to file + if sqlOptions.includeData { + let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") + + if !result.rows.isEmpty { + try await writeInsertStatementsWithProgress( + table: table, + columns: result.columns, + rows: result.rows, + to: fileHandle + ) + try fileHandle.write(contentsOf: "\n".data(using: .utf8)!) + } } } + + try fileHandle.close() + } catch { + try? fileHandle.close() + if let tempURL = tempFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + throw error } try checkCancellation() // Handle gzip compression - if config.sqlOptions.compressWithGzip { + if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { statusMessage = "Compressing..." await Task.yield() - guard let data = output.data(using: .utf8) else { - throw ExportError.exportFailed("Failed to encode SQL content") + defer { + try? FileManager.default.removeItem(at: tempURL) } - // Compress directly to destination file (much faster than piping) - try await compressToFile(data, destination: url) - } else { - statusMessage = "Writing file..." - await Task.yield() - - let outputCopy = output - try await Task.detached { - try outputCopy.write(to: url, atomically: true, encoding: .utf8) - }.value + try await compressFileToFile(source: tempURL, destination: url) } progress = 1.0 } - private func buildInsertStatementsWithProgress( + private func writeInsertStatementsWithProgress( table: ExportTableItem, columns: [String], - rows: [[String?]] - ) async throws -> String { - var output = "" + rows: [[String?]], + to fileHandle: FileHandle + ) async throws { let tableRef = qualifiedTableRef(for: table) let quotedColumns = columns .map { databaseType.quoteIdentifier($0) } @@ -496,7 +516,8 @@ final class ExportService: ObservableObject { return "'\(escaped)'" }.joined(separator: ", ") - output += "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES (\(values));\n" + let statement = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES (\(values));\n" + try fileHandle.write(contentsOf: statement.data(using: .utf8)!) // Update progress (throttled) await incrementProgress() @@ -504,48 +525,31 @@ final class ExportService: ObservableObject { // Ensure final count is shown await finalizeTableProgress() - - return output } // MARK: - Compression - private func compressToFile(_ data: Data, destination: URL) async throws { + private func compressFileToFile(source: URL, destination: URL) async throws { // Run compression on background thread to avoid blocking main thread try await Task.detached(priority: .userInitiated) { - let tempDir = FileManager.default.temporaryDirectory - let tempFile = tempDir.appendingPathComponent(UUID().uuidString + ".sql") - - defer { - try? FileManager.default.removeItem(at: tempFile) - // gzip creates tempFile.gz, clean it up if it exists - let gzFile = tempFile.appendingPathExtension("gz") - try? FileManager.default.removeItem(at: gzFile) - } - - // Write uncompressed data to temp file - try data.write(to: tempFile) - - // Use gzip to compress the file in place (creates .sql.gz) + // Use gzip to compress the file let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") - process.arguments = ["-f", tempFile.path] + process.arguments = ["-c", source.path] + + let outputFile = try FileHandle(forWritingTo: { + FileManager.default.createFile(atPath: destination.path, contents: nil) + return destination + }()) + process.standardOutput = outputFile try process.run() process.waitUntilExit() + try outputFile.close() guard process.terminationStatus == 0 else { throw ExportError.compressionFailed } - - // gzip creates file with .gz extension - let compressedFile = tempFile.appendingPathExtension("gz") - - // Move compressed file to destination - if FileManager.default.fileExists(atPath: destination.path) { - try FileManager.default.removeItem(at: destination) - } - try FileManager.default.moveItem(at: compressedFile, to: destination) }.value } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index c42ea4c78..2964a1eb7 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -421,10 +421,12 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { + // Escape single quotes to prevent SQL injection + let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") let query = """ SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(schema)' + WHERE table_schema = '\(escapedSchema)' ORDER BY table_name """ let result = try await driver.execute(query: query) @@ -437,11 +439,13 @@ struct ExportDialog: View { } private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { + // Escape single quotes to prevent SQL injection + let escapedDatabase = database.replacingOccurrences(of: "'", with: "''") // MySQL/MariaDB: query information_schema for tables in specific database let query = """ SELECT TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES - WHERE TABLE_SCHEMA = '\(database)' + WHERE TABLE_SCHEMA = '\(escapedDatabase)' ORDER BY TABLE_NAME """ let result = try await driver.execute(query: query) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 827f06700..2fb60b396 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -293,7 +293,6 @@ struct SidebarView: View { } NotificationCenter.default.post(name: .exportTables, object: nil) } - .keyboardShortcut("e", modifiers: [.command, .shift]) Divider() @@ -320,7 +319,6 @@ struct SidebarView: View { Button("Export...") { NotificationCenter.default.post(name: .exportTables, object: nil) } - .keyboardShortcut("e", modifiers: [.command, .shift]) if !selectedTables.isEmpty { Divider() From 376d0c93750fad94c6aeb1b5e1153ce0471800a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 28 Dec 2025 20:11:16 +0700 Subject: [PATCH 07/35] refactor: unify sidebar context menus into single function - Combine tableContextMenu(for:) and sidebarContextMenu() into tableContextMenu(clickedTable:) - All menu items always visible with disabled state when no selection - Smart behavior: clicking table row without selection uses that table - Cleaner, more maintainable single source of truth --- TablePro/Views/Sidebar/SidebarView.swift | 74 ++++++++---------------- 1 file changed, 25 insertions(+), 49 deletions(-) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 2fb60b396..e06ce2e1d 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -244,7 +244,7 @@ struct SidebarView: View { ) .tag(table) .contextMenu { - tableContextMenu(for: table) + tableContextMenu(clickedTable: table) } } } header: { @@ -260,7 +260,7 @@ struct SidebarView: View { } .listStyle(.sidebar) .contextMenu { - sidebarContextMenu() + tableContextMenu() } .onDeleteCommand { batchToggleDelete() @@ -270,25 +270,32 @@ struct SidebarView: View { } } + /// Unified context menu for sidebar - used for both table rows and empty space + /// - Parameter clickedTable: The table that was right-clicked, nil if clicking empty space @ViewBuilder - private func tableContextMenu(for table: TableInfo) -> some View { + private func tableContextMenu(clickedTable: TableInfo? = nil) -> some View { + let hasSelection = !selectedTables.isEmpty || clickedTable != nil + Button("Create New Table...") { NotificationCenter.default.post(name: .createTable, object: nil) } - .keyboardShortcut("n", modifiers: [.command, .shift]) - + Divider() - + Button("Copy Name") { - let names = selectedTables.isEmpty ? [table.name] : selectedTables.map { $0.name }.sorted() + let names: [String] + if selectedTables.isEmpty, let table = clickedTable { + names = [table.name] + } else { + names = selectedTables.map { $0.name }.sorted() + } NSPasteboard.general.clearContents() NSPasteboard.general.setString(names.joined(separator: ","), forType: .string) } - .keyboardShortcut("c", modifiers: .command) + .disabled(!hasSelection) Button("Export...") { - // Select the table if not already selected - if selectedTables.isEmpty { + if selectedTables.isEmpty, let table = clickedTable { selectedTables.insert(table) } NotificationCenter.default.post(name: .exportTables, object: nil) @@ -297,51 +304,20 @@ struct SidebarView: View { Divider() Button("Truncate") { + if selectedTables.isEmpty, let table = clickedTable { + selectedTables.insert(table) + } batchToggleTruncate() } - .keyboardShortcut(.delete, modifiers: .option) + .disabled(!hasSelection) Button("Delete", role: .destructive) { - batchToggleDelete() - } - .keyboardShortcut(.delete, modifiers: .command) - } - - @ViewBuilder - private func sidebarContextMenu() -> some View { - Button("Create New Table...") { - NotificationCenter.default.post(name: .createTable, object: nil) - } - .keyboardShortcut("n", modifiers: [.command, .shift]) - - Divider() - - Button("Export...") { - NotificationCenter.default.post(name: .exportTables, object: nil) - } - - if !selectedTables.isEmpty { - Divider() - - Button("Copy Name") { - let names = selectedTables.map { $0.name }.sorted() - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(names.joined(separator: ","), forType: .string) - } - .keyboardShortcut("c", modifiers: .command) - - Divider() - - Button("Truncate") { - batchToggleTruncate() - } - .keyboardShortcut(.delete, modifiers: .option) - - Button("Delete", role: .destructive) { - batchToggleDelete() + if selectedTables.isEmpty, let table = clickedTable { + selectedTables.insert(table) } - .keyboardShortcut(.delete, modifiers: .command) + batchToggleDelete() } + .disabled(!hasSelection) } /// Batch toggle truncate for all selected tables From fc68f11324de0e2aba90aec84978a9a2e2bb91dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sun, 28 Dec 2025 20:43:28 +0700 Subject: [PATCH 08/35] fix: address all code review issues - Remove unused Compression import - Replace force unwraps with safe toUTF8Data() helper that throws on failure - Add proper error handling for file creation with guard statements - Stream JSON export directly to file instead of building in memory - Add escapeJSONString() and formatJSONValue() helpers for proper JSON formatting --- TablePro/Core/Services/ExportService.swift | 161 ++++++++++++++++----- 1 file changed, 121 insertions(+), 40 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index f4f3c413c..c65531df1 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -7,7 +7,6 @@ // import Combine -import Compression import Foundation // MARK: - Export Error @@ -19,6 +18,7 @@ enum ExportError: LocalizedError { case exportFailed(String) case compressionFailed case fileWriteFailed(String) + case encodingFailed var errorDescription: String? { switch self { @@ -32,10 +32,24 @@ enum ExportError: LocalizedError { return "Failed to compress data" case .fileWriteFailed(let path): return "Failed to write file: \(path)" + case .encodingFailed: + return "Failed to encode content as UTF-8" } } } +// MARK: - String Extension for Safe Encoding + +private extension String { + /// Safely encode string to UTF-8 data, throwing if encoding fails + func toUTF8Data() throws -> Data { + guard let data = self.data(using: .utf8) else { + throw ExportError.encodingFailed + } + return data + } +} + // MARK: - Export Service /// Service responsible for exporting table data to various formats @@ -217,6 +231,16 @@ final class ExportService: ObservableObject { } } + // MARK: - File Helpers + + /// Create a file at the given URL and return a FileHandle for writing + private func createFileHandle(at url: URL) throws -> FileHandle { + guard FileManager.default.createFile(atPath: url.path, contents: nil) else { + throw ExportError.fileWriteFailed(url.path) + } + return try FileHandle(forWritingTo: url) + } + // MARK: - CSV Export private func exportToCSV( @@ -225,8 +249,7 @@ final class ExportService: ObservableObject { to url: URL ) async throws { // Create file and get handle for streaming writes - FileManager.default.createFile(atPath: url.path, contents: nil) - let fileHandle = try FileHandle(forWritingTo: url) + let fileHandle = try createFileHandle(at: url) defer { try? fileHandle.close() } let lineBreak = config.csvOptions.lineBreak.value @@ -239,7 +262,7 @@ final class ExportService: ObservableObject { // Add table header comment if multiple tables if tables.count > 1 { - try fileHandle.write(contentsOf: "# Table: \(table.qualifiedName)\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "# Table: \(table.qualifiedName)\n".toUTF8Data()) } // Fetch all data from table @@ -255,7 +278,7 @@ final class ExportService: ObservableObject { ) if index < tables.count - 1 { - try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".data(using: .utf8)!) + try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) } } @@ -277,7 +300,7 @@ final class ExportService: ObservableObject { let headerLine = columns .map { escapeCSVField($0, options: options) } .joined(separator: delimiter) - try fileHandle.write(contentsOf: (headerLine + lineBreak).data(using: .utf8)!) + try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data()) } // Data rows with progress tracking - stream directly to file @@ -309,7 +332,7 @@ final class ExportService: ObservableObject { }.joined(separator: delimiter) // Write row directly to file - try fileHandle.write(contentsOf: (rowLine + lineBreak).data(using: .utf8)!) + try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) // Update progress (throttled) await incrementProgress() @@ -346,31 +369,51 @@ final class ExportService: ObservableObject { config: ExportConfiguration, to url: URL ) async throws { - var exportData: [String: [[String: Any]]] = [:] + // Stream JSON directly to file to minimize memory usage + let fileHandle = try createFileHandle(at: url) + defer { try? fileHandle.close() } - for (index, table) in tables.enumerated() { + let prettyPrint = config.jsonOptions.prettyPrint + let indent = prettyPrint ? " " : "" + let newline = prettyPrint ? "\n" : "" + + // Opening brace + try fileHandle.write(contentsOf: "{\(newline)".toUTF8Data()) + + for (tableIndex, table) in tables.enumerated() { try checkCancellation() - currentTableIndex = index + 1 + currentTableIndex = tableIndex + 1 currentTable = table.qualifiedName let tableRef = qualifiedTableRef(for: table) let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") - var tableData: [[String: Any]] = [] - for row in result.rows { + // Write table key and opening bracket + let escapedTableName = escapeJSONString(table.qualifiedName) + try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) + + // Write rows + for (rowIndex, row) in result.rows.enumerated() { try checkCancellation() - var rowDict: [String: Any] = [:] + // Build row object + var rowParts: [String] = [] for (colIndex, column) in result.columns.enumerated() { if colIndex < row.count { let value = row[colIndex] if config.jsonOptions.includeNullValues || value != nil { - rowDict[column] = value ?? NSNull() + let escapedKey = escapeJSONString(column) + let jsonValue = formatJSONValue(value) + rowParts.append("\"\(escapedKey)\": \(jsonValue)") } } } - tableData.append(rowDict) + + let rowJSON = rowParts.joined(separator: ", ") + let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" + let rowSuffix = rowIndex < result.rows.count - 1 ? ",\(newline)" : newline + try fileHandle.write(contentsOf: "\(rowPrefix){\(rowJSON)}\(rowSuffix)".toUTF8Data()) // Update progress (throttled) await incrementProgress() @@ -379,18 +422,55 @@ final class ExportService: ObservableObject { // Ensure final count is shown for this table await finalizeTableProgress() - exportData[table.qualifiedName] = tableData + // Close array + let tableSuffix = tableIndex < tables.count - 1 ? ",\(newline)" : newline + try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) } + // Closing brace + try fileHandle.write(contentsOf: "}".toUTF8Data()) + try checkCancellation() + progress = 1.0 + } + + /// Escape a string for JSON output + private func escapeJSONString(_ string: String) -> String { + var result = "" + for char in string { + switch char { + case "\"": result += "\\\"" + case "\\": result += "\\\\" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + default: result.append(char) + } + } + return result + } - let options: JSONSerialization.WritingOptions = config.jsonOptions.prettyPrint - ? [.prettyPrinted, .sortedKeys] - : [.sortedKeys] + /// Format a value for JSON output + private func formatJSONValue(_ value: String?) -> String { + guard let val = value else { return "null" } - let jsonData = try JSONSerialization.data(withJSONObject: exportData, options: options) - try jsonData.write(to: url) - progress = 1.0 + // Try to detect numbers and booleans + if let intVal = Int(val) { + return String(intVal) + } + if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { + // Avoid scientific notation issues + if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { + return String(Int(doubleVal)) + } + return String(doubleVal) + } + if val.lowercased() == "true" || val.lowercased() == "false" { + return val.lowercased() + } + + // String value - escape and quote + return "\"\(escapeJSONString(val))\"" } // MARK: - SQL Export @@ -415,15 +495,14 @@ final class ExportService: ObservableObject { } // Create file and get handle for streaming writes - FileManager.default.createFile(atPath: targetURL.path, contents: nil) - let fileHandle = try FileHandle(forWritingTo: targetURL) + let fileHandle = try createFileHandle(at: targetURL) do { // Add header comment let dateFormatter = ISO8601DateFormatter() - try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".data(using: .utf8)!) - try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".data(using: .utf8)!) - try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- TablePro SQL Export\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Generated: \(dateFormatter.string(from: Date()))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Database Type: \(databaseType.rawValue)\n\n".toUTF8Data()) for (index, table) in tables.enumerated() { try checkCancellation() @@ -434,23 +513,23 @@ final class ExportService: ObservableObject { let sqlOptions = table.sqlOptions let tableRef = qualifiedTableRef(for: table) - try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".data(using: .utf8)!) - try fileHandle.write(contentsOf: "-- Table: \(table.qualifiedName)\n".data(using: .utf8)!) - try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Table: \(table.qualifiedName)\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) // DROP statement if sqlOptions.includeDrop { - try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".toUTF8Data()) } // CREATE TABLE (structure) if sqlOptions.includeStructure { let ddl = try await driver.fetchTableDDL(table: tableRef) - try fileHandle.write(contentsOf: ddl.data(using: .utf8)!) + try fileHandle.write(contentsOf: ddl.toUTF8Data()) if !ddl.hasSuffix(";") { - try fileHandle.write(contentsOf: ";".data(using: .utf8)!) + try fileHandle.write(contentsOf: ";".toUTF8Data()) } - try fileHandle.write(contentsOf: "\n\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) } // INSERT statements (data) - stream directly to file @@ -464,7 +543,7 @@ final class ExportService: ObservableObject { rows: result.rows, to: fileHandle ) - try fileHandle.write(contentsOf: "\n".data(using: .utf8)!) + try fileHandle.write(contentsOf: "\n".toUTF8Data()) } } } @@ -517,7 +596,7 @@ final class ExportService: ObservableObject { }.joined(separator: ", ") let statement = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES (\(values));\n" - try fileHandle.write(contentsOf: statement.data(using: .utf8)!) + try fileHandle.write(contentsOf: statement.toUTF8Data()) // Update progress (throttled) await incrementProgress() @@ -532,15 +611,17 @@ final class ExportService: ObservableObject { private func compressFileToFile(source: URL, destination: URL) async throws { // Run compression on background thread to avoid blocking main thread try await Task.detached(priority: .userInitiated) { + // Create output file + guard FileManager.default.createFile(atPath: destination.path, contents: nil) else { + throw ExportError.fileWriteFailed(destination.path) + } + // Use gzip to compress the file let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") process.arguments = ["-c", source.path] - let outputFile = try FileHandle(forWritingTo: { - FileManager.default.createFile(atPath: destination.path, contents: nil) - return destination - }()) + let outputFile = try FileHandle(forWritingTo: destination) process.standardOutput = outputFile try process.run() From a457b5e92923df12ae49916224a9c116447ee295 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 21:51:04 +0700 Subject: [PATCH 09/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index c65531df1..bb0c00482 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -524,12 +524,18 @@ final class ExportService: ObservableObject { // CREATE TABLE (structure) if sqlOptions.includeStructure { - let ddl = try await driver.fetchTableDDL(table: tableRef) - try fileHandle.write(contentsOf: ddl.toUTF8Data()) - if !ddl.hasSuffix(";") { - try fileHandle.write(contentsOf: ";".toUTF8Data()) + do { + let ddl = try await driver.fetchTableDDL(table: tableRef) + try fileHandle.write(contentsOf: ddl.toUTF8Data()) + if !ddl.hasSuffix(";") { + try fileHandle.write(contentsOf: ";".toUTF8Data()) + } + try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) + } catch { + let warningMessage = "Warning: failed to fetch DDL for table \(table.qualifiedName): \(error)" + print(warningMessage) + try fileHandle.write(contentsOf: "-- \(warningMessage)\n\n".toUTF8Data()) } - try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) } // INSERT statements (data) - stream directly to file From baed5e72b42afa232f94d65818cafe29659310d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 21:51:20 +0700 Subject: [PATCH 10/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index bb0c00482..73e5fded4 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -628,11 +628,13 @@ final class ExportService: ObservableObject { process.arguments = ["-c", source.path] let outputFile = try FileHandle(forWritingTo: destination) + defer { + try? outputFile.close() + } process.standardOutput = outputFile try process.run() process.waitUntilExit() - try outputFile.close() guard process.terminationStatus == 0 else { throw ExportError.compressionFailed From 94eb1805437ddf688886cdb1088f97ac2ff67a38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Sun, 28 Dec 2025 21:51:55 +0700 Subject: [PATCH 11/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 73e5fded4..ee5f32cac 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -628,16 +628,34 @@ final class ExportService: ObservableObject { process.arguments = ["-c", source.path] let outputFile = try FileHandle(forWritingTo: destination) + defer { + try? outputFile.close() defer { try? outputFile.close() } process.standardOutput = outputFile + // Capture stderr to provide detailed error messages on failure + let errorPipe = Pipe() + process.standardError = errorPipe + try process.run() process.waitUntilExit() - guard process.terminationStatus == 0 else { - throw ExportError.compressionFailed + let status = process.terminationStatus + guard status == 0 else { + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorString = String(data: errorData, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + + let message: String + if errorString.isEmpty { + message = "Compression failed with exit status \(status)" + } else { + message = "Compression failed with exit status \(status): \(errorString)" + } + + throw ExportError.exportFailed(message) } }.value } From fe05cfa50ad350146287b7cc9e7ffc2a29ad7104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 08:22:07 +0700 Subject: [PATCH 12/35] Update TablePro/Views/Export/ExportSuccessView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportSuccessView.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/TablePro/Views/Export/ExportSuccessView.swift b/TablePro/Views/Export/ExportSuccessView.swift index 778c53bb1..7399d9df5 100644 --- a/TablePro/Views/Export/ExportSuccessView.swift +++ b/TablePro/Views/Export/ExportSuccessView.swift @@ -14,8 +14,13 @@ struct ExportSuccessView: View { let onClose: () -> Void @AppStorage("hideExportSuccessDialog") private var dontShowAgain = false - @State private var localDontShowAgain = false + @State private var localDontShowAgain: Bool + init(onOpenFolder: @escaping () -> Void, onClose: @escaping () -> Void) { + self.onOpenFolder = onOpenFolder + self.onClose = onClose + _localDontShowAgain = State(initialValue: dontShowAgain) + } var body: some View { VStack(spacing: 20) { // Success icon From d1db29e33eacce0cc650eb33f562671d4bbb95d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 08:22:26 +0700 Subject: [PATCH 13/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index ee5f32cac..58bcded11 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -570,11 +570,18 @@ final class ExportService: ObservableObject { statusMessage = "Compressing..." await Task.yield() - defer { - try? FileManager.default.removeItem(at: tempURL) - } + do { + defer { + // Always remove the temporary file, regardless of success or failure + try? FileManager.default.removeItem(at: tempURL) + } - try await compressFileToFile(source: tempURL, destination: url) + try await compressFileToFile(source: tempURL, destination: url) + } catch { + // Remove the (possibly partially written) destination file on compression failure + try? FileManager.default.removeItem(at: url) + throw error + } } progress = 1.0 From 8b65d67663ab85d9b4df15acceca1dc0b7b0144e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 08:22:45 +0700 Subject: [PATCH 14/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 58bcded11..2a6e27bb0 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -397,23 +397,34 @@ final class ExportService: ObservableObject { for (rowIndex, row) in result.rows.enumerated() { try checkCancellation() - // Build row object - var rowParts: [String] = [] + // Stream JSON row object directly to file to avoid building large strings in memory + let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" + let rowSuffix = rowIndex < result.rows.count - 1 ? ",\(newline)" : newline + + // Write row prefix and opening brace + try fileHandle.write(contentsOf: rowPrefix.toUTF8Data()) + try fileHandle.write(contentsOf: "{".toUTF8Data()) + + var isFirstField = true for (colIndex, column) in result.columns.enumerated() { if colIndex < row.count { let value = row[colIndex] if config.jsonOptions.includeNullValues || value != nil { + if !isFirstField { + try fileHandle.write(contentsOf: ", ".toUTF8Data()) + } + isFirstField = false + let escapedKey = escapeJSONString(column) let jsonValue = formatJSONValue(value) - rowParts.append("\"\(escapedKey)\": \(jsonValue)") + try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data()) } } } - let rowJSON = rowParts.joined(separator: ", ") - let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" - let rowSuffix = rowIndex < result.rows.count - 1 ? ",\(newline)" : newline - try fileHandle.write(contentsOf: "\(rowPrefix){\(rowJSON)}\(rowSuffix)".toUTF8Data()) + // Close row object and write row suffix + try fileHandle.write(contentsOf: "}".toUTF8Data()) + try fileHandle.write(contentsOf: rowSuffix.toUTF8Data()) // Update progress (throttled) await incrementProgress() From 764e8fda8c63b64c7de7899c40498f35daeddc6e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 08:45:11 +0700 Subject: [PATCH 15/35] wip --- .../SQLStatementGenerator.swift | 10 +--- TablePro/Core/Database/SQLEscaping.swift | 57 +++++++++++++++++++ .../Core/Services/CreateTableService.swift | 10 +--- TablePro/Core/Services/ExportService.swift | 47 ++++++++++----- TablePro/Models/ExportModels.swift | 7 +++ .../Views/Export/ExportCSVOptionsView.swift | 4 ++ TablePro/Views/Export/ExportDialog.swift | 25 ++++++-- .../Views/Export/ExportJSONOptionsView.swift | 4 ++ TablePro/Views/Export/ExportSuccessView.swift | 3 +- 9 files changed, 130 insertions(+), 37 deletions(-) create mode 100644 TablePro/Core/Database/SQLEscaping.swift diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 5996deda5..733ebd37c 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -397,14 +397,8 @@ struct SQLStatementGenerator { } /// Escape characters that can break SQL strings + /// Delegates to shared SQLEscaping utility for consistent escaping across the codebase private func escapeSQLString(_ str: String) -> String { - var result = str - result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first - result = result.replacingOccurrences(of: "'", with: "''") // Single quote - result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline - result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return - result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab - result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte - return result + SQLEscaping.escapeStringLiteral(str) } } diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift new file mode 100644 index 000000000..f93da331f --- /dev/null +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -0,0 +1,57 @@ +// +// SQLEscaping.swift +// TablePro +// +// Shared utilities for SQL string escaping to prevent SQL injection. +// Used across ExportService, SQLStatementGenerator, and other SQL-generating code. +// + +import Foundation + +/// Centralized SQL escaping utilities to prevent SQL injection vulnerabilities +enum SQLEscaping { + + /// Escape a string value for use in SQL string literals (VALUES, WHERE clauses, etc.) + /// + /// Handles the following special characters: + /// - Backslashes (must be escaped first to avoid double-escaping) + /// - Single quotes (SQL standard: doubled) + /// - Newlines, carriage returns, tabs, null bytes + /// + /// Example: + /// ```swift + /// let safe = SQLEscaping.escapeStringLiteral("O'Brien\\test") + /// // Result: "O''Brien\\\\test" + /// let sql = "INSERT INTO users (name) VALUES ('\(safe)')" + /// ``` + /// + /// - Parameter str: The raw string to escape + /// - Returns: The escaped string safe for use in SQL string literals + static func escapeStringLiteral(_ str: String) -> String { + var result = str + // IMPORTANT: Escape backslashes FIRST to avoid double-escaping + result = result.replacingOccurrences(of: "\\", with: "\\\\") + // Single quote: SQL standard escaping (double the quote) + result = result.replacingOccurrences(of: "'", with: "''") + // Control characters + result = result.replacingOccurrences(of: "\n", with: "\\n") + result = result.replacingOccurrences(of: "\r", with: "\\r") + result = result.replacingOccurrences(of: "\t", with: "\\t") + result = result.replacingOccurrences(of: "\0", with: "\\0") + return result + } + + /// Escape wildcards in LIKE patterns while preserving intentional wildcards + /// + /// This is useful when building LIKE clauses where the search term should be treated literally. + /// + /// - Parameter value: The value to escape + /// - Returns: The escaped value with %, _, and \ escaped + static func escapeLikeWildcards(_ value: String) -> String { + var result = value + result = result.replacingOccurrences(of: "\\", with: "\\\\") + result = result.replacingOccurrences(of: "%", with: "\\%") + result = result.replacingOccurrences(of: "_", with: "\\_") + return result + } +} diff --git a/TablePro/Core/Services/CreateTableService.swift b/TablePro/Core/Services/CreateTableService.swift index b02aa5d08..f804e23c7 100644 --- a/TablePro/Core/Services/CreateTableService.swift +++ b/TablePro/Core/Services/CreateTableService.swift @@ -545,15 +545,9 @@ struct CreateTableService { } /// Escape characters that can break SQL strings + /// Delegates to shared SQLEscaping utility for consistent escaping across the codebase private func escapeSQLString(_ str: String) -> String { - var result = str - result = result.replacingOccurrences(of: "\\", with: "\\\\") // Backslash first - result = result.replacingOccurrences(of: "'", with: "''") // Single quote (SQL standard) - result = result.replacingOccurrences(of: "\n", with: "\\n") // Newline - result = result.replacingOccurrences(of: "\r", with: "\\r") // Carriage return - result = result.replacingOccurrences(of: "\t", with: "\\t") // Tab - result = result.replacingOccurrences(of: "\0", with: "\\0") // Null byte - return result + SQLEscaping.escapeStringLiteral(str) } } diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 2a6e27bb0..5c5431640 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -343,22 +343,34 @@ final class ExportService: ObservableObject { } private func escapeCSVField(_ field: String, options: CSVExportOptions) -> String { + var processed = field + + // Sanitize formula-like prefixes to prevent CSV formula injection + // Values starting with these characters can be executed as formulas in Excel/LibreOffice + if options.sanitizeFormulas { + let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"] + if let first = processed.first, dangerousPrefixes.contains(first) { + // Prefix with single quote - Excel/LibreOffice treats this as text + processed = "'" + processed + } + } + switch options.quoteHandling { case .always: - let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" case .never: - return field + return processed case .asNeeded: - let needsQuotes = field.contains(options.delimiter.actualValue) || - field.contains("\"") || - field.contains("\n") || - field.contains("\r") + let needsQuotes = processed.contains(options.delimiter.actualValue) || + processed.contains("\"") || + processed.contains("\n") || + processed.contains("\r") if needsQuotes { - let escaped = field.replacingOccurrences(of: "\"", with: "\"\"") + let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" } - return field + return processed } } @@ -416,7 +428,7 @@ final class ExportService: ObservableObject { isFirstField = false let escapedKey = escapeJSONString(column) - let jsonValue = formatJSONValue(value) + let jsonValue = formatJSONValue(value, preserveAsString: config.jsonOptions.preserveAllAsStrings) try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data()) } } @@ -462,9 +474,18 @@ final class ExportService: ObservableObject { } /// Format a value for JSON output - private func formatJSONValue(_ value: String?) -> String { + /// - Parameters: + /// - value: The value to format + /// - preserveAsString: If true, always output as string without type detection + /// (preserves leading zeros in ZIP codes, phone numbers, etc.) + private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { guard let val = value else { return "null" } + // If preserving all as strings, skip type detection + if preserveAsString { + return "\"\(escapeJSONString(val))\"" + } + // Try to detect numbers and booleans if let intVal = Int(val) { return String(intVal) @@ -614,8 +635,8 @@ final class ExportService: ObservableObject { let values = row.map { value -> String in guard let val = value else { return "NULL" } - // Escape single quotes by doubling them - let escaped = val.replacingOccurrences(of: "'", with: "''") + // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) + let escaped = SQLEscaping.escapeStringLiteral(val) return "'\(escaped)'" }.joined(separator: ", ") @@ -646,8 +667,6 @@ final class ExportService: ObservableObject { process.arguments = ["-c", source.path] let outputFile = try FileHandle(forWritingTo: destination) - defer { - try? outputFile.close() defer { try? outputFile.close() } diff --git a/TablePro/Models/ExportModels.swift b/TablePro/Models/ExportModels.swift index 6e96ee798..49e81e6d3 100644 --- a/TablePro/Models/ExportModels.swift +++ b/TablePro/Models/ExportModels.swift @@ -100,6 +100,10 @@ struct CSVExportOptions: Equatable { var quoteHandling: CSVQuoteHandling = .asNeeded var lineBreak: CSVLineBreak = .lf var decimalFormat: CSVDecimalFormat = .period + /// Sanitize formula-like values to prevent CSV formula injection attacks. + /// When enabled, values starting with =, +, -, @, tab, or carriage return + /// are prefixed with a single quote to prevent execution in spreadsheet applications. + var sanitizeFormulas: Bool = true } // MARK: - JSON Options @@ -108,6 +112,9 @@ struct CSVExportOptions: Equatable { struct JSONExportOptions: Equatable { var prettyPrint: Bool = true var includeNullValues: Bool = true + /// When enabled, all values are exported as strings without type detection. + /// This preserves leading zeros in ZIP codes, phone numbers, and similar data. + var preserveAllAsStrings: Bool = false } // MARK: - SQL Options diff --git a/TablePro/Views/Export/ExportCSVOptionsView.swift b/TablePro/Views/Export/ExportCSVOptionsView.swift index 3944c7f9e..fbac5b1a7 100644 --- a/TablePro/Views/Export/ExportCSVOptionsView.swift +++ b/TablePro/Views/Export/ExportCSVOptionsView.swift @@ -24,6 +24,10 @@ struct ExportCSVOptionsView: View { Toggle("Put field names in the first row", isOn: $options.includeFieldNames) .toggleStyle(.checkbox) + + Toggle("Sanitize formula-like values", isOn: $options.sanitizeFormulas) + .toggleStyle(.checkbox) + .help("Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote") } Divider() diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 2964a1eb7..e34d996d8 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -278,7 +278,7 @@ struct ExportDialog: View { } .buttonStyle(.borderedProminent) .keyboardShortcut(.return, modifiers: []) - .disabled(selectedCount == 0 || isExporting) + .disabled(selectedCount == 0 || isExporting || !isFileNameValid) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -301,6 +301,21 @@ struct ExportDialog: View { return config.format.fileExtension } + /// Validates that the filename is not empty and contains no invalid filesystem characters + private var isFileNameValid: Bool { + let name = config.fileName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty else { return false } + + // Invalid filesystem characters (covers macOS, Windows, and Linux) + let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") + guard name.rangeOfCharacter(from: invalidChars) == nil else { return false } + + // Prevent path traversal attempts + guard !name.contains("..") else { return false } + + return true + } + // MARK: - Actions @MainActor @@ -421,8 +436,8 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Escape single quotes to prevent SQL injection - let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") + // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) + let escapedSchema = SQLEscaping.escapeStringLiteral(schema) let query = """ SELECT table_name, table_type FROM information_schema.tables @@ -439,8 +454,8 @@ struct ExportDialog: View { } private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Escape single quotes to prevent SQL injection - let escapedDatabase = database.replacingOccurrences(of: "'", with: "''") + // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) + let escapedDatabase = SQLEscaping.escapeStringLiteral(database) // MySQL/MariaDB: query information_schema for tables in specific database let query = """ SELECT TABLE_NAME, TABLE_TYPE diff --git a/TablePro/Views/Export/ExportJSONOptionsView.swift b/TablePro/Views/Export/ExportJSONOptionsView.swift index dc9488257..ac4d721c2 100644 --- a/TablePro/Views/Export/ExportJSONOptionsView.swift +++ b/TablePro/Views/Export/ExportJSONOptionsView.swift @@ -19,6 +19,10 @@ struct ExportJSONOptionsView: View { Toggle("Include NULL values", isOn: $options.includeNullValues) .toggleStyle(.checkbox) + + Toggle("Preserve all values as strings", isOn: $options.preserveAllAsStrings) + .toggleStyle(.checkbox) + .help("Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings") } .font(.system(size: DesignConstants.FontSize.body)) } diff --git a/TablePro/Views/Export/ExportSuccessView.swift b/TablePro/Views/Export/ExportSuccessView.swift index 7399d9df5..c82552079 100644 --- a/TablePro/Views/Export/ExportSuccessView.swift +++ b/TablePro/Views/Export/ExportSuccessView.swift @@ -14,12 +14,11 @@ struct ExportSuccessView: View { let onClose: () -> Void @AppStorage("hideExportSuccessDialog") private var dontShowAgain = false - @State private var localDontShowAgain: Bool + @State private var localDontShowAgain = false init(onOpenFolder: @escaping () -> Void, onClose: @escaping () -> Void) { self.onOpenFolder = onOpenFolder self.onClose = onClose - _localDontShowAgain = State(initialValue: dontShowAgain) } var body: some View { VStack(spacing: 20) { From ed798d9b7c61b2f7337abae73a62aa4bc7b5c145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:02:43 +0700 Subject: [PATCH 16/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 2 -- 1 file changed, 2 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 5c5431640..525ab5a3d 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -595,8 +595,6 @@ final class ExportService: ObservableObject { throw error } - try checkCancellation() - // Handle gzip compression if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { statusMessage = "Compressing..." From de4844409293e5e9fa3567dceba7451693680638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:02:50 +0700 Subject: [PATCH 17/35] Update TablePro/Views/Sidebar/SidebarView.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Sidebar/SidebarView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index e06ce2e1d..f0e788db8 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -300,6 +300,7 @@ struct SidebarView: View { } NotificationCenter.default.post(name: .exportTables, object: nil) } + .disabled(!hasSelection) Divider() From 80ad0a2de731b07bf3e704c0d6d0cf4619811d12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:02:59 +0700 Subject: [PATCH 18/35] Update TablePro/Views/Export/ExportDialog.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportDialog.swift | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index e34d996d8..201678ca0 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -310,8 +310,13 @@ struct ExportDialog: View { let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") guard name.rangeOfCharacter(from: invalidChars) == nil else { return false } - // Prevent path traversal attempts - guard !name.contains("..") else { return false } + // Prevent path traversal attempts where ".." is used as a path component + let isPathTraversalPattern = + name == ".." || + name.hasPrefix("../") || name.hasPrefix("..\\") || + name.hasSuffix("/..") || name.hasSuffix("\\..") || + name.contains("/../") || name.contains("\\..\\") + guard !isPathTraversalPattern else { return false } return true } From 5e29e6f1770cb56b7e648ebea7ef9b97e825cf04 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 09:16:25 +0700 Subject: [PATCH 19/35] wip --- TablePro/Core/Database/MySQLDriver.swift | 4 +- TablePro/Core/Services/ExportService.swift | 57 +++++++++++++++++-- TablePro/Models/ExportModels.swift | 9 +++ TablePro/Views/Export/ExportDialog.swift | 40 ++++++++++--- .../Views/Export/ExportSQLOptionsView.swift | 22 +++++++ 5 files changed, 117 insertions(+), 15 deletions(-) diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index 1c9bab068..8a421465b 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -288,7 +288,9 @@ final class MySQLDriver: DatabaseDriver { } func fetchTableDDL(table: String) async throws -> String { - let query = "SHOW CREATE TABLE `\(table)`" + // Note: table parameter may already be quoted (e.g., `db`.`table`) + // so don't add additional backticks + let query = "SHOW CREATE TABLE \(table)" let result = try await execute(query: query) // SHOW CREATE TABLE returns 2 columns: Table name and Create Table statement diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 525ab5a3d..00cf29c92 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -164,8 +164,10 @@ final class ExportService: ObservableObject { } /// Fetch total row count for all tables + /// Returns the total count and a flag indicating if any counts failed private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { var total = 0 + var failedCount = 0 for table in tables { let tableRef = qualifiedTableRef(for: table) do { @@ -174,9 +176,14 @@ final class ExportService: ObservableObject { total += count } } catch { - // If count fails, estimate based on 0 (progress will be less accurate) + // Log the error but continue - progress will be less accurate + failedCount += 1 + print("Warning: Failed to get row count for \(table.qualifiedName): \(error.localizedDescription)") } } + if failedCount > 0 { + print("Warning: \(failedCount) table(s) failed row count - progress indicator may be inaccurate") + } return total } @@ -314,6 +321,9 @@ final class ExportService: ObservableObject { var processed = val + // Check for line breaks BEFORE converting them (for quote detection) + let hadLineBreaks = val.contains("\n") || val.contains("\r") + // Convert line breaks to space if options.convertLineBreakToSpace { processed = processed @@ -328,7 +338,7 @@ final class ExportService: ObservableObject { processed = processed.replacingOccurrences(of: ".", with: ",") } - return escapeCSVField(processed, options: options) + return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks) }.joined(separator: delimiter) // Write row directly to file @@ -342,7 +352,13 @@ final class ExportService: ObservableObject { await finalizeTableProgress() } - private func escapeCSVField(_ field: String, options: CSVExportOptions) -> String { + /// Escape and quote a CSV field according to the specified options + /// - Parameters: + /// - field: The field value to escape + /// - options: CSV export options + /// - originalHadLineBreaks: Whether the original value had line breaks before conversion. + /// Used for proper quote detection when convertLineBreakToSpace is enabled. + private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { var processed = field // Sanitize formula-like prefixes to prevent CSV formula injection @@ -362,10 +378,14 @@ final class ExportService: ObservableObject { case .never: return processed case .asNeeded: + // Check current content for special characters, OR if original had line breaks + // (important when convertLineBreakToSpace is enabled - original line breaks + // mean the field should still be quoted even after conversion to spaces) let needsQuotes = processed.contains(options.delimiter.actualValue) || processed.contains("\"") || processed.contains("\n") || - processed.contains("\r") + processed.contains("\r") || + originalHadLineBreaks if needsQuotes { let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"") return "\"\(escaped)\"" @@ -579,6 +599,7 @@ final class ExportService: ObservableObject { table: table, columns: result.columns, rows: result.rows, + batchSize: config.sqlOptions.batchSize, to: fileHandle ) try fileHandle.write(contentsOf: "\n".toUTF8Data()) @@ -621,6 +642,7 @@ final class ExportService: ObservableObject { table: ExportTableItem, columns: [String], rows: [[String?]], + batchSize: Int, to fileHandle: FileHandle ) async throws { let tableRef = qualifiedTableRef(for: table) @@ -628,6 +650,13 @@ final class ExportService: ObservableObject { .map { databaseType.quoteIdentifier($0) } .joined(separator: ", ") + let insertPrefix = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES\n" + + // Effective batch size (<=1 means no batching, one row per INSERT) + let effectiveBatchSize = batchSize <= 1 ? 1 : batchSize + var valuesBatch: [String] = [] + valuesBatch.reserveCapacity(effectiveBatchSize) + for row in rows { try checkCancellation() @@ -638,13 +667,25 @@ final class ExportService: ObservableObject { return "'\(escaped)'" }.joined(separator: ", ") - let statement = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES (\(values));\n" - try fileHandle.write(contentsOf: statement.toUTF8Data()) + valuesBatch.append(" (\(values))") + + // Write batch when full + if valuesBatch.count >= effectiveBatchSize { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + valuesBatch.removeAll(keepingCapacity: true) + } // Update progress (throttled) await incrementProgress() } + // Write remaining rows in final batch + if !valuesBatch.isEmpty { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + } + // Ensure final count is shown await finalizeTableProgress() } @@ -679,6 +720,10 @@ final class ExportService: ObservableObject { let status = process.terminationStatus guard status == 0 else { + // Explicitly close the file handle before throwing to ensure + // the destination file can be deleted in the error handler + try? outputFile.close() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() let errorString = String(data: errorData, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" diff --git a/TablePro/Models/ExportModels.swift b/TablePro/Models/ExportModels.swift index 49e81e6d3..f25fefd5d 100644 --- a/TablePro/Models/ExportModels.swift +++ b/TablePro/Models/ExportModels.swift @@ -124,11 +124,20 @@ struct SQLTableExportOptions: Equatable { var includeStructure: Bool = true var includeDrop: Bool = true var includeData: Bool = true + + /// Returns true if at least one export option is enabled + var hasAnyOption: Bool { + includeStructure || includeDrop || includeData + } } /// Global options for SQL export struct SQLExportOptions: Equatable { var compressWithGzip: Bool = false + /// Number of rows per INSERT statement. Default 500. + /// Higher values = fewer statements, smaller file, faster import. + /// Set to 1 for single-row INSERT statements (legacy behavior). + var batchSize: Int = 500 } // MARK: - Export Configuration diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 201678ca0..4a14f1b11 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -193,11 +193,20 @@ struct ExportDialog: View { Spacer() } - // Selection count - Text("\(selectedCount) table\(selectedCount == 1 ? "" : "s") selected") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .center) + // Selection count (shows exportable count for SQL format when some tables have no options) + VStack(spacing: 2) { + Text("\(exportableCount) table\(exportableCount == 1 ? "" : "s") to export") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.secondary) + + // Show warning if some selected tables will be skipped (SQL format only) + if config.format == .sql && exportableCount < selectedCount { + Text("\(selectedCount - exportableCount) skipped (no options)") + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.orange) + } + } + .frame(maxWidth: .infinity, alignment: .center) } .padding(.horizontal, 16) .padding(.top, 16) @@ -278,7 +287,7 @@ struct ExportDialog: View { } .buttonStyle(.borderedProminent) .keyboardShortcut(.return, modifiers: []) - .disabled(selectedCount == 0 || isExporting || !isFileNameValid) + .disabled(exportableCount == 0 || isExporting || !isFileNameValid) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -294,6 +303,21 @@ struct ExportDialog: View { databaseItems.flatMap { $0.selectedTables } } + /// Tables that will actually be exported (filters out SQL tables with no options enabled) + private var exportableTables: [ExportTableItem] { + let tables = selectedTables + // For SQL format, filter out tables with all options disabled (no output would be generated) + if config.format == .sql { + return tables.filter { $0.sqlOptions.hasAnyOption } + } + return tables + } + + /// Count of tables that will actually produce output + private var exportableCount: Int { + exportableTables.count + } + private var fileExtension: String { if config.format == .sql && config.sqlOptions.compressWithGzip { return "sql.gz" @@ -494,7 +518,7 @@ struct ExportDialog: View { savePanel.nameFieldStringValue = config.fullFileName } - savePanel.message = "Export \(selectedCount) table(s) to \(config.format.rawValue)" + savePanel.message = "Export \(exportableCount) table(s) to \(config.format.rawValue)" savePanel.begin { response in guard response == .OK, let url = savePanel.url else { return } @@ -527,7 +551,7 @@ struct ExportDialog: View { do { try await service.export( - tables: selectedTables, + tables: exportableTables, config: config, to: url ) diff --git a/TablePro/Views/Export/ExportSQLOptionsView.swift b/TablePro/Views/Export/ExportSQLOptionsView.swift index 1c49202ae..e8e704082 100644 --- a/TablePro/Views/Export/ExportSQLOptionsView.swift +++ b/TablePro/Views/Export/ExportSQLOptionsView.swift @@ -13,6 +13,9 @@ import SwiftUI struct ExportSQLOptionsView: View { @Binding var options: SQLExportOptions + /// Available batch size options + private static let batchSizeOptions = [1, 100, 500, 1000] + var body: some View { VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { // Info text about per-table options @@ -24,6 +27,25 @@ struct ExportSQLOptionsView: View { Divider() .padding(.vertical, DesignConstants.Spacing.xxs) + // Batch size picker + HStack { + Text("Rows per INSERT") + .font(.system(size: DesignConstants.FontSize.body)) + + Spacer() + + Picker("", selection: $options.batchSize) { + ForEach(Self.batchSizeOptions, id: \.self) { size in + Text(size == 1 ? "1 (no batching)" : "\(size)") + .tag(size) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 130) + } + .help("Higher values create fewer INSERT statements, resulting in smaller files and faster imports") + // Global compression option Toggle("Compress the file using Gzip", isOn: $options.compressWithGzip) .toggleStyle(.checkbox) From a411d587ea499d971156dc0df9d0643f5d3fed8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:28:45 +0700 Subject: [PATCH 20/35] Update TablePro/Core/Database/MySQLDriver.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Database/MySQLDriver.swift | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Database/MySQLDriver.swift b/TablePro/Core/Database/MySQLDriver.swift index 8a421465b..b1393b69d 100644 --- a/TablePro/Core/Database/MySQLDriver.swift +++ b/TablePro/Core/Database/MySQLDriver.swift @@ -288,8 +288,15 @@ final class MySQLDriver: DatabaseDriver { } func fetchTableDDL(table: String) async throws -> String { - // Note: table parameter may already be quoted (e.g., `db`.`table`) - // so don't add additional backticks + // The `table` argument must be a valid MySQL/MariaDB table identifier, optionally + // schema-qualified, and is interpolated verbatim into the query. Examples: + // - "users" + // - "`mydb`.`users`" + // - "`users`" + // + // This method does not add any quoting or escaping around `table`. It is the + // caller's responsibility to provide a correctly formatted and safely quoted + // identifier when needed. let query = "SHOW CREATE TABLE \(table)" let result = try await execute(query: query) From ccb171ce864331dd2f0394c8301cf17d1d800933 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:29:23 +0700 Subject: [PATCH 21/35] Update TablePro/Views/Export/ExportDialog.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportDialog.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 4a14f1b11..b8ac1258a 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -465,18 +465,22 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) - let escapedSchema = SQLEscaping.escapeStringLiteral(schema) + // Fetch tables from information_schema and filter by schema in Swift to avoid SQL interpolation. let query = """ - SELECT table_name, table_type + SELECT table_schema, table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(escapedSchema)' ORDER BY table_name """ let result = try await driver.execute(query: query) return result.rows.compactMap { row in - guard let name = row[0] else { return nil } - let typeStr = row.count > 1 ? (row[1] ?? "BASE TABLE") : "BASE TABLE" + // Expect: [table_schema, table_name, table_type] + guard row.count >= 2, + let rowSchema = row[0], + rowSchema == schema, + let name = row[1] else { + return nil + } + let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } From dcd96aab4fb3dbdd4a960d0d109706d36262f95e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 09:35:04 +0700 Subject: [PATCH 22/35] wip --- TablePro/Core/Database/SQLEscaping.swift | 9 +++++++-- TablePro/Core/Services/ExportService.swift | 20 ++++++++++++++++++-- TablePro/Views/Export/ExportDialog.swift | 18 ++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Database/SQLEscaping.swift b/TablePro/Core/Database/SQLEscaping.swift index f93da331f..f3ffca0d7 100644 --- a/TablePro/Core/Database/SQLEscaping.swift +++ b/TablePro/Core/Database/SQLEscaping.swift @@ -16,7 +16,8 @@ enum SQLEscaping { /// Handles the following special characters: /// - Backslashes (must be escaped first to avoid double-escaping) /// - Single quotes (SQL standard: doubled) - /// - Newlines, carriage returns, tabs, null bytes + /// - Control characters: null, backspace, tab, newline, form feed, carriage return + /// - MySQL EOF marker (\x1A) which can cause parsing issues /// /// Example: /// ```swift @@ -33,11 +34,15 @@ enum SQLEscaping { result = result.replacingOccurrences(of: "\\", with: "\\\\") // Single quote: SQL standard escaping (double the quote) result = result.replacingOccurrences(of: "'", with: "''") - // Control characters + // Common control characters result = result.replacingOccurrences(of: "\n", with: "\\n") result = result.replacingOccurrences(of: "\r", with: "\\r") result = result.replacingOccurrences(of: "\t", with: "\\t") result = result.replacingOccurrences(of: "\0", with: "\\0") + // Additional control characters that can cause issues + result = result.replacingOccurrences(of: "\u{08}", with: "\\b") // Backspace + result = result.replacingOccurrences(of: "\u{0C}", with: "\\f") // Form feed + result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z") // MySQL EOF marker (Ctrl+Z) return result } diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 00cf29c92..8a566ec8e 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -493,11 +493,17 @@ final class ExportService: ObservableObject { return result } - /// Format a value for JSON output + /// Format a value for JSON output with optional type detection + /// /// - Parameters: /// - value: The value to format /// - preserveAsString: If true, always output as string without type detection /// (preserves leading zeros in ZIP codes, phone numbers, etc.) + /// + /// - Note: When type detection is enabled (preserveAsString = false), integers beyond + /// JavaScript's Number.MAX_SAFE_INTEGER (2^53-1 = 9007199254740991) may lose precision + /// when parsed by JavaScript. For large IDs or precise numeric data, enable the + /// "Preserve All Values as Strings" option in export settings. private func formatJSONValue(_ value: String?, preserveAsString: Bool) -> String { guard let val = value else { return "null" } @@ -507,6 +513,7 @@ final class ExportService: ObservableObject { } // Try to detect numbers and booleans + // Note: Large integers (> 2^53-1) may lose precision in JavaScript consumers if let intVal = Int(val) { return String(intVal) } @@ -695,6 +702,15 @@ final class ExportService: ObservableObject { private func compressFileToFile(source: URL, destination: URL) async throws { // Run compression on background thread to avoid blocking main thread try await Task.detached(priority: .userInitiated) { + // Pre-flight check: verify gzip is available + let gzipPath = "/usr/bin/gzip" + guard FileManager.default.isExecutableFile(atPath: gzipPath) else { + throw ExportError.exportFailed( + "Compression unavailable: gzip not found at \(gzipPath). " + + "Please install gzip or disable compression in export options." + ) + } + // Create output file guard FileManager.default.createFile(atPath: destination.path, contents: nil) else { throw ExportError.fileWriteFailed(destination.path) @@ -702,7 +718,7 @@ final class ExportService: ObservableObject { // Use gzip to compress the file let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/gzip") + process.executableURL = URL(fileURLWithPath: gzipPath) process.arguments = ["-c", source.path] let outputFile = try FileHandle(forWritingTo: destination) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index b8ac1258a..1fde1af3c 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -325,6 +325,13 @@ struct ExportDialog: View { return config.format.fileExtension } + /// Windows reserved device names (case-insensitive) + private static let windowsReservedNames: Set = [ + "CON", "PRN", "AUX", "NUL", + "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", + "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" + ] + /// Validates that the filename is not empty and contains no invalid filesystem characters private var isFileNameValid: Bool { let name = config.fileName.trimmingCharacters(in: .whitespaces) @@ -342,6 +349,17 @@ struct ExportDialog: View { name.contains("/../") || name.contains("\\..\\") guard !isPathTraversalPattern else { return false } + // Check for Windows reserved device names (case-insensitive) + // These can cause issues if the export file is copied to Windows + let baseName = name.components(separatedBy: ".").first ?? name + guard !Self.windowsReservedNames.contains(baseName.uppercased()) else { return false } + + // Prevent hidden files on Unix (starting with .) + guard !name.hasPrefix(".") else { return false } + + // Check filename length (255 bytes is common limit on most filesystems) + guard name.utf8.count <= 255 else { return false } + return true } From dc1518b5d06aba51131ce8c3017bd08b7b264621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:50:21 +0700 Subject: [PATCH 23/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 8a566ec8e..62a1cd6b5 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -520,7 +520,13 @@ final class ExportService: ObservableObject { if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { // Avoid scientific notation issues if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { - return String(Int(doubleVal)) + // Safely convert integral Double to Int only when within bounds + if doubleVal >= Double(Int.min) && doubleVal <= Double(Int.max) { + return String(Int(doubleVal)) + } else { + // Fall back to Double representation to avoid overflow + return String(doubleVal) + } } return String(doubleVal) } From 8683a5a1d2c168e3a65a733cf1463b0d09101c16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:50:56 +0700 Subject: [PATCH 24/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 62a1cd6b5..6d7abd6c3 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -163,8 +163,8 @@ final class ExportService: ObservableObject { } } - /// Fetch total row count for all tables - /// Returns the total count and a flag indicating if any counts failed + /// Fetch total row count for all tables. + /// - Returns: The total row count across all tables. Any failures are logged but do not affect the returned value. private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { var total = 0 var failedCount = 0 From dcb444b30b92df24ab3a5cdc7ba728477c21c0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 09:51:05 +0700 Subject: [PATCH 25/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 6d7abd6c3..764a49c6e 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -725,8 +725,17 @@ final class ExportService: ObservableObject { // Use gzip to compress the file let process = Process() process.executableURL = URL(fileURLWithPath: gzipPath) - process.arguments = ["-c", source.path] + // Derive a sanitized, non-encoded filesystem path for the source + let sanitizedSourcePath = source.standardizedFileURL.path(percentEncoded: false) + + // Basic validation to avoid passing obviously malformed paths to the process + if sanitizedSourcePath.contains("\0") || + sanitizedSourcePath.contains(where: { $0.isNewline }) { + throw ExportError.exportFailed("Invalid source path for compression") + } + + process.arguments = ["-c", sanitizedSourcePath] let outputFile = try FileHandle(forWritingTo: destination) defer { try? outputFile.close() From 549bb55d2c2c94fb20f432f55085b6e4970089bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 09:54:59 +0700 Subject: [PATCH 26/35] wip --- TablePro/Core/Services/ExportService.swift | 46 +++++++++++++++++++--- TablePro/Views/Export/ExportDialog.swift | 4 +- 2 files changed, 42 insertions(+), 8 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 764a49c6e..1b478e494 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -238,6 +238,22 @@ final class ExportService: ObservableObject { } } + /// Sanitize a name for use in SQL comments to prevent comment injection + /// + /// Removes characters that could break out of SQL comments: + /// - Newlines (could start new SQL statements) + /// - Comment terminators (* /) + private func sanitizeForSQLComment(_ name: String) -> String { + var result = name + // Replace newlines with spaces + result = result.replacingOccurrences(of: "\n", with: " ") + result = result.replacingOccurrences(of: "\r", with: " ") + // Remove comment terminators (remove the asterisk-slash sequence) + result = result.replacingOccurrences(of: "*/", with: "") + result = result.replacingOccurrences(of: "--", with: "") + return result + } + // MARK: - File Helpers /// Create a file at the given URL and return a FileHandle for writing @@ -268,8 +284,10 @@ final class ExportService: ObservableObject { currentTable = table.qualifiedName // Add table header comment if multiple tables + // Sanitize name to prevent newlines from breaking the comment line if tables.count > 1 { - try fileHandle.write(contentsOf: "# Table: \(table.qualifiedName)\n".toUTF8Data()) + let sanitizedName = sanitizeForSQLComment(table.qualifiedName) + try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) } // Fetch all data from table @@ -477,9 +495,14 @@ final class ExportService: ObservableObject { progress = 1.0 } - /// Escape a string for JSON output + /// Escape a string for JSON output per RFC 8259 + /// + /// Escapes: + /// - Quotation mark, backslash (required) + /// - Control characters U+0000 to U+001F (required by spec) private func escapeJSONString(_ string: String) -> String { var result = "" + result.reserveCapacity(string.count) for char in string { switch char { case "\"": result += "\\\"" @@ -487,7 +510,16 @@ final class ExportService: ObservableObject { case "\n": result += "\\n" case "\r": result += "\\r" case "\t": result += "\\t" - default: result.append(char) + case "\u{08}": result += "\\b" // Backspace + case "\u{0C}": result += "\\f" // Form feed + default: + // Escape other control characters (U+0000 to U+001F) as \uXXXX + if let scalar = char.unicodeScalars.first, + scalar.value < 0x20 { + result += String(format: "\\u%04X", scalar.value) + } else { + result.append(char) + } } } return result @@ -578,8 +610,9 @@ final class ExportService: ObservableObject { let sqlOptions = table.sqlOptions let tableRef = qualifiedTableRef(for: table) + let sanitizedName = sanitizeForSQLComment(table.qualifiedName) try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- Table: \(table.qualifiedName)\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data()) try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) // DROP statement @@ -597,9 +630,10 @@ final class ExportService: ObservableObject { } try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) } catch { - let warningMessage = "Warning: failed to fetch DDL for table \(table.qualifiedName): \(error)" + // Use sanitizedName (already defined above) for safe comment output + let warningMessage = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" print(warningMessage) - try fileHandle.write(contentsOf: "-- \(warningMessage)\n\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(warningMessage))\n\n".toUTF8Data()) } } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 1fde1af3c..c801e2b3a 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -341,9 +341,9 @@ struct ExportDialog: View { let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") guard name.rangeOfCharacter(from: invalidChars) == nil else { return false } - // Prevent path traversal attempts where ".." is used as a path component + // Prevent path traversal attempts and special directory names let isPathTraversalPattern = - name == ".." || + name == "." || name == ".." || // Current/parent directory name.hasPrefix("../") || name.hasPrefix("..\\") || name.hasSuffix("/..") || name.hasSuffix("\\..") || name.contains("/../") || name.contains("\\..\\") From dcee9e8c88d5bd38892e441224b29cbdc6f98df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:03:38 +0700 Subject: [PATCH 27/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 93 +++++++++++++++------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 1b478e494..aed0a63e2 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -437,53 +437,88 @@ final class ExportService: ObservableObject { currentTable = table.qualifiedName let tableRef = qualifiedTableRef(for: table) - let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") // Write table key and opening bracket let escapedTableName = escapeJSONString(table.qualifiedName) try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) - // Write rows - for (rowIndex, row) in result.rows.enumerated() { + // Stream rows in batches to avoid loading the entire table into memory + let batchSize = 1000 + var offset = 0 + var hasWrittenRow = false + var columns: [String]? = nil + + batchLoop: while true { try checkCancellation() - // Stream JSON row object directly to file to avoid building large strings in memory - let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" - let rowSuffix = rowIndex < result.rows.count - 1 ? ",\(newline)" : newline - - // Write row prefix and opening brace - try fileHandle.write(contentsOf: rowPrefix.toUTF8Data()) - try fileHandle.write(contentsOf: "{".toUTF8Data()) - - var isFirstField = true - for (colIndex, column) in result.columns.enumerated() { - if colIndex < row.count { - let value = row[colIndex] - if config.jsonOptions.includeNullValues || value != nil { - if !isFirstField { - try fileHandle.write(contentsOf: ", ".toUTF8Data()) - } - isFirstField = false + let result = try await driver.execute( + query: "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + ) + + if result.rows.isEmpty { + break batchLoop + } + + if columns == nil { + columns = result.columns + } + + for row in result.rows { + try checkCancellation() - let escapedKey = escapeJSONString(column) - let jsonValue = formatJSONValue(value, preserveAsString: config.jsonOptions.preserveAllAsStrings) - try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data()) + // Stream JSON row object directly to file to avoid building large strings in memory + let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" + + // Write comma/newline before every row except the first + if hasWrittenRow { + try fileHandle.write(contentsOf: ",\(newline)".toUTF8Data()) + } + + // Write row prefix and opening brace + try fileHandle.write(contentsOf: rowPrefix.toUTF8Data()) + try fileHandle.write(contentsOf: "{".toUTF8Data()) + + if let columns = columns { + var isFirstField = true + for (colIndex, column) in columns.enumerated() { + if colIndex < row.count { + let value = row[colIndex] + if config.jsonOptions.includeNullValues || value != nil { + if !isFirstField { + try fileHandle.write(contentsOf: ", ".toUTF8Data()) + } + isFirstField = false + + let escapedKey = escapeJSONString(column) + let jsonValue = formatJSONValue( + value, + preserveAsString: config.jsonOptions.preserveAllAsStrings + ) + try fileHandle.write(contentsOf: "\"\(escapedKey)\": \(jsonValue)".toUTF8Data()) + } + } } } - } - // Close row object and write row suffix - try fileHandle.write(contentsOf: "}".toUTF8Data()) - try fileHandle.write(contentsOf: rowSuffix.toUTF8Data()) + // Close row object + try fileHandle.write(contentsOf: "}".toUTF8Data()) - // Update progress (throttled) - await incrementProgress() + hasWrittenRow = true + + // Update progress (throttled) + await incrementProgress() + } + + offset += result.rows.count } // Ensure final count is shown for this table await finalizeTableProgress() // Close array + if hasWrittenRow { + try fileHandle.write(contentsOf: newline.toUTF8Data()) + } let tableSuffix = tableIndex < tables.count - 1 ? ",\(newline)" : newline try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) } From 8e27851d76179c8af5138701040c80a8bf97f3f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:03:46 +0700 Subject: [PATCH 28/35] Update TablePro/Views/Export/ExportDialog.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportDialog.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index c801e2b3a..cfa71de4e 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -354,9 +354,6 @@ struct ExportDialog: View { let baseName = name.components(separatedBy: ".").first ?? name guard !Self.windowsReservedNames.contains(baseName.uppercased()) else { return false } - // Prevent hidden files on Unix (starting with .) - guard !name.hasPrefix(".") else { return false } - // Check filename length (255 bytes is common limit on most filesystems) guard name.utf8.count <= 255 else { return false } From 1bc3152240242d04583c5e185d70e9e4b890d717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:03:58 +0700 Subject: [PATCH 29/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index aed0a63e2..2206431f6 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -352,7 +352,7 @@ final class ExportService: ObservableObject { // Handle decimal format if options.decimalFormat == .comma, - Double(processed) != nil { + processed.range(of: #"^[+-]?\d+\.\d+$"#, options: .regularExpression) != nil { processed = processed.replacingOccurrences(of: ".", with: ",") } From dc609eb6ce069a23d37bc2dfbae0d0c091537f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:04:06 +0700 Subject: [PATCH 30/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 2206431f6..a14bd16f0 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -586,13 +586,17 @@ final class ExportService: ObservableObject { } if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { // Avoid scientific notation issues + let jsMaxSafeInteger = 9007199254740991.0 // 2^53 - 1, JavaScript's Number.MAX_SAFE_INTEGER + if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { - // Safely convert integral Double to Int only when within bounds - if doubleVal >= Double(Int.min) && doubleVal <= Double(Int.max) { + // For integral values, only convert to Int when within both Int and JS safe integer bounds + if abs(doubleVal) <= jsMaxSafeInteger, + doubleVal >= Double(Int.min), + doubleVal <= Double(Int.max) { return String(Int(doubleVal)) } else { - // Fall back to Double representation to avoid overflow - return String(doubleVal) + // Preserve original integral representation to avoid scientific notation / precision changes + return val } } return String(doubleVal) From 0af9b4e0ba1a450c5f35c49a00993fa41e09b4ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:04:17 +0700 Subject: [PATCH 31/35] Update TablePro/Views/Export/ExportDialog.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Views/Export/ExportDialog.swift | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index cfa71de4e..0e0e78763 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -502,20 +502,24 @@ struct ExportDialog: View { } private func fetchTablesForDatabase(_ database: String, driver: DatabaseDriver) async throws -> [TableInfo] { - // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) - let escapedDatabase = SQLEscaping.escapeStringLiteral(database) - // MySQL/MariaDB: query information_schema for tables in specific database + // Fetch tables from information_schema and filter by database in Swift to avoid SQL interpolation. + // MySQL/MariaDB: information_schema.TABLES contains TABLE_SCHEMA, TABLE_NAME, and TABLE_TYPE. let query = """ - SELECT TABLE_NAME, TABLE_TYPE + SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES - WHERE TABLE_SCHEMA = '\(escapedDatabase)' ORDER BY TABLE_NAME """ let result = try await driver.execute(query: query) return result.rows.compactMap { row in - guard let name = row[0] else { return nil } - let typeStr = row.count > 1 ? (row[1] ?? "BASE TABLE") : "BASE TABLE" + // Expect: [TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE] + guard row.count >= 2, + let rowSchema = row[0], + rowSchema == database, + let name = row[1] else { + return nil + } + let typeStr = row.count > 2 ? (row[2] ?? "BASE TABLE") : "BASE TABLE" let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table return TableInfo(name: name, type: type, rowCount: nil) } From 697362142819bd2165dc4dcfe52616d33eac54ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:04:25 +0700 Subject: [PATCH 32/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 34 +++++++++++++++------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index a14bd16f0..0876e8d48 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -290,18 +290,32 @@ final class ExportService: ObservableObject { try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) } - // Fetch all data from table + // Fetch data from table in batches to avoid loading everything into memory let tableRef = qualifiedTableRef(for: table) - let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") - - // Stream CSV content directly to file - try await writeCSVContentWithProgress( - columns: result.columns, - rows: result.rows, - options: config.csvOptions, - to: fileHandle - ) + let batchSize = 10_000 + var offset = 0 + + while true { + try checkCancellation() + + let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let result = try await driver.execute(query: query) + // No more rows to process + if result.rows.isEmpty { + break + } + + // Stream CSV content for this batch directly to file + try await writeCSVContentWithProgress( + columns: result.columns, + rows: result.rows, + options: config.csvOptions, + to: fileHandle + ) + + offset += batchSize + } if index < tables.count - 1 { try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) } From dab537b934780b1a0fa5016dd9663fc7d29871ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 29 Dec 2025 10:04:43 +0700 Subject: [PATCH 33/35] Update TablePro/Core/Services/ExportService.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- TablePro/Core/Services/ExportService.swift | 25 ++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 0876e8d48..8d0b6489c 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -690,18 +690,35 @@ final class ExportService: ObservableObject { } } - // INSERT statements (data) - stream directly to file + // INSERT statements (data) - stream directly to file in batches if sqlOptions.includeData { - let result = try await driver.execute(query: "SELECT * FROM \(tableRef)") + let batchSize = config.sqlOptions.batchSize + var offset = 0 + var wroteAnyRows = false + + while true { + try checkCancellation() + + let query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" + let result = try await driver.execute(query: query) + + if result.rows.isEmpty { + break + } - if !result.rows.isEmpty { try await writeInsertStatementsWithProgress( table: table, columns: result.columns, rows: result.rows, - batchSize: config.sqlOptions.batchSize, + batchSize: batchSize, to: fileHandle ) + + wroteAnyRows = true + offset += batchSize + } + + if wroteAnyRows { try fileHandle.write(contentsOf: "\n".toUTF8Data()) } } From c856ceb8161ad749bb2a8eafff80d68dcf1f0f4c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 10:08:00 +0700 Subject: [PATCH 34/35] wip --- TablePro/Core/Services/ExportService.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index 8d0b6489c..ec139e4a3 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -240,15 +240,16 @@ final class ExportService: ObservableObject { /// Sanitize a name for use in SQL comments to prevent comment injection /// - /// Removes characters that could break out of SQL comments: + /// Removes characters that could break out of or nest SQL comments: /// - Newlines (could start new SQL statements) - /// - Comment terminators (* /) + /// - Comment sequences (/* */ --) private func sanitizeForSQLComment(_ name: String) -> String { var result = name // Replace newlines with spaces result = result.replacingOccurrences(of: "\n", with: " ") result = result.replacingOccurrences(of: "\r", with: " ") - // Remove comment terminators (remove the asterisk-slash sequence) + // Remove comment sequences (both opening and closing) + result = result.replacingOccurrences(of: "/*", with: "") result = result.replacingOccurrences(of: "*/", with: "") result = result.replacingOccurrences(of: "--", with: "") return result From 2f53566736ebae2960df6eeaf7e0ed2a899b755e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 29 Dec 2025 10:21:29 +0700 Subject: [PATCH 35/35] wip --- TablePro.xcodeproj/project.pbxproj | 8 +-- TablePro/Core/Services/ExportService.swift | 62 +++++++++++++++++++--- TablePro/Views/Export/ExportDialog.swift | 48 ++++++++++++----- 3 files changed, 93 insertions(+), 25 deletions(-) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 7f3caa310..9d540bac9 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -253,7 +253,7 @@ AUTOMATION_APPLE_EVENTS = NO; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -286,7 +286,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libmariadb.a", @@ -331,7 +331,7 @@ AUTOMATION_APPLE_EVENTS = NO; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; DEVELOPMENT_TEAM = D7HJ5TFYCU; ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; @@ -364,7 +364,7 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; LIBRARY_SEARCH_PATHS = "$(PROJECT_DIR)/Libs"; MACOSX_DEPLOYMENT_TARGET = 14.6; - MARKETING_VERSION = 0.1.9; + MARKETING_VERSION = 0.1.10; OTHER_LDFLAGS = ( "-force_load", "$(PROJECT_DIR)/Libs/libmariadb.a", diff --git a/TablePro/Core/Services/ExportService.swift b/TablePro/Core/Services/ExportService.swift index ec139e4a3..3189c96fa 100644 --- a/TablePro/Core/Services/ExportService.swift +++ b/TablePro/Core/Services/ExportService.swift @@ -67,6 +67,13 @@ final class ExportService: ObservableObject { @Published var totalRows: Int = 0 @Published var statusMessage: String = "" @Published var errorMessage: String? + /// Non-fatal warnings that occurred during export (e.g., DDL fetch failures) + @Published var warningMessage: String? + + // MARK: - DDL Failure Tracking + + /// Tables that failed DDL fetch during SQL export + private var ddlFailures: [String] = [] // MARK: - Cancellation @@ -136,6 +143,8 @@ final class ExportService: ObservableObject { currentTableIndex = 0 statusMessage = "" errorMessage = nil + warningMessage = nil + ddlFailures = [] defer { isExporting = false @@ -165,6 +174,7 @@ final class ExportService: ObservableObject { /// Fetch total row count for all tables. /// - Returns: The total row count across all tables. Any failures are logged but do not affect the returned value. + /// - Note: When row count fails for some tables, the statusMessage is updated to inform the user that progress is estimated. private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { var total = 0 var failedCount = 0 @@ -183,6 +193,8 @@ final class ExportService: ObservableObject { } if failedCount > 0 { print("Warning: \(failedCount) table(s) failed row count - progress indicator may be inaccurate") + // Update status message so user knows progress is estimated + statusMessage = "Progress estimated (\(failedCount) table\(failedCount > 1 ? "s" : "") could not be counted)" } return total } @@ -243,6 +255,8 @@ final class ExportService: ObservableObject { /// Removes characters that could break out of or nest SQL comments: /// - Newlines (could start new SQL statements) /// - Comment sequences (/* */ --) + /// + /// Logs a warning when the name is modified. private func sanitizeForSQLComment(_ name: String) -> String { var result = name // Replace newlines with spaces @@ -252,6 +266,12 @@ final class ExportService: ObservableObject { result = result.replacingOccurrences(of: "/*", with: "") result = result.replacingOccurrences(of: "*/", with: "") result = result.replacingOccurrences(of: "--", with: "") + + // Log when sanitization modifies the name + if result != name { + print("Warning: Table name '\(name)' was sanitized to '\(result)' for SQL comment safety") + } + return result } @@ -265,6 +285,17 @@ final class ExportService: ObservableObject { return try FileHandle(forWritingTo: url) } + /// Close a file handle with error logging instead of silent suppression + /// + /// Used in defer blocks where we can't throw but want visibility into failures. + private func closeFileHandle(_ handle: FileHandle) { + do { + try handle.close() + } catch { + print("Warning: Failed to close export file handle: \(error.localizedDescription)") + } + } + // MARK: - CSV Export private func exportToCSV( @@ -274,7 +305,7 @@ final class ExportService: ObservableObject { ) async throws { // Create file and get handle for streaming writes let fileHandle = try createFileHandle(at: url) - defer { try? fileHandle.close() } + defer { closeFileHandle(fileHandle) } let lineBreak = config.csvOptions.lineBreak.value @@ -295,6 +326,7 @@ final class ExportService: ObservableObject { let tableRef = qualifiedTableRef(for: table) let batchSize = 10_000 var offset = 0 + var isFirstBatch = true while true { try checkCancellation() @@ -308,13 +340,20 @@ final class ExportService: ObservableObject { } // Stream CSV content for this batch directly to file + // Only include headers on the first batch to avoid duplication + var batchOptions = config.csvOptions + if !isFirstBatch { + batchOptions.includeFieldNames = false + } + try await writeCSVContentWithProgress( columns: result.columns, rows: result.rows, - options: config.csvOptions, + options: batchOptions, to: fileHandle ) + isFirstBatch = false offset += batchSize } if index < tables.count - 1 { @@ -436,7 +475,7 @@ final class ExportService: ObservableObject { ) async throws { // Stream JSON directly to file to minimize memory usage let fileHandle = try createFileHandle(at: url) - defer { try? fileHandle.close() } + defer { closeFileHandle(fileHandle) } let prettyPrint = config.jsonOptions.prettyPrint let indent = prettyPrint ? " " : "" @@ -684,10 +723,13 @@ final class ExportService: ObservableObject { } try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) } catch { + // Track the failure for user notification + ddlFailures.append(sanitizedName) + // Use sanitizedName (already defined above) for safe comment output - let warningMessage = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" - print(warningMessage) - try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(warningMessage))\n\n".toUTF8Data()) + let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" + print(ddlWarning) + try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) } } @@ -727,7 +769,7 @@ final class ExportService: ObservableObject { try fileHandle.close() } catch { - try? fileHandle.close() + closeFileHandle(fileHandle) if let tempURL = tempFileURL { try? FileManager.default.removeItem(at: tempURL) } @@ -753,6 +795,12 @@ final class ExportService: ObservableObject { } } + // Surface DDL failures to user as a warning + if !ddlFailures.isEmpty { + let failedTables = ddlFailures.joined(separator: ", ") + warningMessage = "Export completed with warnings: Could not fetch table structure for: \(failedTables)" + } + progress = 1.0 } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 0e0e78763..8217a8313 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -251,6 +251,13 @@ struct ExportDialog: View { .lineLimit(1) .fixedSize() } + + // Show validation error if filename is invalid + if let validationError = fileNameValidationError { + Text(validationError) + .font(.system(size: DesignConstants.FontSize.small)) + .foregroundStyle(.red) + } } .padding(16) } @@ -332,32 +339,45 @@ struct ExportDialog: View { "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" ] - /// Validates that the filename is not empty and contains no invalid filesystem characters - private var isFileNameValid: Bool { + /// Returns a validation error message if the filename is invalid, nil if valid + private var fileNameValidationError: String? { let name = config.fileName.trimmingCharacters(in: .whitespaces) - guard !name.isEmpty else { return false } + + if name.isEmpty { + return "Filename cannot be empty" + } // Invalid filesystem characters (covers macOS, Windows, and Linux) let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|") - guard name.rangeOfCharacter(from: invalidChars) == nil else { return false } + if name.rangeOfCharacter(from: invalidChars) != nil { + return "Filename contains invalid characters: / \\ : * ? \" < > |" + } // Prevent path traversal attempts and special directory names - let isPathTraversalPattern = - name == "." || name == ".." || // Current/parent directory - name.hasPrefix("../") || name.hasPrefix("..\\") || - name.hasSuffix("/..") || name.hasSuffix("\\..") || - name.contains("/../") || name.contains("\\..\\") - guard !isPathTraversalPattern else { return false } + if name == "." || name == ".." || + name.hasPrefix("../") || name.hasPrefix("..\\") || + name.hasSuffix("/..") || name.hasSuffix("\\..") || + name.contains("/../") || name.contains("\\..\\") { + return "Filename cannot be '.' or '..' or contain path traversal" + } // Check for Windows reserved device names (case-insensitive) - // These can cause issues if the export file is copied to Windows let baseName = name.components(separatedBy: ".").first ?? name - guard !Self.windowsReservedNames.contains(baseName.uppercased()) else { return false } + if Self.windowsReservedNames.contains(baseName.uppercased()) { + return "'\(baseName)' is a reserved Windows device name" + } // Check filename length (255 bytes is common limit on most filesystems) - guard name.utf8.count <= 255 else { return false } + if name.utf8.count > 255 { + return "Filename is too long (max 255 bytes)" + } - return true + return nil + } + + /// Validates that the filename is not empty and contains no invalid filesystem characters + private var isFileNameValid: Bool { + fileNameValidationError == nil } // MARK: - Actions