diff --git a/CHANGELOG.md b/CHANGELOG.md index d7b36a07..b297f5fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,9 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Plugin system architecture — all 8 database drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) extracted into `.tableplugin` bundles loaded at runtime +- Export format plugins — all 5 export formats (CSV, JSON, SQL, XLSX, MQL) extracted into `.tableplugin` bundles with plugin-provided option views and per-table option columns - Settings > Plugins tab for plugin management — list installed plugins, enable/disable, install from file, uninstall user plugins, view plugin details - Plugin marketplace — browse, search, and install plugins from the GitHub-hosted registry with SHA-256 checksum verification, ETag caching, and offline fallback -- TableProPluginKit framework — shared protocols and types for driver plugins +- TableProPluginKit framework — shared protocols and types for driver and export plugins - ClickHouse database support with query progress tracking, EXPLAIN variants, TLS/HTTPS, server-side cancellation, and Parts view ### Changed diff --git a/Plugins/CSVExportPlugin/CSVExportModels.swift b/Plugins/CSVExportPlugin/CSVExportModels.swift new file mode 100644 index 00000000..7b8300f3 --- /dev/null +++ b/Plugins/CSVExportPlugin/CSVExportModels.swift @@ -0,0 +1,82 @@ +// +// CSVExportModels.swift +// CSVExportPlugin +// + +import Foundation + +public enum CSVDelimiter: String, CaseIterable, Identifiable { + case comma = "," + case semicolon = ";" + case tab = "\\t" + case pipe = "|" + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .comma: return "," + case .semicolon: return ";" + case .tab: return "\\t" + case .pipe: return "|" + } + } + + public var actualValue: String { + self == .tab ? "\t" : rawValue + } +} + +public enum CSVQuoteHandling: String, CaseIterable, Identifiable { + case always = "Always" + case asNeeded = "Quote if needed" + case never = "Never" + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .always: return String(localized: "Always", bundle: .main) + case .asNeeded: return String(localized: "Quote if needed", bundle: .main) + case .never: return String(localized: "Never", bundle: .main) + } + } +} + +public enum CSVLineBreak: String, CaseIterable, Identifiable { + case lf = "\\n" + case crlf = "\\r\\n" + case cr = "\\r" + + public var id: String { rawValue } + + public var value: String { + switch self { + case .lf: return "\n" + case .crlf: return "\r\n" + case .cr: return "\r" + } + } +} + +public enum CSVDecimalFormat: String, CaseIterable, Identifiable { + case period = "." + case comma = "," + + public var id: String { rawValue } + + public var separator: String { rawValue } +} + +public struct CSVExportOptions: Equatable { + public var convertNullToEmpty: Bool = true + public var convertLineBreakToSpace: Bool = false + public var includeFieldNames: Bool = true + public var delimiter: CSVDelimiter = .comma + public var quoteHandling: CSVQuoteHandling = .asNeeded + public var lineBreak: CSVLineBreak = .lf + public var decimalFormat: CSVDecimalFormat = .period + public var sanitizeFormulas: Bool = true + + public init() {} +} diff --git a/TablePro/Views/Export/ExportCSVOptionsView.swift b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift similarity index 65% rename from TablePro/Views/Export/ExportCSVOptionsView.swift rename to Plugins/CSVExportPlugin/CSVExportOptionsView.swift index 70e010b5..120a10b9 100644 --- a/TablePro/Views/Export/ExportCSVOptionsView.swift +++ b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -1,31 +1,26 @@ // -// ExportCSVOptionsView.swift -// TablePro -// -// Options panel for CSV export format. -// Provides controls for delimiter, quoting, NULL handling, and formatting. +// CSVExportOptionsView.swift +// CSVExportPlugin // import SwiftUI -/// Options panel for CSV export -struct ExportCSVOptionsView: View { - @Binding var options: CSVExportOptions +struct CSVExportOptionsView: View { + @Bindable var plugin: CSVExportPlugin var body: some View { VStack(alignment: .leading, spacing: 10) { - // Checkboxes section VStack(alignment: .leading, spacing: 8) { - Toggle("Convert NULL to EMPTY", isOn: $options.convertNullToEmpty) + Toggle("Convert NULL to EMPTY", isOn: $plugin.options.convertNullToEmpty) .toggleStyle(.checkbox) - Toggle("Convert line break to space", isOn: $options.convertLineBreakToSpace) + Toggle("Convert line break to space", isOn: $plugin.options.convertLineBreakToSpace) .toggleStyle(.checkbox) - Toggle("Put field names in the first row", isOn: $options.includeFieldNames) + Toggle("Put field names in the first row", isOn: $plugin.options.includeFieldNames) .toggleStyle(.checkbox) - Toggle("Sanitize formula-like values", isOn: $options.sanitizeFormulas) + Toggle("Sanitize formula-like values", isOn: $plugin.options.sanitizeFormulas) .toggleStyle(.checkbox) .help("Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote") } @@ -33,10 +28,9 @@ struct ExportCSVOptionsView: View { Divider() .padding(.vertical, 4) - // Dropdowns section VStack(alignment: .leading, spacing: 10) { - optionRow(String(localized: "Delimiter")) { - Picker("", selection: $options.delimiter) { + optionRow(String(localized: "Delimiter", bundle: .main)) { + Picker("", selection: $plugin.options.delimiter) { ForEach(CSVDelimiter.allCases) { delimiter in Text(delimiter.displayName).tag(delimiter) } @@ -46,8 +40,8 @@ struct ExportCSVOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Quote")) { - Picker("", selection: $options.quoteHandling) { + optionRow(String(localized: "Quote", bundle: .main)) { + Picker("", selection: $plugin.options.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) } @@ -57,8 +51,8 @@ struct ExportCSVOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Line break")) { - Picker("", selection: $options.lineBreak) { + optionRow(String(localized: "Line break", bundle: .main)) { + Picker("", selection: $plugin.options.lineBreak) { ForEach(CSVLineBreak.allCases) { lineBreak in Text(lineBreak.rawValue).tag(lineBreak) } @@ -68,8 +62,8 @@ struct ExportCSVOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Decimal")) { - Picker("", selection: $options.decimalFormat) { + optionRow(String(localized: "Decimal", bundle: .main)) { + Picker("", selection: $plugin.options.decimalFormat) { ForEach(CSVDecimalFormat.allCases) { format in Text(format.rawValue).tag(format) } @@ -96,11 +90,3 @@ struct ExportCSVOptionsView: View { } } } - -// MARK: - Preview - -#Preview { - ExportCSVOptionsView(options: .constant(CSVExportOptions())) - .padding() - .frame(width: 280) -} diff --git a/TablePro/Core/Services/Export/ExportService+CSV.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift similarity index 59% rename from TablePro/Core/Services/Export/ExportService+CSV.swift rename to Plugins/CSVExportPlugin/CSVExportPlugin.swift index bb94a649..d1b3828e 100644 --- a/TablePro/Core/Services/Export/ExportService+CSV.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -1,32 +1,51 @@ // -// ExportService+CSV.swift -// TablePro +// CSVExportPlugin.swift +// CSVExportPlugin // import Foundation +import SwiftUI +import TableProPluginKit -extension ExportService { - func exportToCSV( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL +@Observable +final class CSVExportPlugin: ExportFormatPlugin { + static let pluginName = "CSV Export" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to CSV format" + static let formatId = "csv" + static let formatDisplayName = "CSV" + static let defaultFileExtension = "csv" + static let iconName = "doc.text" + + // swiftlint:disable:next force_try + static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) + + var options = CSVExportOptions() + + required init() {} + + func optionsView() -> AnyView? { + AnyView(CSVExportOptionsView(plugin: self)) + } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress ) async throws { - // Create file and get handle for streaming writes - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) + defer { try? fileHandle.close() } - let lineBreak = config.csvOptions.lineBreak.value + let lineBreak = options.lineBreak.value for (index, table) in tables.enumerated() { - try checkCancellation() + try progress.checkCancellation() - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName + progress.setCurrentTable(table.qualifiedName, index: index + 1) - // Add table header comment if multiple tables - // Sanitize name to prevent newlines from breaking the comment line if tables.count > 1 { - let sanitizedName = sanitizeForSQLComment(table.qualifiedName) + let sanitizedName = PluginExportUtilities.sanitizeForSQLComment(table.qualifiedName) try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data()) } @@ -35,52 +54,55 @@ extension ExportService { var isFirstBatch = true while true { - try checkCancellation() - try Task.checkCancellation() + try progress.checkCancellation() - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + let result = try await dataSource.fetchRows( + table: table.name, + databaseName: table.databaseName, + offset: offset, + limit: batchSize + ) - // No more rows to process - if result.rows.isEmpty { - break - } + if result.rows.isEmpty { break } - // Stream CSV content for this batch directly to file - // Only include headers on the first batch to avoid duplication - var batchOptions = config.csvOptions + var batchOptions = options if !isFirstBatch { batchOptions.includeFieldNames = false } - try await writeCSVContentWithProgress( + try writeCSVContent( columns: result.columns, rows: result.rows, options: batchOptions, - to: fileHandle + to: fileHandle, + progress: progress ) isFirstBatch = false offset += batchSize } + if index < tables.count - 1 { try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data()) } } - try checkCancellation() - state.progress = 1.0 + try progress.checkCancellation() + progress.finalizeTable() } - private func writeCSVContentWithProgress( + // MARK: - Private + + private func writeCSVContent( columns: [String], rows: [[String?]], options: CSVExportOptions, - to fileHandle: FileHandle - ) async throws { + to fileHandle: FileHandle, + progress: PluginExportProgress + ) throws { let delimiter = options.delimiter.actualValue let lineBreak = options.lineBreak.value - // Header row if options.includeFieldNames { let headerLine = columns .map { escapeCSVField($0, options: options) } @@ -88,9 +110,8 @@ extension ExportService { try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data()) } - // Data rows with progress tracking - stream directly to file for row in rows { - try checkCancellation() + try progress.checkCancellation() let rowLine = row.map { value -> String in guard let val = value else { @@ -98,11 +119,8 @@ extension ExportService { } 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 .replacingOccurrences(of: "\r\n", with: " ") @@ -110,7 +128,6 @@ extension ExportService { .replacingOccurrences(of: "\n", with: " ") } - // Handle decimal format if options.decimalFormat == .comma { let range = NSRange(processed.startIndex..., in: processed) if Self.decimalFormatRegex.firstMatch(in: processed, range: range) != nil { @@ -121,26 +138,17 @@ extension ExportService { return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks) }.joined(separator: delimiter) - // Write row directly to file try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) - - // Update progress (throttled) - await incrementProgress() + progress.incrementRow() } - - // Ensure final count is shown - await finalizeTableProgress() } private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> 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 } } @@ -152,9 +160,6 @@ extension ExportService { 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") || @@ -167,4 +172,5 @@ extension ExportService { return processed } } + } diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist new file mode 100644 index 00000000..12b650a8 --- /dev/null +++ b/Plugins/CSVExportPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 1 + + diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist new file mode 100644 index 00000000..12b650a8 --- /dev/null +++ b/Plugins/JSONExportPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 1 + + diff --git a/Plugins/JSONExportPlugin/JSONExportModels.swift b/Plugins/JSONExportPlugin/JSONExportModels.swift new file mode 100644 index 00000000..0dc6e7b1 --- /dev/null +++ b/Plugins/JSONExportPlugin/JSONExportModels.swift @@ -0,0 +1,14 @@ +// +// JSONExportModels.swift +// JSONExportPlugin +// + +import Foundation + +public struct JSONExportOptions: Equatable { + public var prettyPrint: Bool = true + public var includeNullValues: Bool = true + public var preserveAllAsStrings: Bool = false + + public init() {} +} diff --git a/Plugins/JSONExportPlugin/JSONExportOptionsView.swift b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift new file mode 100644 index 00000000..5a4bfce7 --- /dev/null +++ b/Plugins/JSONExportPlugin/JSONExportOptionsView.swift @@ -0,0 +1,25 @@ +// +// JSONExportOptionsView.swift +// JSONExportPlugin +// + +import SwiftUI + +struct JSONExportOptionsView: View { + @Bindable var plugin: JSONExportPlugin + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle("Pretty print (formatted output)", isOn: $plugin.options.prettyPrint) + .toggleStyle(.checkbox) + + Toggle("Include NULL values", isOn: $plugin.options.includeNullValues) + .toggleStyle(.checkbox) + + Toggle("Preserve all values as strings", isOn: $plugin.options.preserveAllAsStrings) + .toggleStyle(.checkbox) + .help("Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings") + } + .font(.system(size: 13)) + } +} diff --git a/TablePro/Core/Services/Export/ExportService+JSON.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift similarity index 55% rename from TablePro/Core/Services/Export/ExportService+JSON.swift rename to Plugins/JSONExportPlugin/JSONExportPlugin.swift index daf5f92c..3f56123c 100644 --- a/TablePro/Core/Services/Export/ExportService+JSON.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -1,35 +1,51 @@ // -// ExportService+JSON.swift -// TablePro +// JSONExportPlugin.swift +// JSONExportPlugin // import Foundation +import SwiftUI +import TableProPluginKit -extension ExportService { - func exportToJSON( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL +@Observable +final class JSONExportPlugin: ExportFormatPlugin { + static let pluginName = "JSON Export" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to JSON format" + static let formatId = "json" + static let formatDisplayName = "JSON" + static let defaultFileExtension = "json" + static let iconName = "curlybraces" + + var options = JSONExportOptions() + + required init() {} + + func optionsView() -> AnyView? { + AnyView(JSONExportOptionsView(plugin: self)) + } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress ) async throws { - // Stream JSON directly to file to minimize memory usage - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) + defer { try? fileHandle.close() } - let prettyPrint = config.jsonOptions.prettyPrint + let prettyPrint = options.prettyPrint let indent = prettyPrint ? " " : "" let newline = prettyPrint ? "\n" : "" - // Opening brace try fileHandle.write(contentsOf: "{\(newline)".toUTF8Data()) for (tableIndex, table) in tables.enumerated() { - try checkCancellation() + try progress.checkCancellation() - state.currentTableIndex = tableIndex + 1 - state.currentTable = table.qualifiedName + progress.setCurrentTable(table.qualifiedName, index: tableIndex + 1) - // Write table key and opening bracket - let escapedTableName = escapeJSONString(table.qualifiedName) + let escapedTableName = PluginExportUtilities.escapeJSONString(table.qualifiedName) try fileHandle.write(contentsOf: "\(indent)\"\(escapedTableName)\": [\(newline)".toUTF8Data()) let batchSize = 1_000 @@ -38,50 +54,49 @@ extension ExportService { var columns: [String]? batchLoop: while true { - try checkCancellation() - try Task.checkCancellation() + try progress.checkCancellation() - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + let result = try await dataSource.fetchRows( + table: table.name, + databaseName: table.databaseName, + offset: offset, + limit: batchSize + ) - if result.rows.isEmpty { - break batchLoop - } + if result.rows.isEmpty { break batchLoop } if columns == nil { columns = result.columns } for row in result.rows { - try checkCancellation() + try progress.checkCancellation() - // Buffer entire row into a String, then write once (SVC-10) let rowPrefix = prettyPrint ? "\(indent)\(indent)" : "" var rowString = "" - // Comma/newline before every row except the first if hasWrittenRow { rowString += ",\(newline)" } - // Row prefix and opening brace rowString += rowPrefix rowString += "{" - if let columns = columns { + if let 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 options.includeNullValues || value != nil { if !isFirstField { rowString += ", " } isFirstField = false - let escapedKey = escapeJSONString(column) + let escapedKey = PluginExportUtilities.escapeJSONString(column) let jsonValue = formatJSONValue( value, - preserveAsString: config.jsonOptions.preserveAllAsStrings + preserveAsString: options.preserveAllAsStrings ) rowString += "\"\(escapedKey)\": \(jsonValue)" } @@ -89,25 +104,16 @@ extension ExportService { } } - // Close row object rowString += "}" - // Single write per row instead of per field try fileHandle.write(contentsOf: rowString.toUTF8Data()) - hasWrittenRow = true - - // Update progress (throttled) - await incrementProgress() + progress.incrementRow() } offset += result.rows.count } - // Ensure final count is shown for this table - await finalizeTableProgress() - - // Close array if hasWrittenRow { try fileHandle.write(contentsOf: newline.toUTF8Data()) } @@ -115,38 +121,33 @@ extension ExportService { try fileHandle.write(contentsOf: "\(indent)]\(tableSuffix)".toUTF8Data()) } - // Closing brace try fileHandle.write(contentsOf: "}".toUTF8Data()) - try checkCancellation() - state.progress = 1.0 + try progress.checkCancellation() + progress.finalizeTable() } + // MARK: - Private + 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))\"" + return "\"\(PluginExportUtilities.escapeJSONString(val))\"" } - // 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) } if let doubleVal = Double(val), !val.contains("e") && !val.contains("E") { - // Avoid scientific notation issues - let jsMaxSafeInteger = 9_007_199_254_740_991.0 // 2^53 - 1, JavaScript's Number.MAX_SAFE_INTEGER + let jsMaxSafeInteger = 9_007_199_254_740_991.0 if doubleVal.truncatingRemainder(dividingBy: 1) == 0 && !val.contains(".") { - // 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 { - // Preserve original integral representation to avoid scientific notation / precision changes return val } } @@ -156,7 +157,7 @@ extension ExportService { return val.lowercased() } - // String value - escape and quote - return "\"\(escapeJSONString(val))\"" + return "\"\(PluginExportUtilities.escapeJSONString(val))\"" } + } diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist new file mode 100644 index 00000000..12b650a8 --- /dev/null +++ b/Plugins/MQLExportPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 1 + + diff --git a/Plugins/MQLExportPlugin/MQLExportHelpers.swift b/Plugins/MQLExportPlugin/MQLExportHelpers.swift new file mode 100644 index 00000000..0a50355c --- /dev/null +++ b/Plugins/MQLExportPlugin/MQLExportHelpers.swift @@ -0,0 +1,50 @@ +// +// MQLExportHelpers.swift +// MQLExportPlugin +// + +import Foundation +import TableProPluginKit + +enum MQLExportHelpers { + static func escapeJSIdentifier(_ name: String) -> String { + guard let firstChar = name.first, + !firstChar.isNumber, + name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { + return "[\"\(PluginExportUtilities.escapeJSONString(name))\"]" + } + return name + } + + static func collectionAccessor(for name: String) -> String { + let escaped = escapeJSIdentifier(name) + if escaped.hasPrefix("[") { + return "db\(escaped)" + } + return "db.\(escaped)" + } + + static func mqlJsonValue(for value: String) -> String { + if value == "true" || value == "false" { + return value + } + if value == "null" { + return "null" + } + if Int64(value) != nil { + return value + } + if Double(value) != nil, value.contains(".") { + return value + } + if (value.hasPrefix("{") && value.hasSuffix("}")) || + (value.hasPrefix("[") && value.hasSuffix("]")) { + let hasControlChars = value.utf8.contains(where: { $0 < 0x20 }) + if hasControlChars { + return "\"\(PluginExportUtilities.escapeJSONString(value))\"" + } + return value + } + return "\"\(PluginExportUtilities.escapeJSONString(value))\"" + } +} diff --git a/Plugins/MQLExportPlugin/MQLExportModels.swift b/Plugins/MQLExportPlugin/MQLExportModels.swift new file mode 100644 index 00000000..ddd630c5 --- /dev/null +++ b/Plugins/MQLExportPlugin/MQLExportModels.swift @@ -0,0 +1,12 @@ +// +// MQLExportModels.swift +// MQLExportPlugin +// + +import Foundation + +public struct MQLExportOptions: Equatable { + public var batchSize: Int = 500 + + public init() {} +} diff --git a/TablePro/Views/Export/ExportMQLOptionsView.swift b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift similarity index 55% rename from TablePro/Views/Export/ExportMQLOptionsView.swift rename to Plugins/MQLExportPlugin/MQLExportOptionsView.swift index 77209534..59a1865e 100644 --- a/TablePro/Views/Export/ExportMQLOptionsView.swift +++ b/Plugins/MQLExportPlugin/MQLExportOptionsView.swift @@ -1,36 +1,32 @@ // -// ExportMQLOptionsView.swift -// TablePro -// -// Options panel for MQL (MongoDB Query Language) export format. +// MQLExportOptionsView.swift +// MQLExportPlugin // import SwiftUI -/// Options panel for MQL export -struct ExportMQLOptionsView: View { - @Binding var options: MQLExportOptions +struct MQLExportOptionsView: View { + @Bindable var plugin: MQLExportPlugin - /// Available batch size options private static let batchSizeOptions = [100, 500, 1_000, 5_000] var body: some View { - VStack(alignment: .leading, spacing: DesignConstants.Spacing.xs) { + VStack(alignment: .leading, spacing: 8) { Text("Exports data as mongosh-compatible scripts. Drop, Indexes, and Data options are configured per collection in the collection list.") - .font(.system(size: DesignConstants.FontSize.small)) + .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) Divider() - .padding(.vertical, DesignConstants.Spacing.xxs) + .padding(.vertical, 2) HStack { Text("Rows per insertMany") - .font(.system(size: DesignConstants.FontSize.body)) + .font(.system(size: 13)) Spacer() - Picker("", selection: $options.batchSize) { + Picker("", selection: $plugin.options.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in Text("\(size)") .tag(size) @@ -44,11 +40,3 @@ struct ExportMQLOptionsView: View { } } } - -// MARK: - Preview - -#Preview { - ExportMQLOptionsView(options: .constant(MQLExportOptions())) - .padding() - .frame(width: 300) -} diff --git a/TablePro/Core/Services/Export/ExportService+MQL.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift similarity index 51% rename from TablePro/Core/Services/Export/ExportService+MQL.swift rename to Plugins/MQLExportPlugin/MQLExportPlugin.swift index eec1e700..35f3e369 100644 --- a/TablePro/Core/Services/Export/ExportService+MQL.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -1,18 +1,53 @@ // -// ExportService+MQL.swift -// TablePro +// MQLExportPlugin.swift +// MQLExportPlugin // import Foundation +import SwiftUI +import TableProPluginKit + +@Observable +final class MQLExportPlugin: ExportFormatPlugin { + static let pluginName = "MQL Export" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to MongoDB Query Language format" + static let formatId = "mql" + static let formatDisplayName = "MQL" + static let defaultFileExtension = "js" + static let iconName = "leaf" + static let supportedDatabaseTypeIds = ["MongoDB"] + + static let perTableOptionColumns: [PluginExportOptionColumn] = [ + PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), + PluginExportOptionColumn(id: "indexes", label: "Indexes", width: 44), + PluginExportOptionColumn(id: "data", label: "Data", width: 44) + ] + + var options = MQLExportOptions() + + required init() {} + + func defaultTableOptionValues() -> [Bool] { + [true, true, true] + } + + func isTableExportable(optionValues: [Bool]) -> Bool { + optionValues.contains(true) + } -extension ExportService { - func exportToMQL( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL + func optionsView() -> AnyView? { + AnyView(MQLExportOptionsView(plugin: self)) + } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress ) async throws { - let fileHandle = try createFileHandle(at: url) - defer { closeFileHandle(fileHandle) } + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) + defer { try? fileHandle.close() } let dateFormatter = ISO8601DateFormatter() try fileHandle.write(contentsOf: "// TablePro MQL Export\n".toUTF8Data()) @@ -20,44 +55,44 @@ extension ExportService { let dbName = tables.first?.databaseName ?? "" if !dbName.isEmpty { - try fileHandle.write(contentsOf: "// Database: \(sanitizeForSQLComment(dbName))\n".toUTF8Data()) + try fileHandle.write(contentsOf: "// Database: \(PluginExportUtilities.sanitizeForSQLComment(dbName))\n".toUTF8Data()) } try fileHandle.write(contentsOf: "\n".toUTF8Data()) - let batchSize = config.mqlOptions.batchSize + let batchSize = options.batchSize for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - let mqlOpts = table.mqlOptions - let escapedCollection = escapeJSIdentifier(table.name) - let collectionAccessor: String - if escapedCollection.hasPrefix("[") { - collectionAccessor = "db\(escapedCollection)" - } else { - collectionAccessor = "db.\(escapedCollection)" - } + try progress.checkCancellation() + + progress.setCurrentTable(table.qualifiedName, index: index + 1) - try fileHandle.write(contentsOf: "// Collection: \(sanitizeForSQLComment(table.name))\n".toUTF8Data()) + let includeDrop = optionValue(table, at: 0) + let includeIndexes = optionValue(table, at: 1) + let includeData = optionValue(table, at: 2) - if mqlOpts.includeDrop { + let collectionAccessor = MQLExportHelpers.collectionAccessor(for: table.name) + + try fileHandle.write(contentsOf: "// Collection: \(PluginExportUtilities.sanitizeForSQLComment(table.name))\n".toUTF8Data()) + + if includeDrop { try fileHandle.write(contentsOf: "\(collectionAccessor).drop();\n".toUTF8Data()) } - if mqlOpts.includeData { + if includeData { let fetchBatchSize = 5_000 var offset = 0 var columns: [String] = [] var documentBatch: [String] = [] while true { - try checkCancellation() - try Task.checkCancellation() + try progress.checkCancellation() - let result = try await fetchBatch(for: table, offset: offset, limit: fetchBatchSize) + let result = try await dataSource.fetchRows( + table: table.name, + databaseName: table.databaseName, + offset: offset, + limit: fetchBatchSize + ) if result.rows.isEmpty { break } @@ -66,14 +101,14 @@ extension ExportService { } for row in result.rows { - try checkCancellation() + try progress.checkCancellation() var fields: [String] = [] for (colIndex, column) in columns.enumerated() { guard colIndex < row.count else { continue } guard let value = row[colIndex] else { continue } - let jsonValue = mqlJsonValue(for: value) - fields.append("\"\(escapeJSONString(column))\": \(jsonValue)") + let jsonValue = MQLExportHelpers.mqlJsonValue(for: value) + fields.append("\"\(PluginExportUtilities.escapeJSONString(column))\": \(jsonValue)") } documentBatch.append(" {\(fields.joined(separator: ", "))}") @@ -86,7 +121,7 @@ extension ExportService { documentBatch.removeAll(keepingCapacity: true) } - await incrementProgress() + progress.incrementRow() } offset += fetchBatchSize @@ -101,24 +136,30 @@ extension ExportService { } } - // Indexes after data for performance - if mqlOpts.includeIndexes { + if includeIndexes { try await writeMQLIndexes( collection: table.name, + databaseName: table.databaseName, collectionAccessor: collectionAccessor, + dataSource: dataSource, to: fileHandle ) } - await finalizeTableProgress() - if index < tables.count - 1 { try fileHandle.write(contentsOf: "\n".toUTF8Data()) } } - try checkCancellation() - state.progress = 1.0 + try progress.checkCancellation() + progress.finalizeTable() + } + + // MARK: - Private + + private func optionValue(_ table: PluginExportTable, at index: Int) -> Bool { + guard index < table.optionValues.count else { return true } + return table.optionValues[index] } private func writeMQLInsertMany( @@ -126,13 +167,8 @@ extension ExportService { documents: [String], to fileHandle: FileHandle ) throws { - let escapedCollection = escapeJSIdentifier(collection) - var statement: String - if escapedCollection.hasPrefix("[") { - statement = "db\(escapedCollection).insertMany([\n" - } else { - statement = "db.\(escapedCollection).insertMany([\n" - } + let collectionAccessor = MQLExportHelpers.collectionAccessor(for: collection) + var statement = "\(collectionAccessor).insertMany([\n" statement += documents.joined(separator: ",\n") statement += "\n]);\n" try fileHandle.write(contentsOf: statement.toUTF8Data()) @@ -140,10 +176,15 @@ extension ExportService { private func writeMQLIndexes( collection: String, + databaseName: String, collectionAccessor: String, + dataSource: any PluginExportDataSource, to fileHandle: FileHandle ) async throws { - let ddl = try await driver.fetchTableDDL(table: collection) + let ddl = try await dataSource.fetchTableDDL( + table: collection, + databaseName: databaseName + ) let lines = ddl.components(separatedBy: "\n") var indexLines: [String] = [] @@ -159,7 +200,7 @@ extension ExportService { let escapedForDDL = collection.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") let ddlAccessor = "db[\"\(escapedForDDL)\"]" if processedLine.hasPrefix(ddlAccessor) { - processedLine = collectionAccessor + processedLine.dropFirst(ddlAccessor.count) + processedLine = collectionAccessor + String(processedLine.dropFirst(ddlAccessor.count)) } indexLines.append(processedLine) } @@ -171,37 +212,4 @@ extension ExportService { } } - private func mqlJsonValue(for value: String) -> String { - if value == "true" || value == "false" { - return value - } - if value == "null" { - return "null" - } - if Int64(value) != nil { - return value - } - if Double(value) != nil, value.contains(".") { - return value - } - // JSON object or array -- pass through if valid (no unescaped control chars) - if (value.hasPrefix("{") && value.hasSuffix("}")) || - (value.hasPrefix("[") && value.hasSuffix("]")) { - let hasControlChars = value.utf8.contains(where: { $0 < 0x20 }) - if hasControlChars { - return "\"\(escapeJSONString(value))\"" - } - return value - } - return "\"\(escapeJSONString(value))\"" - } - - func escapeJSIdentifier(_ name: String) -> String { - guard let firstChar = name.first, - !firstChar.isNumber, - name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { - return "[\"\(escapeJSONString(name))\"]" - } - return name - } } diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist new file mode 100644 index 00000000..12b650a8 --- /dev/null +++ b/Plugins/SQLExportPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 1 + + diff --git a/Plugins/SQLExportPlugin/SQLExportHelpers.swift b/Plugins/SQLExportPlugin/SQLExportHelpers.swift new file mode 100644 index 00000000..cd9942d5 --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportHelpers.swift @@ -0,0 +1,24 @@ +// +// SQLExportHelpers.swift +// SQLExportPlugin +// + +import Foundation + +enum SQLExportHelpers { + static func buildPaginatedQuery( + tableRef: String, + databaseTypeId: String, + offset: Int, + limit: Int + ) -> String { + switch databaseTypeId { + case "Oracle": + return "SELECT * FROM \(tableRef) ORDER BY 1 OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + case "MSSQL": + return "SELECT * FROM \(tableRef) ORDER BY (SELECT NULL) OFFSET \(offset) ROWS FETCH NEXT \(limit) ROWS ONLY" + default: + return "SELECT * FROM \(tableRef) LIMIT \(limit) OFFSET \(offset)" + } + } +} diff --git a/Plugins/SQLExportPlugin/SQLExportModels.swift b/Plugins/SQLExportPlugin/SQLExportModels.swift new file mode 100644 index 00000000..2a567b38 --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportModels.swift @@ -0,0 +1,13 @@ +// +// SQLExportModels.swift +// SQLExportPlugin +// + +import Foundation + +public struct SQLExportOptions: Equatable { + public var compressWithGzip: Bool = false + public var batchSize: Int = 500 + + public init() {} +} diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift new file mode 100644 index 00000000..6ffed8ac --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -0,0 +1,46 @@ +// +// SQLExportOptionsView.swift +// SQLExportPlugin +// + +import SwiftUI + +struct SQLExportOptionsView: View { + @Bindable var plugin: SQLExportPlugin + + private static let batchSizeOptions = [1, 100, 500, 1_000] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Structure, Drop, and Data options are configured per table in the table list.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .padding(.vertical, 2) + + HStack { + Text("Rows per INSERT") + .font(.system(size: 13)) + + Spacer() + + Picker("", selection: $plugin.options.batchSize) { + ForEach(Self.batchSizeOptions, id: \.self) { size in + Text(size == 1 ? String(localized: "1 (no batching)", bundle: .main) : "\(size)") + .tag(size) + } + } + .pickerStyle(.menu) + .labelsHidden() + .frame(width: 130) + } + .help("Higher values create fewer INSERT statements, resulting in smaller files and faster imports") + + Toggle("Compress the file using Gzip", isOn: $plugin.options.compressWithGzip) + .toggleStyle(.checkbox) + .font(.system(size: 13)) + } + } +} diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift new file mode 100644 index 00000000..6785ac8f --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -0,0 +1,336 @@ +// +// SQLExportPlugin.swift +// SQLExportPlugin +// + +import Foundation +import os +import SwiftUI +import TableProPluginKit + +@Observable +final class SQLExportPlugin: ExportFormatPlugin { + static let pluginName = "SQL Export" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to SQL format" + static let formatId = "sql" + static let formatDisplayName = "SQL" + static let defaultFileExtension = "sql" + static let iconName = "text.page" + static let excludedDatabaseTypeIds = ["MongoDB", "Redis"] + + static let perTableOptionColumns: [PluginExportOptionColumn] = [ + PluginExportOptionColumn(id: "structure", label: "Structure", width: 56), + PluginExportOptionColumn(id: "drop", label: "Drop", width: 44), + PluginExportOptionColumn(id: "data", label: "Data", width: 44) + ] + + var options = SQLExportOptions() + var ddlFailures: [String] = [] + + private static let logger = Logger(subsystem: "com.TablePro", category: "SQLExportPlugin") + + required init() {} + + func defaultTableOptionValues() -> [Bool] { + [true, true, true] + } + + func isTableExportable(optionValues: [Bool]) -> Bool { + optionValues.contains(true) + } + + var currentFileExtension: String { + options.compressWithGzip ? "sql.gz" : "sql" + } + + var warnings: [String] { + guard !ddlFailures.isEmpty else { return [] } + let failedTables = ddlFailures.joined(separator: ", ") + return ["Could not fetch table structure for: \(failedTables)"] + } + + func optionsView() -> AnyView? { + AnyView(SQLExportOptionsView(plugin: self)) + } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress + ) async throws { + ddlFailures = [] + + // For gzip, write to temp file first then compress + let targetURL: URL + let tempFileURL: URL? + + if options.compressWithGzip { + let tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".sql") + tempFileURL = tempURL + targetURL = tempURL + } else { + tempFileURL = nil + targetURL = destination + } + + let fileHandle = try PluginExportUtilities.createFileHandle(at: targetURL) + + do { + let dateFormatter = ISO8601DateFormatter() + 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: \(dataSource.databaseTypeId)\n\n".toUTF8Data()) + + // Collect dependent sequences and enum types (PostgreSQL) + var emittedSequenceNames: Set = [] + var emittedTypeNames: Set = [] + let structureTables = tables.filter { optionValue($0, at: 0) } + + for table in structureTables { + do { + let sequences = try await dataSource.fetchDependentSequences( + table: table.name, + databaseName: table.databaseName + ) + for seq in sequences where !emittedSequenceNames.contains(seq.name) { + emittedSequenceNames.insert(seq.name) + let quotedName = "\"\(seq.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + try fileHandle.write(contentsOf: "DROP SEQUENCE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) + try fileHandle.write(contentsOf: "\(seq.ddl)\n\n".toUTF8Data()) + } + } catch { + Self.logger.warning("Failed to fetch dependent sequences for table \(table.name): \(error)") + } + + do { + let enumTypes = try await dataSource.fetchDependentTypes( + table: table.name, + databaseName: table.databaseName + ) + for enumType in enumTypes where !emittedTypeNames.contains(enumType.name) { + emittedTypeNames.insert(enumType.name) + let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" + try fileHandle.write(contentsOf: "DROP TYPE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) + let quotedLabels = enumType.labels.map { "'\(dataSource.escapeStringLiteral($0))'" } + try fileHandle.write(contentsOf: "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n\n".toUTF8Data()) + } + } catch { + Self.logger.warning("Failed to fetch dependent types for table \(table.name): \(error)") + } + } + + for (index, table) in tables.enumerated() { + try progress.checkCancellation() + + progress.setCurrentTable(table.qualifiedName, index: index + 1) + + let includeStructure = optionValue(table, at: 0) + let includeDrop = optionValue(table, at: 1) + let includeData = optionValue(table, at: 2) + + let tableRef = dataSource.quoteIdentifier(table.name) + + let sanitizedName = PluginExportUtilities.sanitizeForSQLComment(table.name) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\n".toUTF8Data()) + try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n\n".toUTF8Data()) + + if includeDrop { + try fileHandle.write(contentsOf: "DROP TABLE IF EXISTS \(tableRef);\n\n".toUTF8Data()) + } + + if includeStructure { + do { + let ddl = try await dataSource.fetchTableDDL( + table: table.name, + databaseName: table.databaseName + ) + try fileHandle.write(contentsOf: ddl.toUTF8Data()) + if !ddl.hasSuffix(";") { + try fileHandle.write(contentsOf: ";".toUTF8Data()) + } + try fileHandle.write(contentsOf: "\n\n".toUTF8Data()) + } catch { + ddlFailures.append(sanitizedName) + let ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" + Self.logger.warning("Failed to fetch DDL for table \(sanitizedName): \(error)") + try fileHandle.write(contentsOf: "-- \(PluginExportUtilities.sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) + } + } + + if includeData { + let batchSize = options.batchSize + var offset = 0 + var wroteAnyRows = false + + while true { + try progress.checkCancellation() + + let query = SQLExportHelpers.buildPaginatedQuery( + tableRef: tableRef, + databaseTypeId: dataSource.databaseTypeId, + offset: offset, + limit: batchSize + ) + let result = try await dataSource.execute(query: query) + + if result.rows.isEmpty { break } + + try writeInsertStatements( + tableName: table.name, + columns: result.columns, + rows: result.rows, + batchSize: batchSize, + dataSource: dataSource, + to: fileHandle, + progress: progress + ) + + wroteAnyRows = true + offset += batchSize + } + + if wroteAnyRows { + try fileHandle.write(contentsOf: "\n".toUTF8Data()) + } + } + } + + try fileHandle.close() + } catch { + try? fileHandle.close() + if let tempURL = tempFileURL { + try? FileManager.default.removeItem(at: tempURL) + } + throw error + } + + // Handle gzip compression + if options.compressWithGzip, let tempURL = tempFileURL { + progress.setStatus("Compressing...") + + do { + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + try await compressFile(source: tempURL, destination: destination) + } catch { + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + progress.finalizeTable() + } + + // MARK: - Private + + private func optionValue(_ table: PluginExportTable, at index: Int) -> Bool { + guard index < table.optionValues.count else { return true } + return table.optionValues[index] + } + + private func writeInsertStatements( + tableName: String, + columns: [String], + rows: [[String?]], + batchSize: Int, + dataSource: any PluginExportDataSource, + to fileHandle: FileHandle, + progress: PluginExportProgress + ) throws { + let tableRef = dataSource.quoteIdentifier(tableName) + let quotedColumns = columns + .map { dataSource.quoteIdentifier($0) } + .joined(separator: ", ") + + let insertPrefix = "INSERT INTO \(tableRef) (\(quotedColumns)) VALUES\n" + + let effectiveBatchSize = batchSize <= 1 ? 1 : batchSize + var valuesBatch: [String] = [] + valuesBatch.reserveCapacity(effectiveBatchSize) + + for row in rows { + try progress.checkCancellation() + + let values = row.map { value -> String in + guard let val = value else { return "NULL" } + let escaped = dataSource.escapeStringLiteral(val) + return "'\(escaped)'" + }.joined(separator: ", ") + + valuesBatch.append(" (\(values))") + + if valuesBatch.count >= effectiveBatchSize { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + valuesBatch.removeAll(keepingCapacity: true) + } + + progress.incrementRow() + } + + if !valuesBatch.isEmpty { + let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" + try fileHandle.write(contentsOf: statement.toUTF8Data()) + } + } + + private func compressFile(source: URL, destination: URL) async throws { + try await Task.detached(priority: .userInitiated) { + let gzipPath = "/usr/bin/gzip" + guard FileManager.default.isExecutableFile(atPath: gzipPath) else { + throw PluginExportError.exportFailed( + "Compression unavailable: gzip not found at \(gzipPath)" + ) + } + + guard FileManager.default.createFile(atPath: destination.path(percentEncoded: false), contents: nil) else { + throw PluginExportError.fileWriteFailed(destination.path(percentEncoded: false)) + } + + let process = Process() + process.executableURL = URL(fileURLWithPath: gzipPath) + + let sanitizedSourcePath = source.standardizedFileURL.path(percentEncoded: false) + + if sanitizedSourcePath.contains("\0") || + sanitizedSourcePath.contains(where: { $0.isNewline }) { + throw PluginExportError.exportFailed("Invalid source path for compression") + } + + process.arguments = ["-c", sanitizedSourcePath] + let outputFile = try FileHandle(forWritingTo: destination) + defer { try? outputFile.close() } + process.standardOutput = outputFile + + let errorPipe = Pipe() + process.standardError = errorPipe + + try process.run() + process.waitUntilExit() + + let status = process.terminationStatus + guard status == 0 else { + try? outputFile.close() + + 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 PluginExportError.exportFailed(message) + } + }.value + } +} diff --git a/Plugins/TableProPluginKit/ExportFormatPlugin.swift b/Plugins/TableProPluginKit/ExportFormatPlugin.swift new file mode 100644 index 00000000..d7f287f1 --- /dev/null +++ b/Plugins/TableProPluginKit/ExportFormatPlugin.swift @@ -0,0 +1,44 @@ +// +// ExportFormatPlugin.swift +// TableProPluginKit +// + +import Foundation +import SwiftUI + +public protocol ExportFormatPlugin: TableProPlugin { + static var formatId: String { get } + static var formatDisplayName: String { get } + static var defaultFileExtension: String { get } + static var iconName: String { get } + static var supportedDatabaseTypeIds: [String] { get } + static var excludedDatabaseTypeIds: [String] { get } + + static var perTableOptionColumns: [PluginExportOptionColumn] { get } + func defaultTableOptionValues() -> [Bool] + func isTableExportable(optionValues: [Bool]) -> Bool + + var currentFileExtension: String { get } + var warnings: [String] { get } + + func optionsView() -> AnyView? + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress + ) async throws +} + +public extension ExportFormatPlugin { + static var capabilities: [PluginCapability] { [.exportFormat] } + static var supportedDatabaseTypeIds: [String] { [] } + static var excludedDatabaseTypeIds: [String] { [] } + static var perTableOptionColumns: [PluginExportOptionColumn] { [] } + func defaultTableOptionValues() -> [Bool] { [] } + func isTableExportable(optionValues: [Bool]) -> Bool { true } + var currentFileExtension: String { Self.defaultFileExtension } + var warnings: [String] { [] } + func optionsView() -> AnyView? { nil } +} diff --git a/Plugins/TableProPluginKit/PluginExportDataSource.swift b/Plugins/TableProPluginKit/PluginExportDataSource.swift new file mode 100644 index 00000000..812f72be --- /dev/null +++ b/Plugins/TableProPluginKit/PluginExportDataSource.swift @@ -0,0 +1,23 @@ +// +// PluginExportDataSource.swift +// TableProPluginKit +// + +import Foundation + +public protocol PluginExportDataSource: AnyObject, Sendable { + var databaseTypeId: String { get } + func fetchRows(table: String, databaseName: String, offset: Int, limit: Int) async throws -> PluginQueryResult + func fetchTableDDL(table: String, databaseName: String) async throws -> String + func execute(query: String) async throws -> PluginQueryResult + func quoteIdentifier(_ identifier: String) -> String + func escapeStringLiteral(_ value: String) -> String + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] +} + +public extension PluginExportDataSource { + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] { [] } + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] { [] } +} diff --git a/Plugins/TableProPluginKit/PluginExportProgress.swift b/Plugins/TableProPluginKit/PluginExportProgress.swift new file mode 100644 index 00000000..de364680 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginExportProgress.swift @@ -0,0 +1,103 @@ +// +// PluginExportProgress.swift +// TableProPluginKit +// + +import Foundation + +public final class PluginExportProgress: @unchecked Sendable { + private let lock = NSLock() + private var _currentTable: String = "" + private var _currentTableIndex: Int = 0 + private var _processedRows: Int = 0 + private var _totalRows: Int = 0 + private var _statusMessage: String = "" + private var _isCancelled: Bool = false + + private let updateInterval: Int = 1_000 + private var internalRowCount: Int = 0 + + public var onUpdate: (@Sendable (String, Int, Int, Int, String) -> Void)? + + public init() {} + + public func setCurrentTable(_ name: String, index: Int) { + lock.lock() + _currentTable = name + _currentTableIndex = index + lock.unlock() + notifyUpdate() + } + + public func incrementRow() { + lock.lock() + internalRowCount += 1 + _processedRows = internalRowCount + let shouldNotify = internalRowCount % updateInterval == 0 + lock.unlock() + if shouldNotify { + notifyUpdate() + } + } + + public func finalizeTable() { + notifyUpdate() + } + + public func setTotalRows(_ count: Int) { + lock.lock() + _totalRows = count + lock.unlock() + } + + public func setStatus(_ message: String) { + lock.lock() + _statusMessage = message + lock.unlock() + notifyUpdate() + } + + public func checkCancellation() throws { + lock.lock() + let cancelled = _isCancelled + lock.unlock() + if cancelled || Task.isCancelled { + throw PluginExportCancellationError() + } + } + + public func cancel() { + lock.lock() + _isCancelled = true + lock.unlock() + } + + public var isCancelled: Bool { + lock.lock() + defer { lock.unlock() } + return _isCancelled + } + + public var processedRows: Int { + lock.lock() + defer { lock.unlock() } + return _processedRows + } + + public var totalRows: Int { + lock.lock() + defer { lock.unlock() } + return _totalRows + } + + private func notifyUpdate() { + lock.lock() + let table = _currentTable + let index = _currentTableIndex + let rows = _processedRows + let total = _totalRows + let status = _statusMessage + lock.unlock() + onUpdate?(table, index, rows, total, status) + } +} diff --git a/Plugins/TableProPluginKit/PluginExportTypes.swift b/Plugins/TableProPluginKit/PluginExportTypes.swift new file mode 100644 index 00000000..e8d0de53 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginExportTypes.swift @@ -0,0 +1,83 @@ +// +// PluginExportTypes.swift +// TableProPluginKit +// + +import Foundation + +public struct PluginExportTable: Sendable { + public let name: String + public let databaseName: String + public let tableType: String + public let optionValues: [Bool] + + public init(name: String, databaseName: String, tableType: String, optionValues: [Bool] = []) { + self.name = name + self.databaseName = databaseName + self.tableType = tableType + self.optionValues = optionValues + } + + public var qualifiedName: String { + databaseName.isEmpty ? name : "\(databaseName).\(name)" + } +} + +public struct PluginExportOptionColumn: Sendable, Identifiable { + public let id: String + public let label: String + public let width: CGFloat + public let defaultValue: Bool + + public init(id: String, label: String, width: CGFloat, defaultValue: Bool = true) { + self.id = id + self.label = label + self.width = width + self.defaultValue = defaultValue + } +} + +public enum PluginExportError: LocalizedError { + case fileWriteFailed(String) + case encodingFailed + case compressionFailed + case exportFailed(String) + + public var errorDescription: String? { + switch self { + case .fileWriteFailed(let path): + return "Failed to write file: \(path)" + case .encodingFailed: + return "Failed to encode content as UTF-8" + case .compressionFailed: + return "Failed to compress data" + case .exportFailed(let message): + return "Export failed: \(message)" + } + } +} + +public struct PluginExportCancellationError: Error, LocalizedError { + public init() {} + public var errorDescription: String? { "Export cancelled" } +} + +public struct PluginSequenceInfo: Sendable { + public let name: String + public let ddl: String + + public init(name: String, ddl: String) { + self.name = name + self.ddl = ddl + } +} + +public struct PluginEnumTypeInfo: Sendable { + public let name: String + public let labels: [String] + + public init(name: String, labels: [String]) { + self.name = name + self.labels = labels + } +} diff --git a/Plugins/TableProPluginKit/PluginExportUtilities.swift b/Plugins/TableProPluginKit/PluginExportUtilities.swift new file mode 100644 index 00000000..0945adda --- /dev/null +++ b/Plugins/TableProPluginKit/PluginExportUtilities.swift @@ -0,0 +1,72 @@ +// +// PluginExportUtilities.swift +// TableProPluginKit +// + +import Foundation + +public enum PluginExportUtilities { + public static func escapeJSONString(_ string: String) -> String { + var utf8Result = [UInt8]() + utf8Result.reserveCapacity(string.utf8.count) + + for byte in string.utf8 { + switch byte { + case 0x22: // " + utf8Result.append(0x5C) + utf8Result.append(0x22) + case 0x5C: // backslash + utf8Result.append(0x5C) + utf8Result.append(0x5C) + case 0x0A: // \n + utf8Result.append(0x5C) + utf8Result.append(0x6E) + case 0x0D: // \r + utf8Result.append(0x5C) + utf8Result.append(0x72) + case 0x09: // \t + utf8Result.append(0x5C) + utf8Result.append(0x74) + case 0x08: // backspace + utf8Result.append(0x5C) + utf8Result.append(0x62) + case 0x0C: // form feed + utf8Result.append(0x5C) + utf8Result.append(0x66) + case 0x00...0x1F: + let hex = String(format: "\\u%04X", byte) + utf8Result.append(contentsOf: hex.utf8) + default: + utf8Result.append(byte) + } + } + + return String(bytes: utf8Result, encoding: .utf8) ?? string + } + + public static func createFileHandle(at url: URL) throws -> FileHandle { + guard FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil) else { + throw PluginExportError.fileWriteFailed(url.path(percentEncoded: false)) + } + return try FileHandle(forWritingTo: url) + } + + public static func sanitizeForSQLComment(_ name: String) -> String { + var result = name + result = result.replacingOccurrences(of: "\n", with: " ") + result = result.replacingOccurrences(of: "\r", with: " ") + result = result.replacingOccurrences(of: "/*", with: "") + result = result.replacingOccurrences(of: "*/", with: "") + result = result.replacingOccurrences(of: "--", with: "") + return result + } +} + +public extension String { + func toUTF8Data() throws -> Data { + guard let data = self.data(using: .utf8) else { + throw PluginExportError.encodingFailed + } + return data + } +} diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist new file mode 100644 index 00000000..12b650a8 --- /dev/null +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -0,0 +1,8 @@ + + + + + TableProPluginKitVersion + 1 + + diff --git a/Plugins/XLSXExportPlugin/XLSXExportModels.swift b/Plugins/XLSXExportPlugin/XLSXExportModels.swift new file mode 100644 index 00000000..233bb2f6 --- /dev/null +++ b/Plugins/XLSXExportPlugin/XLSXExportModels.swift @@ -0,0 +1,13 @@ +// +// XLSXExportModels.swift +// XLSXExportPlugin +// + +import Foundation + +public struct XLSXExportOptions: Equatable { + public var includeHeaderRow: Bool = true + public var convertNullToEmpty: Bool = true + + public init() {} +} diff --git a/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift new file mode 100644 index 00000000..159b7c58 --- /dev/null +++ b/Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift @@ -0,0 +1,21 @@ +// +// XLSXExportOptionsView.swift +// XLSXExportPlugin +// + +import SwiftUI + +struct XLSXExportOptionsView: View { + @Bindable var plugin: XLSXExportPlugin + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Toggle("Include column headers", isOn: $plugin.options.includeHeaderRow) + .toggleStyle(.checkbox) + + Toggle("Convert NULL to empty", isOn: $plugin.options.convertNullToEmpty) + .toggleStyle(.checkbox) + } + .font(.system(size: 13)) + } +} diff --git a/TablePro/Core/Services/Export/ExportService+XLSX.swift b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift similarity index 53% rename from TablePro/Core/Services/Export/ExportService+XLSX.swift rename to Plugins/XLSXExportPlugin/XLSXExportPlugin.swift index 26e2d157..115a8e45 100644 --- a/TablePro/Core/Services/Export/ExportService+XLSX.swift +++ b/Plugins/XLSXExportPlugin/XLSXExportPlugin.swift @@ -1,25 +1,42 @@ // -// ExportService+XLSX.swift -// TablePro +// XLSXExportPlugin.swift +// XLSXExportPlugin // -import AppKit import Foundation +import SwiftUI +import TableProPluginKit -extension ExportService { - func exportToXLSX( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL +@Observable +final class XLSXExportPlugin: ExportFormatPlugin { + static let pluginName = "XLSX Export" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Export data to Excel format" + static let formatId = "xlsx" + static let formatDisplayName = "XLSX" + static let defaultFileExtension = "xlsx" + static let iconName = "tablecells" + + var options = XLSXExportOptions() + + required init() {} + + func optionsView() -> AnyView? { + AnyView(XLSXExportOptionsView(plugin: self)) + } + + func export( + tables: [PluginExportTable], + dataSource: any PluginExportDataSource, + destination: URL, + progress: PluginExportProgress ) async throws { let writer = XLSXWriter() - let options = config.xlsxOptions for (index, table) in tables.enumerated() { - try checkCancellation() + try progress.checkCancellation() - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName + progress.setCurrentTable(table.qualifiedName, index: index + 1) let batchSize = 5_000 var offset = 0 @@ -27,10 +44,14 @@ extension ExportService { var isFirstBatch = true while true { - try checkCancellation() - try Task.checkCancellation() + try progress.checkCancellation() - let result = try await fetchBatch(for: table, offset: offset, limit: batchSize) + let result = try await dataSource.fetchRows( + table: table.name, + databaseName: table.databaseName, + offset: offset, + limit: batchSize + ) if result.rows.isEmpty { break } @@ -45,24 +66,20 @@ extension ExportService { isFirstBatch = false } - // Write this batch to the sheet XML and release batch memory autoreleasepool { writer.addRows(result.rows, convertNullToEmpty: options.convertNullToEmpty) } - // Update progress for each row in this batch for _ in result.rows { - await incrementProgress() + progress.incrementRow() } offset += batchSize } - // If we fetched at least one batch, finish the sheet if !isFirstBatch { writer.finishSheet() } else { - // Table was empty - create an empty sheet with no data writer.beginSheet( name: table.name, columns: [], @@ -72,12 +89,11 @@ extension ExportService { writer.finishSheet() } - await finalizeTableProgress() + progress.finalizeTable() } - // Write XLSX on background thread to avoid blocking UI try await Task.detached(priority: .userInitiated) { - try writer.write(to: url) + try writer.write(to: destination) }.value } } diff --git a/TablePro/Core/Services/Export/XLSXWriter.swift b/Plugins/XLSXExportPlugin/XLSXWriter.swift similarity index 100% rename from TablePro/Core/Services/Export/XLSXWriter.swift rename to Plugins/XLSXExportPlugin/XLSXWriter.swift diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index edecc242..62045dc2 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -30,6 +30,16 @@ 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; 5ACE00012F4F00000000000D /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000C /* MarkdownUI */; }; 5AEE5B362F5C9B7B00FA84D7 /* OracleNIO in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F00000000000F /* OracleNIO */; }; + 5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86A000100000000 /* CSVExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,6 +106,41 @@ remoteGlobalIDString = 5A868000000000000; remoteInfo = PostgreSQLDriver; }; + 5A86A000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A86A000000000000; + remoteInfo = CSVExport; + }; + 5A86B000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A86B000000000000; + remoteInfo = JSONExport; + }; + 5A86C000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A86C000000000000; + remoteInfo = SQLExport; + }; + 5A86D000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A86D000000000000; + remoteInfo = XLSXExport; + }; + 5A86E000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A86E000000000000; + remoteInfo = MQLExport; + }; 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -120,6 +165,11 @@ 5A866000D00000000 /* MongoDBDriver.tableplugin in Copy Plug-Ins */, 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */, 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */, + 5A86A000D00000000 /* CSVExport.tableplugin in Copy Plug-Ins */, + 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */, + 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */, + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */, + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */, ); name = "Copy Plug-Ins"; runOnlyForDeploymentPostprocessing = 0; @@ -148,6 +198,11 @@ 5A866000100000000 /* MongoDBDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MongoDBDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A867000100000000 /* RedisDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RedisDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A868000100000000 /* PostgreSQLDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PostgreSQLDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A86A000100000000 /* CSVExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A86B000100000000 /* JSONExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A86C000100000000 /* SQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A86D000100000000 /* XLSXExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = XLSXExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; + 5A86E000100000000 /* MQLExport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MQLExport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ @@ -216,6 +271,41 @@ ); target = 5A868000000000000 /* PostgreSQLDriver */; }; + 5A86A000900000000 /* Exceptions for "Plugins/CSVExportPlugin" folder in "CSVExport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86A000000000000 /* CSVExport */; + }; + 5A86B000900000000 /* Exceptions for "Plugins/JSONExportPlugin" folder in "JSONExport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86B000000000000 /* JSONExport */; + }; + 5A86C000900000000 /* Exceptions for "Plugins/SQLExportPlugin" folder in "SQLExport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86C000000000000 /* SQLExport */; + }; + 5A86D000900000000 /* Exceptions for "Plugins/XLSXExportPlugin" folder in "XLSXExport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86D000000000000 /* XLSXExport */; + }; + 5A86E000900000000 /* Exceptions for "Plugins/MQLExportPlugin" folder in "MQLExport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86E000000000000 /* MQLExport */; + }; 5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -306,6 +396,46 @@ path = Plugins/PostgreSQLDriverPlugin; sourceTree = ""; }; + 5A86A000500000000 /* Plugins/CSVExportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86A000900000000 /* Exceptions for "Plugins/CSVExportPlugin" folder in "CSVExport" target */, + ); + path = Plugins/CSVExportPlugin; + sourceTree = ""; + }; + 5A86B000500000000 /* Plugins/JSONExportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86B000900000000 /* Exceptions for "Plugins/JSONExportPlugin" folder in "JSONExport" target */, + ); + path = Plugins/JSONExportPlugin; + sourceTree = ""; + }; + 5A86C000500000000 /* Plugins/SQLExportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86C000900000000 /* Exceptions for "Plugins/SQLExportPlugin" folder in "SQLExport" target */, + ); + path = Plugins/SQLExportPlugin; + sourceTree = ""; + }; + 5A86D000500000000 /* Plugins/XLSXExportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86D000900000000 /* Exceptions for "Plugins/XLSXExportPlugin" folder in "XLSXExport" target */, + ); + path = Plugins/XLSXExportPlugin; + sourceTree = ""; + }; + 5A86E000500000000 /* Plugins/MQLExportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86E000900000000 /* Exceptions for "Plugins/MQLExportPlugin" folder in "MQLExport" target */, + ); + path = Plugins/MQLExportPlugin; + sourceTree = ""; + }; 5ABCC5A82F43856700EAF3FC /* TableProTests */ = { isa = PBXFileSystemSynchronizedRootGroup; path = TableProTests; @@ -398,6 +528,46 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86A000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86A000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86B000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86B000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86C000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86D000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86E000300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A42F43856700EAF3FC /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -429,6 +599,11 @@ 5A866000500000000 /* Plugins/MongoDBDriverPlugin */, 5A867000500000000 /* Plugins/RedisDriverPlugin */, 5A868000500000000 /* Plugins/PostgreSQLDriverPlugin */, + 5A86A000500000000 /* Plugins/CSVExportPlugin */, + 5A86B000500000000 /* Plugins/JSONExportPlugin */, + 5A86C000500000000 /* Plugins/SQLExportPlugin */, + 5A86D000500000000 /* Plugins/XLSXExportPlugin */, + 5A86E000500000000 /* Plugins/MQLExportPlugin */, 5ABCC5A82F43856700EAF3FC /* TableProTests */, 5A1091C82EF17EDC0055EA7C /* Products */, 5A05FBC72F3EDF7500819CD7 /* Recovered References */, @@ -448,6 +623,11 @@ 5A866000100000000 /* MongoDBDriver.tableplugin */, 5A867000100000000 /* RedisDriver.tableplugin */, 5A868000100000000 /* PostgreSQLDriver.tableplugin */, + 5A86A000100000000 /* CSVExport.tableplugin */, + 5A86B000100000000 /* JSONExport.tableplugin */, + 5A86C000100000000 /* SQLExport.tableplugin */, + 5A86D000100000000 /* XLSXExport.tableplugin */, + 5A86E000100000000 /* MQLExport.tableplugin */, 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, ); name = Products; @@ -488,6 +668,11 @@ 5A866000C00000000 /* PBXTargetDependency */, 5A867000C00000000 /* PBXTargetDependency */, 5A868000C00000000 /* PBXTargetDependency */, + 5A86A000C00000000 /* PBXTargetDependency */, + 5A86B000C00000000 /* PBXTargetDependency */, + 5A86C000C00000000 /* PBXTargetDependency */, + 5A86D000C00000000 /* PBXTargetDependency */, + 5A86E000C00000000 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 5A1091C92EF17EDC0055EA7C /* TablePro */, @@ -688,6 +873,106 @@ productReference = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5A86A000000000000 /* CSVExport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */; + buildPhases = ( + 5A86A000200000000 /* Sources */, + 5A86A000300000000 /* Frameworks */, + 5A86A000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86A000500000000 /* Plugins/CSVExportPlugin */, + ); + name = CSVExport; + productName = CSVExport; + productReference = 5A86A000100000000 /* CSVExport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; + 5A86B000000000000 /* JSONExport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86B000800000000 /* Build configuration list for PBXNativeTarget "JSONExport" */; + buildPhases = ( + 5A86B000200000000 /* Sources */, + 5A86B000300000000 /* Frameworks */, + 5A86B000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86B000500000000 /* Plugins/JSONExportPlugin */, + ); + name = JSONExport; + productName = JSONExport; + productReference = 5A86B000100000000 /* JSONExport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; + 5A86C000000000000 /* SQLExport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86C000800000000 /* Build configuration list for PBXNativeTarget "SQLExport" */; + buildPhases = ( + 5A86C000200000000 /* Sources */, + 5A86C000300000000 /* Frameworks */, + 5A86C000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86C000500000000 /* Plugins/SQLExportPlugin */, + ); + name = SQLExport; + productName = SQLExport; + productReference = 5A86C000100000000 /* SQLExport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; + 5A86D000000000000 /* XLSXExport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86D000800000000 /* Build configuration list for PBXNativeTarget "XLSXExport" */; + buildPhases = ( + 5A86D000200000000 /* Sources */, + 5A86D000300000000 /* Frameworks */, + 5A86D000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86D000500000000 /* Plugins/XLSXExportPlugin */, + ); + name = XLSXExport; + productName = XLSXExport; + productReference = 5A86D000100000000 /* XLSXExport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; + 5A86E000000000000 /* MQLExport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86E000800000000 /* Build configuration list for PBXNativeTarget "MQLExport" */; + buildPhases = ( + 5A86E000200000000 /* Sources */, + 5A86E000300000000 /* Frameworks */, + 5A86E000400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86E000500000000 /* Plugins/MQLExportPlugin */, + ); + name = MQLExport; + productName = MQLExport; + productReference = 5A86E000100000000 /* MQLExport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5ABCC5A62F43856700EAF3FC /* TableProTests */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */; @@ -749,6 +1034,21 @@ 5A868000000000000 = { CreatedOnToolsVersion = 26.2; }; + 5A86A000000000000 = { + CreatedOnToolsVersion = 26.2; + }; + 5A86B000000000000 = { + CreatedOnToolsVersion = 26.2; + }; + 5A86C000000000000 = { + CreatedOnToolsVersion = 26.2; + }; + 5A86D000000000000 = { + CreatedOnToolsVersion = 26.2; + }; + 5A86E000000000000 = { + CreatedOnToolsVersion = 26.2; + }; 5ABCC5A62F43856700EAF3FC = { CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; @@ -787,6 +1087,11 @@ 5A866000000000000 /* MongoDBDriver */, 5A867000000000000 /* RedisDriver */, 5A868000000000000 /* PostgreSQLDriver */, + 5A86A000000000000 /* CSVExport */, + 5A86B000000000000 /* JSONExport */, + 5A86C000000000000 /* SQLExport */, + 5A86D000000000000 /* XLSXExport */, + 5A86E000000000000 /* MQLExport */, 5ABCC5A62F43856700EAF3FC /* TableProTests */, ); }; @@ -863,6 +1168,41 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86A000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86B000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86C000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86D000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86E000400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A52F43856700EAF3FC /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -943,6 +1283,41 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86A000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86B000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86C000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86D000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 5A86E000200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5ABCC5A32F43856700EAF3FC /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -998,6 +1373,31 @@ target = 5A868000000000000 /* PostgreSQLDriver */; targetProxy = 5A868000B00000000 /* PBXContainerItemProxy */; }; + 5A86A000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86A000000000000 /* CSVExport */; + targetProxy = 5A86A000B00000000 /* PBXContainerItemProxy */; + }; + 5A86B000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86B000000000000 /* JSONExport */; + targetProxy = 5A86B000B00000000 /* PBXContainerItemProxy */; + }; + 5A86C000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86C000000000000 /* SQLExport */; + targetProxy = 5A86C000B00000000 /* PBXContainerItemProxy */; + }; + 5A86D000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86D000000000000 /* XLSXExport */; + targetProxy = 5A86D000B00000000 /* PBXContainerItemProxy */; + }; + 5A86E000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86E000000000000 /* MQLExport */; + targetProxy = 5A86E000B00000000 /* PBXContainerItemProxy */; + }; 5ABCC5AC2F43856700EAF3FC /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A1091C62EF17EDC0055EA7C /* TablePro */; @@ -1792,6 +2192,236 @@ }; name = Release; }; + 5A86A000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CSVExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CSVExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86A000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/CSVExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).CSVExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.CSVExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + 5A86B000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/JSONExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.JSONExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86B000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/JSONExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.JSONExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + 5A86C000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/SQLExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86C000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/SQLExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + 5A86D000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/XLSXExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).XLSXExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.XLSXExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86D000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/XLSXExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).XLSXExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.XLSXExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; + 5A86E000600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86E000700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = D7HJ5TFYCU; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/MQLExportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).MQLExportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.MQLExportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5ABCC5AE2F43856700EAF3FC /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1938,6 +2568,51 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5A86A000800000000 /* Build configuration list for PBXNativeTarget "CSVExport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86A000600000000 /* Debug */, + 5A86A000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5A86B000800000000 /* Build configuration list for PBXNativeTarget "JSONExport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86B000600000000 /* Debug */, + 5A86B000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5A86C000800000000 /* Build configuration list for PBXNativeTarget "SQLExport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86C000600000000 /* Debug */, + 5A86C000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5A86D000800000000 /* Build configuration list for PBXNativeTarget "XLSXExport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86D000600000000 /* Debug */, + 5A86D000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 5A86E000800000000 /* Build configuration list for PBXNativeTarget "MQLExport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86E000600000000 /* Debug */, + 5A86E000700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift new file mode 100644 index 00000000..bf574494 --- /dev/null +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -0,0 +1,104 @@ +// +// ExportDataSourceAdapter.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable { + let databaseTypeId: String + private let driver: DatabaseDriver + private let dbType: DatabaseType + + private static let logger = Logger(subsystem: "com.TablePro", category: "ExportDataSourceAdapter") + + init(driver: DatabaseDriver, databaseType: DatabaseType) { + self.driver = driver + self.dbType = databaseType + self.databaseTypeId = databaseType.rawValue + } + + func fetchRows(table: String, databaseName: String, offset: Int, limit: Int) async throws -> PluginQueryResult { + let query: String + switch dbType { + case .mongodb: + let escaped = escapeJSIdentifier(table) + if escaped.hasPrefix("[") { + query = "db\(escaped).find({})" + } else { + query = "db.\(escaped).find({})" + } + case .redis: + query = "SCAN 0 MATCH \"*\" COUNT 10000" + default: + let tableRef = qualifiedTableRef(table: table, databaseName: databaseName) + query = "SELECT * FROM \(tableRef)" + } + let result = try await driver.fetchRows(query: query, offset: offset, limit: limit) + return mapToPluginResult(result) + } + + func fetchTableDDL(table: String, databaseName: String) async throws -> String { + try await driver.fetchTableDDL(table: table) + } + + func execute(query: String) async throws -> PluginQueryResult { + let result = try await driver.execute(query: query) + return mapToPluginResult(result) + } + + func quoteIdentifier(_ identifier: String) -> String { + dbType.quoteIdentifier(identifier) + } + + func escapeStringLiteral(_ value: String) -> String { + SQLEscaping.escapeStringLiteral(value, databaseType: dbType) + } + + func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { + try await driver.fetchApproximateRowCount(table: table) + } + + func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] { + let sequences = try await driver.fetchDependentSequences(forTable: table) + return sequences.map { PluginSequenceInfo(name: $0.name, ddl: $0.ddl) } + } + + func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] { + let types = try await driver.fetchDependentTypes(forTable: table) + return types.map { PluginEnumTypeInfo(name: $0.name, labels: $0.labels) } + } + + // MARK: - Helpers + + private func qualifiedTableRef(table: String, databaseName: String) -> String { + if databaseName.isEmpty { + return dbType.quoteIdentifier(table) + } else { + let quotedDb = dbType.quoteIdentifier(databaseName) + let quotedTable = dbType.quoteIdentifier(table) + return "\(quotedDb).\(quotedTable)" + } + } + + private func escapeJSIdentifier(_ name: String) -> String { + guard let firstChar = name.first, + !firstChar.isNumber, + name.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) else { + return "[\"\(PluginExportUtilities.escapeJSONString(name))\"]" + } + return name + } + + private func mapToPluginResult(_ result: QueryResult) -> PluginQueryResult { + PluginQueryResult( + columns: result.columns, + columnTypeNames: result.columnTypes.map { $0.rawType ?? "" }, + rows: result.rows, + rowsAffected: result.rowsAffected, + executionTime: result.executionTime + ) + } +} diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index cf9c4304..34ab2a1e 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -17,6 +17,8 @@ final class PluginManager { private(set) var driverPlugins: [String: any DriverPlugin] = [:] + private(set) var exportPlugins: [String: any ExportFormatPlugin] = [:] + private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL } private var userPluginsDir: URL { @@ -51,7 +53,7 @@ final class PluginManager { loadPlugins(from: userPluginsDir, source: .userInstalled) - Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s)") + Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s)") } private func loadPlugins(from directory: URL, source: PluginSource) { @@ -146,6 +148,12 @@ final class PluginManager { } Self.logger.debug("Registered driver plugin '\(pluginId)' for database type '\(typeId)'") } + + if let exportPlugin = instance as? any ExportFormatPlugin { + let formatId = type(of: exportPlugin).formatId + exportPlugins[formatId] = exportPlugin + Self.logger.debug("Registered export plugin '\(pluginId)' for format '\(formatId)'") + } } private func unregisterCapabilities(pluginId: String) { @@ -157,6 +165,14 @@ final class PluginManager { } return true } + + exportPlugins = exportPlugins.filter { _, value in + guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true } + if let principalClass = entry.bundle.principalClass as? any ExportFormatPlugin.Type { + return principalClass.formatId != type(of: value).formatId + } + return true + } } // MARK: - Enable / Disable diff --git a/TablePro/Core/Plugins/PluginModels.swift b/TablePro/Core/Plugins/PluginModels.swift index 94e9495c..b3492c32 100644 --- a/TablePro/Core/Plugins/PluginModels.swift +++ b/TablePro/Core/Plugins/PluginModels.swift @@ -43,4 +43,8 @@ extension PluginEntry { var defaultPort: Int? { driverPlugin?.defaultPort } + + var exportPlugin: (any ExportFormatPlugin.Type)? { + bundle.principalClass as? any ExportFormatPlugin.Type + } } diff --git a/TablePro/Core/Services/Export/ExportService+SQL.swift b/TablePro/Core/Services/Export/ExportService+SQL.swift deleted file mode 100644 index f6f2232b..00000000 --- a/TablePro/Core/Services/Export/ExportService+SQL.swift +++ /dev/null @@ -1,249 +0,0 @@ -// -// ExportService+SQL.swift -// TablePro -// - -import Foundation -import os - -extension ExportService { - func exportToSQL( - tables: [ExportTableItem], - config: ExportConfiguration, - to url: URL - ) async throws { - // For gzip, write to temp file first then compress - // For non-gzip, stream directly to destination - let targetURL: URL - let tempFileURL: URL? - - if config.sqlOptions.compressWithGzip { - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".sql") - tempFileURL = tempURL - targetURL = tempURL - } else { - tempFileURL = nil - targetURL = url - } - - // Create file and get handle for streaming writes - let fileHandle = try createFileHandle(at: targetURL) - - do { - // Add header comment - let dateFormatter = ISO8601DateFormatter() - 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()) - - // Collect and emit dependent sequences and enum types (PostgreSQL) in batch - var emittedSequenceNames: Set = [] - var emittedTypeNames: Set = [] - let structureTableNames = tables.filter { $0.sqlOptions.includeStructure }.map(\.name) - - var allSequences: [String: [(name: String, ddl: String)]] = [:] - do { - allSequences = try await driver.fetchAllDependentSequences(forTables: structureTableNames) - } catch { - Self.logger.warning("Failed to fetch dependent sequences: \(error.localizedDescription)") - } - - var allEnumTypes: [String: [(name: String, labels: [String])]] = [:] - do { - allEnumTypes = try await driver.fetchAllDependentTypes(forTables: structureTableNames) - } catch { - Self.logger.warning("Failed to fetch dependent enum types: \(error.localizedDescription)") - } - - for table in tables where table.sqlOptions.includeStructure { - let sequences = allSequences[table.name] ?? [] - for seq in sequences where !emittedSequenceNames.contains(seq.name) { - emittedSequenceNames.insert(seq.name) - let quotedName = "\"\(seq.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - try fileHandle.write(contentsOf: "DROP SEQUENCE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) - try fileHandle.write(contentsOf: "\(seq.ddl)\n\n".toUTF8Data()) - } - - let enumTypes = allEnumTypes[table.name] ?? [] - for enumType in enumTypes where !emittedTypeNames.contains(enumType.name) { - emittedTypeNames.insert(enumType.name) - let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\"" - try fileHandle.write(contentsOf: "DROP TYPE IF EXISTS \(quotedName) CASCADE;\n".toUTF8Data()) - let quotedLabels = enumType.labels.map { "'\(SQLEscaping.escapeStringLiteral($0, databaseType: databaseType))'" } - try fileHandle.write(contentsOf: "CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n\n".toUTF8Data()) - } - } - - for (index, table) in tables.enumerated() { - try checkCancellation() - - state.currentTableIndex = index + 1 - state.currentTable = table.qualifiedName - - let sqlOptions = table.sqlOptions - let tableRef = databaseType.quoteIdentifier(table.name) - - let sanitizedName = sanitizeForSQLComment(table.name) - try fileHandle.write(contentsOf: "-- --------------------------------------------------------\n".toUTF8Data()) - try fileHandle.write(contentsOf: "-- Table: \(sanitizedName)\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".toUTF8Data()) - } - - // CREATE TABLE (structure) - if sqlOptions.includeStructure { - do { - let ddl = try await driver.fetchTableDDL(table: table.name) - try fileHandle.write(contentsOf: ddl.toUTF8Data()) - if !ddl.hasSuffix(";") { - try fileHandle.write(contentsOf: ";".toUTF8Data()) - } - 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 ddlWarning = "Warning: failed to fetch DDL for table \(sanitizedName): \(error)" - Self.logger.warning("Failed to fetch DDL for table \(sanitizedName): \(error)") - try fileHandle.write(contentsOf: "-- \(sanitizeForSQLComment(ddlWarning))\n\n".toUTF8Data()) - } - } - - // INSERT statements (data) - stream directly to file in batches - if sqlOptions.includeData { - let batchSize = config.sqlOptions.batchSize - var offset = 0 - var wroteAnyRows = false - - while true { - try checkCancellation() - try Task.checkCancellation() - - let query: String - switch databaseType { - case .oracle: - query = "SELECT * FROM \(tableRef) ORDER BY 1 OFFSET \(offset) ROWS FETCH NEXT \(batchSize) ROWS ONLY" - case .mssql: - query = "SELECT * FROM \(tableRef) ORDER BY (SELECT NULL) OFFSET \(offset) ROWS FETCH NEXT \(batchSize) ROWS ONLY" - default: - query = "SELECT * FROM \(tableRef) LIMIT \(batchSize) OFFSET \(offset)" - } - let result = try await driver.execute(query: query) - - if result.rows.isEmpty { - break - } - - try await writeInsertStatementsWithProgress( - table: table, - columns: result.columns, - rows: result.rows, - batchSize: batchSize, - to: fileHandle - ) - - wroteAnyRows = true - offset += batchSize - } - - if wroteAnyRows { - try fileHandle.write(contentsOf: "\n".toUTF8Data()) - } - } - } - - try fileHandle.close() - } catch { - closeFileHandle(fileHandle) - if let tempURL = tempFileURL { - try? FileManager.default.removeItem(at: tempURL) - } - throw error - } - - // Handle gzip compression - if config.sqlOptions.compressWithGzip, let tempURL = tempFileURL { - state.statusMessage = "Compressing..." - await Task.yield() - - 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) - } catch { - // Remove the (possibly partially written) destination file on compression failure - try? FileManager.default.removeItem(at: url) - throw error - } - } - - // Surface DDL failures to user as a warning - if !ddlFailures.isEmpty { - let failedTables = ddlFailures.joined(separator: ", ") - state.warningMessage = "Export completed with warnings: Could not fetch table structure for: \(failedTables)" - } - - state.progress = 1.0 - } - - private func writeInsertStatementsWithProgress( - table: ExportTableItem, - columns: [String], - rows: [[String?]], - batchSize: Int, - to fileHandle: FileHandle - ) async throws { - // Use unqualified table name for INSERT statements (schema-agnostic export) - let tableRef = databaseType.quoteIdentifier(table.name) - let quotedColumns = columns - .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() - - let values = row.map { value -> String in - guard let val = value else { return "NULL" } - // Use proper SQL escaping to prevent injection (handles backslashes, quotes, etc.) - let escaped = SQLEscaping.escapeStringLiteral(val, databaseType: databaseType) - return "'\(escaped)'" - }.joined(separator: ", ") - - 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() - } -} diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 3429f210..529ce034 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -2,17 +2,14 @@ // ExportService.swift // TablePro // -// Service responsible for exporting table data to CSV, JSON, and SQL formats. -// Supports configurable options for each format including compression. -// import Foundation import Observation import os +import TableProPluginKit // MARK: - Export Error -/// Errors that can occur during export operations enum ExportError: LocalizedError { case notConnected case noTablesSelected @@ -20,6 +17,7 @@ enum ExportError: LocalizedError { case compressionFailed case fileWriteFailed(String) case encodingFailed + case formatNotFound(String) var errorDescription: String? { switch self { @@ -35,26 +33,14 @@ enum ExportError: LocalizedError { return String(localized: "Failed to write file: \(path)") case .encodingFailed: return String(localized: "Failed to encode content as UTF-8") + case .formatNotFound(let formatId): + return String(localized: "Export format '\(formatId)' not found") } } } -// MARK: - String Extension for Safe Encoding - -internal 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 State -/// Consolidated state struct to minimize @Published update overhead. -/// A single @Published property avoids N separate objectWillChange notifications per batch iteration. struct ExportState { var isExporting: Bool = false var progress: Double = 0.0 @@ -70,20 +56,19 @@ struct ExportState { // MARK: - Export Service -/// Service responsible for exporting table data to various formats @MainActor @Observable final class ExportService { static let logger = Logger(subsystem: "com.TablePro", category: "ExportService") - // swiftlint:disable:next force_try - static let decimalFormatRegex = try! NSRegularExpression(pattern: #"^[+-]?\d+\.\d+$"#) - // MARK: - Published State var state = ExportState() - // MARK: - DDL Failure Tracking + private let driver: DatabaseDriver + private let databaseType: DatabaseType - /// Tables that failed DDL fetch during SQL export - var ddlFailures: [String] = [] + init(driver: DatabaseDriver, databaseType: DatabaseType) { + self.driver = driver + self.databaseType = databaseType + } // MARK: - Cancellation @@ -102,37 +87,15 @@ final class ExportService { } } - // MARK: - Progress Throttling - - /// Number of rows to process before updating UI - let progressUpdateInterval: Int = 1_000 - /// Internal counter for processed rows (updated every row) - var internalProcessedRows: Int = 0 - - // MARK: - Dependencies - - let driver: DatabaseDriver - 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 + currentProgress?.cancel() } - /// 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 + private var currentProgress: PluginExportProgress? + + // MARK: - Public API + func export( tables: [ExportTableItem], config: ExportConfiguration, @@ -142,45 +105,86 @@ final class ExportService { throw ExportError.noTablesSelected } + guard let plugin = PluginManager.shared.exportPlugins[config.formatId] else { + throw ExportError.formatNotFound(config.formatId) + } + // Reset state state = ExportState(isExporting: true, totalTables: tables.count) isCancelled = false - internalProcessedRows = 0 - ddlFailures = [] defer { state.isExporting = false isCancelled = false state.statusMessage = "" + currentProgress = nil } - // Fetch total row counts for all tables + // Fetch total row counts state.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) - case .xlsx: - try await exportToXLSX(tables: tables, config: config, to: url) - case .mql: - try await exportToMQL(tables: tables, config: config, to: url) + // Create data source adapter + let dataSource = ExportDataSourceAdapter(driver: driver, databaseType: databaseType) + + // Create progress tracker + let progress = PluginExportProgress() + currentProgress = progress + progress.setTotalRows(state.totalRows) + + // Wire progress updates to UI state (coalesced to avoid main actor flooding) + let pendingUpdate = ProgressUpdateCoalescer() + progress.onUpdate = { [weak self] table, index, rows, total, status in + let shouldDispatch = pendingUpdate.markPending() + if shouldDispatch { + Task { @MainActor [weak self] in + pendingUpdate.clearPending() + guard let self else { return } + self.state.currentTable = table + self.state.currentTableIndex = index + self.state.processedRows = rows + if total > 0 { + self.state.progress = Double(rows) / Double(total) + } + if !status.isEmpty { + self.state.statusMessage = status + } + } } + } + + // Convert ExportTableItems to PluginExportTables + let pluginTables = tables.map { table in + PluginExportTable( + name: table.name, + databaseName: table.databaseName, + tableType: table.type == .view ? "view" : "table", + optionValues: table.optionValues + ) + } + + do { + try await plugin.export( + tables: pluginTables, + dataSource: dataSource, + destination: url, + progress: progress + ) } catch { - // Clean up partial file on cancellation or error try? FileManager.default.removeItem(at: url) state.errorMessage = error.localizedDescription throw error } + + let pluginWarnings = plugin.warnings + if !pluginWarnings.isEmpty { + state.warningMessage = pluginWarnings.joined(separator: "\n") + } + + state.progress = 1.0 } - /// 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. + // MARK: - Row Count Fetching + private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int { guard !tables.isEmpty else { return 0 } @@ -205,14 +209,23 @@ final class ExportService { return total } - // Batch all COUNT(*) into a single UNION ALL query per chunk let chunkSize = 50 for chunkStart in stride(from: 0, to: tables.count, by: chunkSize) { let end = min(chunkStart + chunkSize, tables.count) let batch = tables[chunkStart ..< end] - let unionParts = batch.map { "SELECT COUNT(*) AS c FROM \(qualifiedTableRef(for: $0))" } + let unionParts = batch.map { table -> String in + let tableRef: String + if table.databaseName.isEmpty { + tableRef = databaseType.quoteIdentifier(table.name) + } else { + let quotedDb = databaseType.quoteIdentifier(table.databaseName) + let quotedTable = databaseType.quoteIdentifier(table.name) + tableRef = "\(quotedDb).\(quotedTable)" + } + return "SELECT COUNT(*) AS c FROM \(tableRef)" + } let batchQuery = unionParts.joined(separator: " UNION ALL ") do { @@ -225,7 +238,14 @@ final class ExportService { } catch { for table in batch { do { - let tableRef = qualifiedTableRef(for: table) + let tableRef: String + if table.databaseName.isEmpty { + tableRef = databaseType.quoteIdentifier(table.name) + } else { + let quotedDb = databaseType.quoteIdentifier(table.databaseName) + let quotedTable = databaseType.quoteIdentifier(table.name) + tableRef = "\(quotedDb).\(quotedTable)" + } 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 @@ -244,229 +264,28 @@ final class ExportService { } return total } +} - /// Check if export was cancelled and throw if so - func checkCancellation() throws { - if isCancelled { - throw NSError( - domain: "ExportService", - code: NSUserCancelledError, - userInfo: [NSLocalizedDescriptionKey: "Export cancelled"] - ) - } - } - - /// Increment processed rows with throttled UI updates - /// Only updates @Published properties every `progressUpdateInterval` rows - /// Uses Task.yield() to allow UI to refresh - func incrementProgress() async { - internalProcessedRows += 1 - - // Only update UI every N rows - if internalProcessedRows % progressUpdateInterval == 0 { - state.processedRows = internalProcessedRows - if state.totalRows > 0 { - state.progress = Double(internalProcessedRows) / Double(state.totalRows) - } - // Yield to allow UI to update - await Task.yield() - } - } - - /// Finalize progress for current table (ensures UI shows final count) - func finalizeTableProgress() async { - state.processedRows = internalProcessedRows - if state.totalRows > 0 { - state.progress = Double(internalProcessedRows) / Double(state.totalRows) - } - // Yield to allow UI to update - await Task.yield() - } - - // MARK: - Helpers - - /// Build fully qualified and quoted table reference (database.table or just table) - 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)" - } - } - - func fetchAllQuery(for table: ExportTableItem) -> String { - switch databaseType { - case .mongodb: - let escaped = escapeJSIdentifier(table.name) - if escaped.hasPrefix("[") { - return "db\(escaped).find({})" - } - return "db.\(escaped).find({})" - case .redis: - return "SCAN 0 MATCH \"*\" COUNT 10000" - default: - return "SELECT * FROM \(qualifiedTableRef(for: table))" - } - } - - func fetchBatch(for table: ExportTableItem, offset: Int, limit: Int) async throws -> QueryResult { - let query = fetchAllQuery(for: table) - return try await driver.fetchRows(query: query, offset: offset, limit: limit) - } - - /// Sanitize a name for use in SQL comments to prevent comment injection - /// - /// 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. - 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 sequences (both opening and closing) - result = result.replacingOccurrences(of: "/*", with: "") - result = result.replacingOccurrences(of: "*/", with: "") - result = result.replacingOccurrences(of: "--", with: "") - - // Log when sanitization modifies the name - if result != name { - Self.logger.warning("Table name '\(name)' was sanitized to '\(result)' for SQL comment safety") - } - - return result - } - - // MARK: - File Helpers - - /// Create a file at the given URL and return a FileHandle for writing - func createFileHandle(at url: URL) throws -> FileHandle { - guard FileManager.default.createFile(atPath: url.path(percentEncoded: false), contents: nil) else { - throw ExportError.fileWriteFailed(url.path(percentEncoded: false)) - } - 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. - func closeFileHandle(_ handle: FileHandle) { - do { - try handle.close() - } catch { - Self.logger.warning("Failed to close export file handle: \(error.localizedDescription)") - } - } - - func escapeJSONString(_ string: String) -> String { - var utf8Result = [UInt8]() - utf8Result.reserveCapacity(string.utf8.count) - - for byte in string.utf8 { - switch byte { - case 0x22: // " - utf8Result.append(0x5C) // backslash - utf8Result.append(0x22) - case 0x5C: // backslash - utf8Result.append(0x5C) - utf8Result.append(0x5C) - case 0x0A: // \n - utf8Result.append(0x5C) - utf8Result.append(0x6E) // n - case 0x0D: // \r - utf8Result.append(0x5C) - utf8Result.append(0x72) // r - case 0x09: // \t - utf8Result.append(0x5C) - utf8Result.append(0x74) // t - case 0x08: // backspace - utf8Result.append(0x5C) - utf8Result.append(0x62) // b - case 0x0C: // form feed - utf8Result.append(0x5C) - utf8Result.append(0x66) // f - case 0x00...0x1F: - // Other control characters: emit \uXXXX - let hex = String(format: "\\u%04X", byte) - utf8Result.append(contentsOf: hex.utf8) - default: - utf8Result.append(byte) - } - } - - return String(bytes: utf8Result, encoding: .utf8) ?? string +// MARK: - Progress Update Coalescer + +/// Ensures only one `Task { @MainActor }` is in-flight at a time to prevent +/// flooding the main actor queue during high-throughput exports. +private final class ProgressUpdateCoalescer: @unchecked Sendable { + private let lock = NSLock() + private var isPending = false + + /// Returns `true` if the caller should dispatch a UI update (no update is in-flight). + func markPending() -> Bool { + lock.lock() + defer { lock.unlock() } + if isPending { return false } + isPending = true + return true } - // MARK: - Compression - - 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(percentEncoded: false), contents: nil) else { - throw ExportError.fileWriteFailed(destination.path(percentEncoded: false)) - } - - // Use gzip to compress the file - let process = Process() - process.executableURL = URL(fileURLWithPath: gzipPath) - - // 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() - } - process.standardOutput = outputFile - - // Capture stderr to provide detailed error messages on failure - let errorPipe = Pipe() - process.standardError = errorPipe - - // Safe: already inside Task.detached, so waitUntilExit() won't block MainActor - try process.run() - process.waitUntilExit() - - 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) ?? "" - - 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 + func clearPending() { + lock.lock() + isPending = false + lock.unlock() } } diff --git a/TablePro/Models/Export/ExportModels.swift b/TablePro/Models/Export/ExportModels.swift index 6a72ffe3..eeca5bc2 100644 --- a/TablePro/Models/Export/ExportModels.swift +++ b/TablePro/Models/Export/ExportModels.swift @@ -2,230 +2,41 @@ // 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" - case mql = "MQL" - case xlsx = "XLSX" - - 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" - case .mql: return "js" - case .xlsx: return "xlsx" - } - } - - static func availableCases(for databaseType: DatabaseType) -> [ExportFormat] { - switch databaseType { - case .mongodb: - return [.csv, .json, .mql, .xlsx] - case .redis: - return [.csv, .json, .xlsx] - default: - return allCases.filter { $0 != .mql } - } - } -} - -// 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 } - - var displayName: String { - switch self { - case .always: return String(localized: "Always") - case .asNeeded: return String(localized: "Quote if needed") - case .never: return String(localized: "Never") - } - } -} - -/// 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 - /// 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 - -/// Options for JSON export -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 - -/// Per-table SQL export options (Structure, Drop, Data checkboxes) -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 - } -} - -/// Per-collection MQL export options (Drop, Data, Indexes checkboxes) -struct MQLTableExportOptions: Equatable { - var includeDrop: Bool = true - var includeData: Bool = true - var includeIndexes: Bool = true - - var hasAnyOption: Bool { - includeDrop || includeData || includeIndexes - } -} - -/// 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: - MQL Options - -/// Options for MQL (MongoDB Query Language) export -struct MQLExportOptions: Equatable { - var batchSize: Int = 500 -} - -// MARK: - XLSX Options - -/// Options for Excel (.xlsx) export -struct XLSXExportOptions: Equatable { - var includeHeaderRow: Bool = true - var convertNullToEmpty: Bool = true -} +import TableProPluginKit // MARK: - Export Configuration -/// Complete export configuration combining format, selection, and options +@MainActor struct ExportConfiguration { - var format: ExportFormat = .csv + var formatId: String = "csv" var fileName: String = "export" - var csvOptions = CSVExportOptions() - var jsonOptions = JSONExportOptions() - var sqlOptions = SQLExportOptions() - var mqlOptions = MQLExportOptions() - var xlsxOptions = XLSXExportOptions() - /// Full file name including extension var fullFileName: String { - let ext = compressedExtension ?? format.fileExtension - return "\(fileName).\(ext)" + guard let plugin = PluginManager.shared.exportPlugins[formatId] else { + return "\(fileName).\(formatId)" + } + return "\(fileName).\(plugin.currentFileExtension)" } - private var compressedExtension: String? { - if format == .sql && sqlOptions.compressWithGzip { - return "sql.gz" + var fileExtension: String { + guard let plugin = PluginManager.shared.exportPlugins[formatId] else { + return formatId } - return nil + return plugin.currentFileExtension } } // 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() - var mqlOptions = MQLTableExportOptions() + var optionValues: [Bool] = [] init( id: UUID = UUID(), @@ -233,24 +44,20 @@ struct ExportTableItem: Identifiable, Hashable { databaseName: String = "", type: TableInfo.TableType, isSelected: Bool = false, - sqlOptions: SQLTableExportOptions = SQLTableExportOptions(), - mqlOptions: MQLTableExportOptions = MQLTableExportOptions() + optionValues: [Bool] = [] ) { self.id = id self.name = name self.databaseName = databaseName self.type = type self.isSelected = isSelected - self.sqlOptions = sqlOptions - self.mqlOptions = mqlOptions + self.optionValues = optionValues } - /// 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) } @@ -260,7 +67,6 @@ struct ExportTableItem: Identifiable, Hashable { } } -/// Represents a database item in the export tree view (contains tables) struct ExportDatabaseItem: Identifiable { let id: UUID let name: String @@ -279,22 +85,18 @@ struct ExportDatabaseItem: Identifiable { self.isExpanded = isExpanded } - /// Number of selected tables var selectedCount: Int { tables.count(where: \.isSelected) } - /// 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/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 7569f2fb..e84febf1 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -653,6 +653,7 @@ } }, "1 (no batching)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -1209,6 +1210,7 @@ }, "Always" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2116,6 +2118,7 @@ } }, "Compress the file using Gzip" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2360,6 +2363,7 @@ } }, "Convert line break to space" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2370,6 +2374,7 @@ } }, "Convert NULL to empty" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2380,6 +2385,7 @@ } }, "Convert NULL to EMPTY" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -3047,6 +3053,7 @@ } }, "Decimal" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -3258,6 +3265,7 @@ } }, "Delimiter" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -3394,6 +3402,7 @@ } }, "Drop" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -4014,6 +4023,9 @@ }, "Export Format" : { + }, + "Export format '%@' not found" : { + }, "Export Formats" : { @@ -4049,6 +4061,7 @@ } }, "Exports data as mongosh-compatible scripts. Drop, Indexes, and Data options are configured per collection in the collection list." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -4701,6 +4714,7 @@ } }, "Higher values create fewer INSERT statements, resulting in smaller files and faster imports" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -4929,6 +4943,7 @@ } }, "Include column headers" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -4959,6 +4974,7 @@ } }, "Include NULL values" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5011,6 +5027,7 @@ } }, "Indexes" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5251,6 +5268,7 @@ } }, "Keep leading zeros in ZIP codes, phone numbers, and IDs by outputting all values as strings" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5419,6 +5437,7 @@ } }, "Line break" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -6552,6 +6571,7 @@ } }, "Number of documents per insertMany statement. Higher values create fewer statements." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7037,6 +7057,7 @@ } }, "Preserve all values as strings" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7067,6 +7088,7 @@ } }, "Pretty print (formatted output)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7077,6 +7099,7 @@ } }, "Prevent CSV formula injection by prefixing values starting with =, +, -, @ with a single quote" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7267,6 +7290,7 @@ } }, "Put field names in the first row" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7368,6 +7392,7 @@ } }, "Quote" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7378,6 +7403,7 @@ } }, "Quote if needed" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7882,6 +7908,7 @@ } }, "Rows per INSERT" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7892,6 +7919,7 @@ } }, "Rows per insertMany" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -7932,6 +7960,7 @@ } }, "Sanitize formula-like values" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8890,6 +8919,7 @@ } }, "Structure, Drop, and Data options are configured per table in the table list." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 80d73225..6882e224 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -2,13 +2,14 @@ // ExportDialog.swift // TablePro // -// Main export dialog for exporting tables to CSV, JSON, or SQL formats. +// Main export dialog for exporting tables using format plugins. // Features a split layout with table selection tree on the left and format options on the right. // import AppKit import Observation import SwiftUI +import TableProPluginKit import UniformTypeIdentifiers /// Main export dialog view @@ -62,16 +63,16 @@ struct ExportDialog: View { .frame(width: dialogWidth) .background(Color(nsColor: .windowBackgroundColor)) .onAppear { - if connection.type == .mongodb && config.format == .sql { - config.format = .mql - } - if connection.type == .redis && config.format == .sql { - config.format = .json - } - if connection.type == .mssql && config.format == .mql { - config.format = .sql + let available = availableFormats + if !available.contains(where: { type(of: $0).formatId == config.formatId }) { + if let first = available.first { + config.formatId = type(of: first).formatId + } } } + .onChange(of: config.formatId) { + resetOptionValues() + } .onExitCommand { if !isExporting { isPresented = false @@ -108,10 +109,41 @@ struct ExportDialog: View { } } + // MARK: - Plugin Helpers + + private var availableFormats: [any ExportFormatPlugin] { + let dbTypeId = connection.type.rawValue + return PluginManager.shared.exportPlugins.values + .filter { plugin in + let pluginType = type(of: plugin) + if !pluginType.supportedDatabaseTypeIds.isEmpty { + return pluginType.supportedDatabaseTypeIds.contains(dbTypeId) + } + if pluginType.excludedDatabaseTypeIds.contains(dbTypeId) { + return false + } + return true + } + .sorted { a, b in + let aIndex = Self.formatDisplayOrder.firstIndex(of: type(of: a).formatId) ?? Int.max + let bIndex = Self.formatDisplayOrder.firstIndex(of: type(of: b).formatId) ?? Int.max + return aIndex < bIndex + } + } + + private var availableFormatIds: [String] { + availableFormats.map { type(of: $0).formatId } + } + + private var currentPlugin: (any ExportFormatPlugin)? { + PluginManager.shared.exportPlugins[config.formatId] + } + // MARK: - Layout Constants private var leftPanelWidth: CGFloat { - (config.format == .sql || config.format == .mql) ? 380 : 240 + guard let plugin = currentPlugin else { return 240 } + return type(of: plugin).perTableOptionColumns.isEmpty ? 240 : 380 } private var dialogWidth: CGFloat { @@ -130,36 +162,13 @@ struct ExportDialog: View { Spacer() - 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) - } else if config.format == .mql { - Text("Drop") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) - .foregroundStyle(.secondary) - .frame(width: 44, alignment: .center) - - Text("Indexes") - .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) + if let plugin = currentPlugin { + ForEach(type(of: plugin).perTableOptionColumns) { column in + Text(column.label) + .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) + .foregroundStyle(.secondary) + .frame(width: column.width, alignment: .center) + } } } .padding(.horizontal, 12) @@ -183,7 +192,7 @@ struct ExportDialog: View { } else { ExportTableTreeView( databaseItems: $databaseItems, - format: config.format + formatId: config.formatId ) .frame(minHeight: 300, maxHeight: .infinity) } @@ -199,9 +208,11 @@ struct ExportDialog: View { HStack { Spacer() - Picker("", selection: $config.format) { - ForEach(ExportFormat.availableCases(for: connection.type)) { format in - Text(format.rawValue).tag(format) + Picker("", selection: $config.formatId) { + ForEach(availableFormatIds, id: \.self) { formatId in + if let plugin = PluginManager.shared.exportPlugins[formatId] { + Text(type(of: plugin).formatDisplayName).tag(formatId) + } } } .pickerStyle(.segmented) @@ -211,13 +222,13 @@ struct ExportDialog: View { Spacer() } - // Selection count (shows exportable count for SQL format when some tables have no options) + // Selection count (shows exportable count 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) - if (config.format == .sql || config.format == .mql) && exportableCount < selectedCount { + if let plugin = currentPlugin, !type(of: plugin).perTableOptionColumns.isEmpty, exportableCount < selectedCount { Text("\(selectedCount - exportableCount) skipped (no options)") .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.orange) @@ -234,17 +245,8 @@ struct ExportDialog: View { // 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) - case .mql: - ExportMQLOptionsView(options: $config.mqlOptions) - case .xlsx: - ExportXLSXOptionsView(options: $config.xlsxOptions) + if let optionsView = currentPlugin?.optionsView() { + optionsView } } .padding(.horizontal, 16) @@ -332,13 +334,8 @@ struct ExportDialog: View { private var exportableTables: [ExportTableItem] { let tables = selectedTables - if config.format == .sql { - return tables.filter { $0.sqlOptions.hasAnyOption } - } - if config.format == .mql { - return tables.filter { $0.mqlOptions.hasAnyOption } - } - return tables + guard let plugin = currentPlugin else { return tables } + return tables.filter { plugin.isTableExportable(optionValues: $0.optionValues) } } /// Count of tables that will actually produce output @@ -347,12 +344,11 @@ struct ExportDialog: View { } private var fileExtension: String { - if config.format == .sql && config.sqlOptions.compressWithGzip { - return "sql.gz" - } - return config.format.fileExtension + currentPlugin?.currentFileExtension ?? config.formatId } + private static let formatDisplayOrder = ["csv", "json", "sql", "xlsx", "mql"] + /// Windows reserved device names (case-insensitive) private static let windowsReservedNames: Set = [ "CON", "PRN", "AUX", "NUL", @@ -401,6 +397,15 @@ struct ExportDialog: View { fileNameValidationError == nil } + private func resetOptionValues() { + let defaults = currentPlugin?.defaultTableOptionValues() ?? [] + for dbIndex in databaseItems.indices { + for tableIndex in databaseItems[dbIndex].tables.indices { + databaseItems[dbIndex].tables[tableIndex].optionValues = defaults + } + } + } + // MARK: - Actions @MainActor @@ -645,22 +650,24 @@ struct ExportDialog: View { } 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" + let ext = fileExtension + if ext.contains(".") { + // Compound extension like "sql.gz" + let lastComponent = ext.components(separatedBy: ".").last ?? ext + savePanel.allowedContentTypes = [UTType(filenameExtension: lastComponent) ?? .data] + savePanel.nameFieldStringValue = "\(config.fileName).\(ext)" } else { - let utType = UTType(filenameExtension: config.format.fileExtension) ?? .plainText + let utType = UTType(filenameExtension: ext) ?? .plainText savePanel.allowedContentTypes = [utType] savePanel.nameFieldStringValue = config.fullFileName } - savePanel.message = "Export \(exportableCount) table(s) to \(config.format.rawValue)" + let formatName = currentPlugin.map { type(of: $0).formatDisplayName } ?? config.formatId.uppercased() + savePanel.message = "Export \(exportableCount) table(s) to \(formatName)" savePanel.begin { response in guard response == .OK, let url = savePanel.url else { return } diff --git a/TablePro/Views/Export/ExportJSONOptionsView.swift b/TablePro/Views/Export/ExportJSONOptionsView.swift deleted file mode 100644 index ac4d721c..00000000 --- a/TablePro/Views/Export/ExportJSONOptionsView.swift +++ /dev/null @@ -1,37 +0,0 @@ -// -// 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) - - 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)) - } -} - -// MARK: - Preview - -#Preview { - ExportJSONOptionsView(options: .constant(JSONExportOptions())) - .padding() - .frame(width: 300) -} diff --git a/TablePro/Views/Export/ExportSQLOptionsView.swift b/TablePro/Views/Export/ExportSQLOptionsView.swift deleted file mode 100644 index 8a8f55ee..00000000 --- a/TablePro/Views/Export/ExportSQLOptionsView.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// 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 - - /// Available batch size options - private static let batchSizeOptions = [1, 100, 500, 1_000] - - 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) - - // 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 ? String(localized: "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) - .font(.system(size: DesignConstants.FontSize.body)) - } - } -} - -// MARK: - Preview - -#Preview { - ExportSQLOptionsView(options: .constant(SQLExportOptions())) - .padding() - .frame(width: 300) -} diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift index e62e2968..04b32086 100644 --- a/TablePro/Views/Export/ExportTableTreeView.swift +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -8,10 +8,20 @@ import AppKit import SwiftUI +import TableProPluginKit struct ExportTableTreeView: View { @Binding var databaseItems: [ExportDatabaseItem] - let format: ExportFormat + let formatId: String + + private var optionColumns: [PluginExportOptionColumn] { + guard let plugin = PluginManager.shared.exportPlugins[formatId] else { return [] } + return type(of: plugin).perTableOptionColumns + } + + private var currentPlugin: (any ExportFormatPlugin)? { + PluginManager.shared.exportPlugins[formatId] + } var body: some View { VStack(spacing: 0) { @@ -44,14 +54,11 @@ struct ExportTableTreeView: View { let newState = !database.allSelected for index in allTables.wrappedValue.indices { allTables[index].isSelected.wrappedValue = newState - if newState && format == .sql { - if !allTables[index].sqlOptions.wrappedValue.hasAnyOption { - allTables[index].sqlOptions.wrappedValue = SQLTableExportOptions() - } - } - if newState && format == .mql { - if !allTables[index].mqlOptions.wrappedValue.hasAnyOption { - allTables[index].mqlOptions.wrappedValue = MQLTableExportOptions() + if newState && !optionColumns.isEmpty { + if allTables[index].wrappedValue.optionValues.isEmpty || + !allTables[index].wrappedValue.optionValues.contains(true) { + let defaults = currentPlugin?.defaultTableOptionValues() ?? Array(repeating: true, count: optionColumns.count) + allTables[index].optionValues.wrappedValue = defaults } } } @@ -82,19 +89,11 @@ struct ExportTableTreeView: View { private func tableRow(table: Binding) -> some View { HStack(spacing: 4) { - if format == .sql { + if !optionColumns.isEmpty { TristateCheckbox( - state: sqlTableCheckboxState(table.wrappedValue), + state: genericCheckboxState(table.wrappedValue), action: { - toggleTableSQLOptions(table) - } - ) - .frame(width: 18) - } else if format == .mql { - TristateCheckbox( - state: mqlTableCheckboxState(table.wrappedValue), - action: { - toggleTableMQLOptions(table) + toggleGenericOptions(table) } ) .frame(width: 18) @@ -113,125 +112,67 @@ struct ExportTableTreeView: View { .lineLimit(1) .truncationMode(.middle) - if format == .sql { - Spacer() - - Toggle("Structure", isOn: table.sqlOptions.includeStructure) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!table.wrappedValue.isSelected) - .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 56, alignment: .center) - .onChange(of: table.wrappedValue.sqlOptions) { _, newOptions in - table.isSelected.wrappedValue = newOptions.hasAnyOption - } - - Toggle("Drop", isOn: table.sqlOptions.includeDrop) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!table.wrappedValue.isSelected) - .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 44, alignment: .center) - - Toggle("Data", isOn: table.sqlOptions.includeData) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!table.wrappedValue.isSelected) - .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 44, alignment: .center) - } else if format == .mql { + if !optionColumns.isEmpty { Spacer() - Toggle("Drop", isOn: table.mqlOptions.includeDrop) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!table.wrappedValue.isSelected) - .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 44, alignment: .center) - .onChange(of: table.wrappedValue.mqlOptions) { _, newOptions in - table.isSelected.wrappedValue = newOptions.hasAnyOption - } - - Toggle("Indexes", isOn: table.mqlOptions.includeIndexes) - .toggleStyle(.checkbox) - .labelsHidden() - .disabled(!table.wrappedValue.isSelected) - .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 44, alignment: .center) - .onChange(of: table.wrappedValue.mqlOptions) { _, newOptions in - table.isSelected.wrappedValue = newOptions.hasAnyOption - } - - Toggle("Data", isOn: table.mqlOptions.includeData) + ForEach(Array(optionColumns.enumerated()), id: \.element.id) { colIndex, column in + Toggle(column.label, isOn: Binding( + get: { + guard colIndex < table.wrappedValue.optionValues.count else { return true } + return table.optionValues[colIndex].wrappedValue + }, + set: { newValue in + ensureOptionValues(table) + table.optionValues[colIndex].wrappedValue = newValue + let anyTrue = table.wrappedValue.optionValues.contains(true) + table.isSelected.wrappedValue = anyTrue + } + )) .toggleStyle(.checkbox) .labelsHidden() .disabled(!table.wrappedValue.isSelected) .opacity(table.wrappedValue.isSelected ? 1.0 : 0.4) - .frame(width: 44, alignment: .center) - .onChange(of: table.wrappedValue.mqlOptions) { _, newOptions in - table.isSelected.wrappedValue = newOptions.hasAnyOption - } + .frame(width: column.width, alignment: .center) + } } } } - private func sqlTableCheckboxState(_ table: ExportTableItem) -> TristateCheckbox.State { - let opts = table.sqlOptions - let count = (opts.includeStructure ? 1 : 0) + (opts.includeDrop ? 1 : 0) + (opts.includeData ? 1 : 0) - if !table.isSelected || count == 0 { return .unchecked } - if count == 3 { return .checked } + // MARK: - Generic Option Helpers + + private func genericCheckboxState(_ table: ExportTableItem) -> TristateCheckbox.State { + if !table.isSelected || table.optionValues.isEmpty { return .unchecked } + let trueCount = table.optionValues.count(where: { $0 }) + if trueCount == 0 { return .unchecked } + if trueCount == table.optionValues.count { return .checked } return .mixed } - private func toggleTableSQLOptions(_ table: Binding) { + private func toggleGenericOptions(_ table: Binding) { + ensureOptionValues(table) if !table.wrappedValue.isSelected { table.isSelected.wrappedValue = true - if !table.wrappedValue.sqlOptions.hasAnyOption { - table.sqlOptions.includeStructure.wrappedValue = true - table.sqlOptions.includeDrop.wrappedValue = true - table.sqlOptions.includeData.wrappedValue = true + if !table.wrappedValue.optionValues.contains(true) { + for i in table.wrappedValue.optionValues.indices { + table.optionValues[i].wrappedValue = true + } } } else { - let opts = table.wrappedValue.sqlOptions - let allChecked = opts.includeStructure && opts.includeDrop && opts.includeData - + let allChecked = table.wrappedValue.optionValues.allSatisfy { $0 } if allChecked { table.isSelected.wrappedValue = false } else { - table.sqlOptions.includeStructure.wrappedValue = true - table.sqlOptions.includeDrop.wrappedValue = true - table.sqlOptions.includeData.wrappedValue = true + for i in table.wrappedValue.optionValues.indices { + table.optionValues[i].wrappedValue = true + } } } } - private func mqlTableCheckboxState(_ table: ExportTableItem) -> TristateCheckbox.State { - let opts = table.mqlOptions - let count = (opts.includeDrop ? 1 : 0) + (opts.includeIndexes ? 1 : 0) + (opts.includeData ? 1 : 0) - if !table.isSelected || count == 0 { return .unchecked } - if count == 3 { return .checked } - return .mixed - } - - private func toggleTableMQLOptions(_ table: Binding) { - if !table.wrappedValue.isSelected { - table.isSelected.wrappedValue = true - if !table.wrappedValue.mqlOptions.hasAnyOption { - table.mqlOptions.includeDrop.wrappedValue = true - table.mqlOptions.includeData.wrappedValue = true - table.mqlOptions.includeIndexes.wrappedValue = true - } - } else { - let opts = table.wrappedValue.mqlOptions - let allChecked = opts.includeDrop && opts.includeData && opts.includeIndexes - - if allChecked { - table.isSelected.wrappedValue = false - } else { - table.mqlOptions.includeDrop.wrappedValue = true - table.mqlOptions.includeData.wrappedValue = true - table.mqlOptions.includeIndexes.wrappedValue = true - } + private func ensureOptionValues(_ table: Binding) { + if table.wrappedValue.optionValues.count < optionColumns.count { + let defaults = currentPlugin?.defaultTableOptionValues() ?? Array(repeating: true, count: optionColumns.count) + table.optionValues.wrappedValue = defaults } } } diff --git a/TablePro/Views/Export/ExportXLSXOptionsView.swift b/TablePro/Views/Export/ExportXLSXOptionsView.swift deleted file mode 100644 index f9c6520e..00000000 --- a/TablePro/Views/Export/ExportXLSXOptionsView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ExportXLSXOptionsView.swift -// TablePro -// -// Options panel for Excel (.xlsx) export format. -// - -import SwiftUI - -/// Options panel for XLSX export -struct ExportXLSXOptionsView: View { - @Binding var options: XLSXExportOptions - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - Toggle("Include column headers", isOn: $options.includeHeaderRow) - .toggleStyle(.checkbox) - - Toggle("Convert NULL to empty", isOn: $options.convertNullToEmpty) - .toggleStyle(.checkbox) - } - .font(.system(size: 13)) - } -} - -#Preview { - ExportXLSXOptionsView(options: .constant(XLSXExportOptions())) - .padding() - .frame(width: 280) -} diff --git a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift index 0fe43214..221f1cfa 100644 --- a/TableProTests/Core/Autocomplete/CompletionEngineTests.swift +++ b/TableProTests/Core/Autocomplete/CompletionEngineTests.swift @@ -9,7 +9,7 @@ import Foundation import Testing @testable import TablePro -@Suite("Completion Engine") +@Suite("Completion Engine", .serialized) struct CompletionEngineTests { private let schemaProvider: SQLSchemaProvider private let engine: CompletionEngine diff --git a/TableProTests/Core/Database/MSSQLDriverTests.swift b/TableProTests/Core/Database/MSSQLDriverTests.swift index 788d11af..f271f500 100644 --- a/TableProTests/Core/Database/MSSQLDriverTests.swift +++ b/TableProTests/Core/Database/MSSQLDriverTests.swift @@ -10,6 +10,48 @@ import Foundation import TableProPluginKit import Testing +// MARK: - Mock MSSQL Plugin Driver + +private final class MockMSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private var schema: String? + + init(initialSchema: String?) { + schema = initialSchema + } + + var currentSchema: String? { schema } + var supportsSchemas: Bool { true } + + func switchSchema(to schema: String) async throws { + self.schema = schema + } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + throw NSError( + domain: "MockMSSQLPluginDriver", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Not connected"] + ) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + @MainActor @Suite("MSSQL Driver") struct MSSQLDriverTests { @@ -23,20 +65,8 @@ struct MSSQLDriverTests { private func makeAdapter(mssqlSchema: String? = nil) -> PluginDriverAdapter { let conn = makeConnection(mssqlSchema: mssqlSchema) - let config = DriverConnectionConfig( - host: conn.host, - port: conn.port, - username: conn.username, - password: "", - database: conn.database, - additionalFields: [ - "mssqlSchema": mssqlSchema ?? "dbo" - ] - ) - guard let plugin = PluginManager.shared.driverPlugins["SQL Server"] else { - fatalError("SQL Server plugin not loaded") - } - let pluginDriver = plugin.createDriver(config: config) + let effectiveSchema: String? = if let s = mssqlSchema, !s.isEmpty { s } else { "dbo" } + let pluginDriver = MockMSSQLPluginDriver(initialSchema: effectiveSchema) return PluginDriverAdapter(connection: conn, pluginDriver: pluginDriver) } @@ -111,7 +141,7 @@ struct MSSQLDriverTests { // MARK: - Execute Tests @Test("Execute throws when not connected") - func executeThrowsWhenNotConnected() async { + func executeThrowsWhenNotConnected() async throws { let adapter = makeAdapter() await #expect(throws: (any Error).self) { _ = try await adapter.execute(query: "SELECT 1") diff --git a/TableProTests/Core/Redis/ExportModelsRedisTests.swift b/TableProTests/Core/Redis/ExportModelsRedisTests.swift index 166ef831..7359cd2b 100644 --- a/TableProTests/Core/Redis/ExportModelsRedisTests.swift +++ b/TableProTests/Core/Redis/ExportModelsRedisTests.swift @@ -1,24 +1,31 @@ import Testing @testable import TablePro -@Suite("ExportFormat.availableCases for Redis") +@Suite("Export format filtering for Redis") struct ExportModelsRedisTests { - @Test("Redis available cases are csv, json, xlsx") - func availableCases() { - let cases = ExportFormat.availableCases(for: .redis) - #expect(cases == [.csv, .json, .xlsx]) + @Test("ExportTableItem supports optionValues for generic per-table options") + func tableItemOptionValues() { + let item = ExportTableItem(name: "keys", type: .table, isSelected: true, optionValues: [true, false]) + #expect(item.optionValues.count == 2) + #expect(item.optionValues[0] == true) + #expect(item.optionValues[1] == false) } - @Test("Redis does not include sql") - func excludesSql() { - let cases = ExportFormat.availableCases(for: .redis) - #expect(!cases.contains(.sql)) + @Test("ExportTableItem defaults to empty optionValues") + func tableItemDefaultOptionValues() { + let item = ExportTableItem(name: "keys", type: .table) + #expect(item.optionValues.isEmpty) } - @Test("Redis does not include mql") - func excludesMql() { - let cases = ExportFormat.availableCases(for: .redis) - #expect(!cases.contains(.mql)) + @Test("ExportDatabaseItem tracks selected tables correctly") + func databaseItemSelection() { + let tables = [ + ExportTableItem(name: "keys", type: .table, isSelected: true), + ExportTableItem(name: "sets", type: .table, isSelected: false), + ] + let db = ExportDatabaseItem(name: "0", tables: tables) + #expect(db.selectedCount == 1) + #expect(db.selectedTables.map(\.name) == ["keys"]) } } diff --git a/TableProTests/Core/Redis/ExportServiceRedisTests.swift b/TableProTests/Core/Redis/ExportServiceRedisTests.swift index e6403027..22d6f2dc 100644 --- a/TableProTests/Core/Redis/ExportServiceRedisTests.swift +++ b/TableProTests/Core/Redis/ExportServiceRedisTests.swift @@ -2,55 +2,40 @@ // ExportServiceRedisTests.swift // TableProTests // -// Tests for Redis-specific export behavior beyond ExportFormat.availableCases -// (which is covered in ExportModelsRedisTests). -// -// NOTE: ExportService.fetchAllQuery is private and cannot be tested directly. -// The Redis SCAN query generation is internal to ExportService and would require -// either making it internal or extracting it into a testable helper. -// import Foundation import Testing @testable import TablePro -@Suite("Export Service Redis") +@Suite("Export service state") struct ExportServiceRedisTests { - @Test("Redis format count is three") - func redisFormatCount() { - let formats = ExportFormat.availableCases(for: .redis) - #expect(formats.count == 3) - } - @Test("MongoDB formats differ from Redis formats") - func mongoDBFormatsDifferFromRedis() { - let redisFormats = ExportFormat.availableCases(for: .redis) - let mongoFormats = ExportFormat.availableCases(for: .mongodb) - #expect(redisFormats != mongoFormats) + @Test("ExportState initializes with correct defaults") + func exportStateDefaults() { + let state = ExportState() + #expect(state.isExporting == false) + #expect(state.progress == 0.0) + #expect(state.currentTable == "") + #expect(state.totalTables == 0) + #expect(state.processedRows == 0) + #expect(state.totalRows == 0) + #expect(state.errorMessage == nil) } - @Test("SQL databases include SQL format unlike Redis") - func sqlDatabasesIncludeSQL() { - let mysqlFormats = ExportFormat.availableCases(for: .mysql) - #expect(mysqlFormats.contains(.sql)) - - let redisFormats = ExportFormat.availableCases(for: .redis) - #expect(!redisFormats.contains(.sql)) + @MainActor @Test("ExportConfiguration uses formatId string") + func exportConfigFormatId() { + var config = ExportConfiguration() + #expect(config.formatId == "csv") + config.formatId = "json" + #expect(config.formatId == "json") } - @Test("Redis and MySQL share CSV and JSON formats") - func redisAndMysqlShareCsvJson() { - let redisFormats = ExportFormat.availableCases(for: .redis) - let mysqlFormats = ExportFormat.availableCases(for: .mysql) - #expect(redisFormats.contains(.csv)) - #expect(redisFormats.contains(.json)) - #expect(mysqlFormats.contains(.csv)) - #expect(mysqlFormats.contains(.json)) - } + @Test("ExportError descriptions are localized") + func exportErrorDescriptions() { + let error = ExportError.noTablesSelected + #expect(error.errorDescription != nil) - @Test("Redis includes XLSX format") - func redisIncludesXlsx() { - let formats = ExportFormat.availableCases(for: .redis) - #expect(formats.contains(.xlsx)) + let formatError = ExportError.formatNotFound("parquet") + #expect(formatError.errorDescription?.contains("parquet") == true) } } diff --git a/TableProTests/Models/ExportModelsTests.swift b/TableProTests/Models/ExportModelsTests.swift index 8718a52e..af02f14c 100644 --- a/TableProTests/Models/ExportModelsTests.swift +++ b/TableProTests/Models/ExportModelsTests.swift @@ -12,78 +12,16 @@ import Testing @Suite("Export Models") struct ExportModelsTests { - @Test("Export format file extension for CSV") - func exportFormatCSV() { - #expect(ExportFormat.csv.fileExtension == "csv") - } - - @Test("Export format file extension for JSON") - func exportFormatJSON() { - #expect(ExportFormat.json.fileExtension == "json") - } - - @Test("Export format file extension for SQL") - func exportFormatSQL() { - #expect(ExportFormat.sql.fileExtension == "sql") - } - - @Test("Export format file extension for XLSX") - func exportFormatXLSX() { - #expect(ExportFormat.xlsx.fileExtension == "xlsx") - } - - @Test("Export format has five cases") - func exportFormatCaseCount() { - #expect(ExportFormat.allCases.count == 5) - } - - @Test("CSV delimiter comma actual value") - func csvDelimiterComma() { - #expect(CSVDelimiter.comma.actualValue == ",") - } - - @Test("CSV delimiter semicolon actual value") - func csvDelimiterSemicolon() { - #expect(CSVDelimiter.semicolon.actualValue == ";") - } - - @Test("CSV delimiter tab actual value") - func csvDelimiterTab() { - #expect(CSVDelimiter.tab.actualValue == "\t") - } - - @Test("CSV delimiter pipe actual value") - func csvDelimiterPipe() { - #expect(CSVDelimiter.pipe.actualValue == "|") - } - - @Test("Export configuration default full file name") - func exportConfigurationDefaultFullFileName() { + @MainActor @Test("Export configuration default format is csv") + func exportConfigurationDefaultFormat() { let config = ExportConfiguration() - #expect(config.fullFileName == "export.csv") - } - - @Test("Export configuration full file name with JSON format") - func exportConfigurationJSONFullFileName() { - var config = ExportConfiguration() - config.format = .json - #expect(config.fullFileName == "export.json") - } - - @Test("Export configuration full file name with compressed SQL") - func exportConfigurationCompressedSQL() { - var config = ExportConfiguration() - config.format = .sql - config.sqlOptions.compressWithGzip = true - #expect(config.fullFileName == "export.sql.gz") + #expect(config.formatId == "csv") } - @Test("Export configuration full file name with custom name") - func exportConfigurationCustomName() { - var config = ExportConfiguration() - config.fileName = "my_data" - config.format = .xlsx - #expect(config.fullFileName == "my_data.xlsx") + @MainActor @Test("Export configuration default file name") + func exportConfigurationDefaultFileName() { + let config = ExportConfiguration() + #expect(config.fileName == "export") } @Test("Export database item selected count with no tables") @@ -98,7 +36,7 @@ struct ExportModelsTests { func exportDatabaseItemAllSelected() { let tables = [ ExportTableItem(name: "users", type: .table, isSelected: true), - ExportTableItem(name: "posts", type: .table, isSelected: true) + ExportTableItem(name: "posts", type: .table, isSelected: true), ] let item = ExportDatabaseItem(name: "testdb", tables: tables) #expect(item.selectedCount == 2) @@ -110,7 +48,7 @@ struct ExportModelsTests { func exportDatabaseItemPartialSelection() { let tables = [ ExportTableItem(name: "users", type: .table, isSelected: true), - ExportTableItem(name: "posts", type: .table, isSelected: false) + ExportTableItem(name: "posts", type: .table, isSelected: false), ] let item = ExportDatabaseItem(name: "testdb", tables: tables) #expect(item.selectedCount == 1) @@ -122,7 +60,7 @@ struct ExportModelsTests { func exportDatabaseItemNoneSelected() { let tables = [ ExportTableItem(name: "users", type: .table, isSelected: false), - ExportTableItem(name: "posts", type: .table, isSelected: false) + ExportTableItem(name: "posts", type: .table, isSelected: false), ] let item = ExportDatabaseItem(name: "testdb", tables: tables) #expect(item.selectedCount == 0) @@ -135,7 +73,7 @@ struct ExportModelsTests { 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: "comments", type: .table, isSelected: true), ] let item = ExportDatabaseItem(name: "testdb", tables: tables) let selectedTables = item.selectedTables @@ -154,4 +92,16 @@ struct ExportModelsTests { let table = ExportTableItem(name: "users", databaseName: "mydb", type: .table, isSelected: true) #expect(table.qualifiedName == "mydb.users") } + + @Test("Export table item option values default to empty") + func exportTableItemOptionValuesDefault() { + let table = ExportTableItem(name: "users", type: .table) + #expect(table.optionValues.isEmpty) + } + + @Test("Export table item with option values") + func exportTableItemWithOptionValues() { + let table = ExportTableItem(name: "users", type: .table, isSelected: true, optionValues: [true, false, true]) + #expect(table.optionValues == [true, false, true]) + } }