From d66da665f9ecde6a147d00854a9f1f984a81c507 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 09:25:58 +0700 Subject: [PATCH 1/5] feat: extract export formats into plugin bundles Extract all 5 built-in export formats (CSV, JSON, SQL, XLSX, MQL) into .tableplugin bundles, making the export system plugin-based and extensible. - Add ExportFormatPlugin protocol, PluginExportDataSource, PluginExportProgress, and shared utilities to TableProPluginKit - Create 5 export plugin bundles with self-contained models, options views, and export logic - Simplify ExportService to thin orchestrator delegating to plugins - Dynamic format picker in ExportDialog filtered by database type - Generic per-table option columns replacing hardcoded SQL/MQL columns - Fix test parallelization causing false failures (scheme parallelizable=NO) - Update tests for new plugin-based export API --- CHANGELOG.md | 3 +- Plugins/CSVExportPlugin/CSVExportModels.swift | 82 +++ .../CSVExportOptionsView.swift | 38 +- .../CSVExportPlugin/CSVExportPlugin.swift | 120 ++-- Plugins/CSVExportPlugin/Info.plist | 8 + Plugins/JSONExportPlugin/Info.plist | 8 + .../JSONExportPlugin/JSONExportModels.swift | 14 + .../JSONExportOptionsView.swift | 25 + .../JSONExportPlugin/JSONExportPlugin.swift | 115 +-- Plugins/MQLExportPlugin/Info.plist | 8 + .../MQLExportPlugin/MQLExportHelpers.swift | 50 ++ Plugins/MQLExportPlugin/MQLExportModels.swift | 12 + .../MQLExportOptionsView.swift | 30 +- .../MQLExportPlugin/MQLExportPlugin.swift | 172 +++-- Plugins/SQLExportPlugin/Info.plist | 8 + .../SQLExportPlugin/SQLExportHelpers.swift | 24 + Plugins/SQLExportPlugin/SQLExportModels.swift | 13 + .../SQLExportOptionsView.swift | 46 ++ Plugins/SQLExportPlugin/SQLExportPlugin.swift | 331 +++++++++ .../ExportFormatPlugin.swift | 42 ++ .../PluginExportDataSource.swift | 23 + .../PluginExportProgress.swift | 103 +++ .../TableProPluginKit/PluginExportTypes.swift | 83 +++ .../PluginExportUtilities.swift | 65 ++ Plugins/XLSXExportPlugin/Info.plist | 8 + .../XLSXExportPlugin/XLSXExportModels.swift | 13 + .../XLSXExportOptionsView.swift | 21 + .../XLSXExportPlugin/XLSXExportPlugin.swift | 62 +- .../XLSXExportPlugin}/XLSXWriter.swift | 0 TablePro.xcodeproj/project.pbxproj | 675 ++++++++++++++++++ .../xcshareddata/xcschemes/TablePro.xcscheme | 2 +- .../Plugins/ExportDataSourceAdapter.swift | 104 +++ TablePro/Core/Plugins/PluginManager.swift | 18 +- TablePro/Core/Plugins/PluginModels.swift | 4 + .../Services/Export/ExportService+SQL.swift | 249 ------- .../Core/Services/Export/ExportService.swift | 383 +++------- TablePro/Models/Export/ExportModels.swift | 226 +----- TablePro/Resources/Localizable.xcstrings | 30 + TablePro/Views/Export/ExportDialog.swift | 139 ++-- .../Views/Export/ExportJSONOptionsView.swift | 37 - .../Views/Export/ExportSQLOptionsView.swift | 63 -- .../Views/Export/ExportTableTreeView.swift | 171 ++--- .../Views/Export/ExportXLSXOptionsView.swift | 30 - .../Core/Redis/ExportModelsRedisTests.swift | 33 +- .../Core/Redis/ExportServiceRedisTests.swift | 61 +- TableProTests/Models/ExportModelsTests.swift | 96 +-- docs/development/plugin-system/README.md | 74 ++ .../development/plugin-system/architecture.md | 115 +++ .../plugin-system/developer-guide.md | 243 +++++++ .../plugin-system/migration-guide.md | 164 +++++ docs/development/plugin-system/plugin-kit.md | 323 +++++++++ .../plugin-system/plugin-manager.md | 233 ++++++ docs/development/plugin-system/roadmap.md | 143 ++++ docs/development/plugin-system/security.md | 154 ++++ .../plugin-system/troubleshooting.md | 235 ++++++ docs/development/plugin-system/ui-design.md | 216 ++++++ 56 files changed, 4286 insertions(+), 1462 deletions(-) create mode 100644 Plugins/CSVExportPlugin/CSVExportModels.swift rename TablePro/Views/Export/ExportCSVOptionsView.swift => Plugins/CSVExportPlugin/CSVExportOptionsView.swift (71%) rename TablePro/Core/Services/Export/ExportService+CSV.swift => Plugins/CSVExportPlugin/CSVExportPlugin.swift (59%) create mode 100644 Plugins/CSVExportPlugin/Info.plist create mode 100644 Plugins/JSONExportPlugin/Info.plist create mode 100644 Plugins/JSONExportPlugin/JSONExportModels.swift create mode 100644 Plugins/JSONExportPlugin/JSONExportOptionsView.swift rename TablePro/Core/Services/Export/ExportService+JSON.swift => Plugins/JSONExportPlugin/JSONExportPlugin.swift (55%) create mode 100644 Plugins/MQLExportPlugin/Info.plist create mode 100644 Plugins/MQLExportPlugin/MQLExportHelpers.swift create mode 100644 Plugins/MQLExportPlugin/MQLExportModels.swift rename TablePro/Views/Export/ExportMQLOptionsView.swift => Plugins/MQLExportPlugin/MQLExportOptionsView.swift (55%) rename TablePro/Core/Services/Export/ExportService+MQL.swift => Plugins/MQLExportPlugin/MQLExportPlugin.swift (50%) create mode 100644 Plugins/SQLExportPlugin/Info.plist create mode 100644 Plugins/SQLExportPlugin/SQLExportHelpers.swift create mode 100644 Plugins/SQLExportPlugin/SQLExportModels.swift create mode 100644 Plugins/SQLExportPlugin/SQLExportOptionsView.swift create mode 100644 Plugins/SQLExportPlugin/SQLExportPlugin.swift create mode 100644 Plugins/TableProPluginKit/ExportFormatPlugin.swift create mode 100644 Plugins/TableProPluginKit/PluginExportDataSource.swift create mode 100644 Plugins/TableProPluginKit/PluginExportProgress.swift create mode 100644 Plugins/TableProPluginKit/PluginExportTypes.swift create mode 100644 Plugins/TableProPluginKit/PluginExportUtilities.swift create mode 100644 Plugins/XLSXExportPlugin/Info.plist create mode 100644 Plugins/XLSXExportPlugin/XLSXExportModels.swift create mode 100644 Plugins/XLSXExportPlugin/XLSXExportOptionsView.swift rename TablePro/Core/Services/Export/ExportService+XLSX.swift => Plugins/XLSXExportPlugin/XLSXExportPlugin.swift (53%) rename {TablePro/Core/Services/Export => Plugins/XLSXExportPlugin}/XLSXWriter.swift (100%) create mode 100644 TablePro/Core/Plugins/ExportDataSourceAdapter.swift delete mode 100644 TablePro/Core/Services/Export/ExportService+SQL.swift delete mode 100644 TablePro/Views/Export/ExportJSONOptionsView.swift delete mode 100644 TablePro/Views/Export/ExportSQLOptionsView.swift delete mode 100644 TablePro/Views/Export/ExportXLSXOptionsView.swift create mode 100644 docs/development/plugin-system/README.md create mode 100644 docs/development/plugin-system/architecture.md create mode 100644 docs/development/plugin-system/developer-guide.md create mode 100644 docs/development/plugin-system/migration-guide.md create mode 100644 docs/development/plugin-system/plugin-kit.md create mode 100644 docs/development/plugin-system/plugin-manager.md create mode 100644 docs/development/plugin-system/roadmap.md create mode 100644 docs/development/plugin-system/security.md create mode 100644 docs/development/plugin-system/troubleshooting.md create mode 100644 docs/development/plugin-system/ui-design.md 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..407b6d8e --- /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") + case .asNeeded: return String(localized: "Quote if needed") + case .never: return String(localized: "Never") + } + } +} + +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 71% rename from TablePro/Views/Export/ExportCSVOptionsView.swift rename to Plugins/CSVExportPlugin/CSVExportOptionsView.swift index 70e010b5..dbbe41dd 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) { + Picker("", selection: $plugin.options.delimiter) { ForEach(CSVDelimiter.allCases) { delimiter in Text(delimiter.displayName).tag(delimiter) } @@ -47,7 +41,7 @@ struct ExportCSVOptionsView: View { } optionRow(String(localized: "Quote")) { - Picker("", selection: $options.quoteHandling) { + Picker("", selection: $plugin.options.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) } @@ -58,7 +52,7 @@ struct ExportCSVOptionsView: View { } optionRow(String(localized: "Line break")) { - Picker("", selection: $options.lineBreak) { + Picker("", selection: $plugin.options.lineBreak) { ForEach(CSVLineBreak.allCases) { lineBreak in Text(lineBreak.rawValue).tag(lineBreak) } @@ -69,7 +63,7 @@ struct ExportCSVOptionsView: View { } optionRow(String(localized: "Decimal")) { - Picker("", selection: $options.decimalFormat) { + 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..933b043d 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 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,19 @@ 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() + progress.finalizeTable() } 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 +162,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 +174,11 @@ extension ExportService { return processed } } + + private 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) + } } 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..d1c5352a 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 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,18 @@ 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() + progress.finalizeTable() - // Close array if hasWrittenRow { try fileHandle.write(contentsOf: newline.toUTF8Data()) } @@ -115,38 +123,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 +159,13 @@ extension ExportService { return val.lowercased() } - // String value - escape and quote - return "\"\(escapeJSONString(val))\"" + return "\"\(PluginExportUtilities.escapeJSONString(val))\"" + } + + private 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) } } 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 50% rename from TablePro/Core/Services/Export/ExportService+MQL.swift rename to Plugins/MQLExportPlugin/MQLExportPlugin.swift index eec1e700..d77b14a3 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 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() - try fileHandle.write(contentsOf: "// Collection: \(sanitizeForSQLComment(table.name))\n".toUTF8Data()) + progress.setCurrentTable(table.qualifiedName, index: index + 1) - if mqlOpts.includeDrop { + let includeDrop = optionValue(table, at: 0) + let includeIndexes = optionValue(table, at: 1) + let includeData = optionValue(table, at: 2) + + 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,31 @@ extension ExportService { } } - // Indexes after data for performance - if mqlOpts.includeIndexes { + if includeIndexes { try await writeMQLIndexes( collection: table.name, collectionAccessor: collectionAccessor, + dataSource: dataSource, to: fileHandle ) } - await finalizeTableProgress() + progress.finalizeTable() 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 +168,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()) @@ -141,9 +178,13 @@ extension ExportService { private func writeMQLIndexes( collection: 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: "" + ) 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,10 @@ 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))\"]" + private 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 name + return try FileHandle(forWritingTo: url) } } 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..49638df6 --- /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)") : "\(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..5c782508 --- /dev/null +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -0,0 +1,331 @@ +// +// 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" + } + + 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 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 { + 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()) + } + + 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()) + } + } + + 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()) + } + + progress.finalizeTable() + } + + private 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) + } + + 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..fce7333e --- /dev/null +++ b/Plugins/TableProPluginKit/ExportFormatPlugin.swift @@ -0,0 +1,42 @@ +// +// 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 } + + 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 } + 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..9fa5381d --- /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 { + 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..d83af0e2 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginExportUtilities.swift @@ -0,0 +1,65 @@ +// +// 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 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.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index f99c67cb..35ece929 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -32,7 +32,7 @@ + parallelizable = "NO"> 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..5baa05e4 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,76 @@ 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 + progress.onUpdate = { [weak self] table, index, rows, total, status in + Task { @MainActor [weak self] in + 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 } + + 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 +199,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 +228,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 +254,4 @@ 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: - 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 - } } 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..d5a2f12c 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,14 +63,11 @@ 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 + } } } .onExitCommand { @@ -108,10 +106,37 @@ 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 { type(of: $0).formatDisplayName < type(of: $1).formatDisplayName } + } + + 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 +155,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 +185,7 @@ struct ExportDialog: View { } else { ExportTableTreeView( databaseItems: $databaseItems, - format: config.format + formatId: config.formatId ) .frame(minHeight: 300, maxHeight: .infinity) } @@ -199,9 +201,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 +215,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 +238,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 +327,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,10 +337,7 @@ 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 } /// Windows reserved device names (case-insensitive) @@ -645,22 +632,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/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]) + } } diff --git a/docs/development/plugin-system/README.md b/docs/development/plugin-system/README.md new file mode 100644 index 00000000..0f199b1f --- /dev/null +++ b/docs/development/plugin-system/README.md @@ -0,0 +1,74 @@ +# Plugin System + +TablePro uses a native-bundle plugin architecture to load database drivers (and eventually other extensions) at runtime. Each plugin is a `.tableplugin` bundle that links the `TableProPluginKit` framework and exposes a principal class conforming to `TableProPlugin`. + +Phase 0 (foundation), Phase 1 (all 8 built-in drivers extracted), and Phase 2 (sideload install/uninstall via Settings > Plugins tab) are all complete. The system is live and shipping. + +Code review improvements applied during Phase 2: data race fix on plugin registry access, async process execution for install/uninstall operations, team-pinned `SecRequirement` signature verification, and proper error propagation throughout the plugin lifecycle. + +## Documents + +| Document | Description | +|----------|-------------| +| [architecture.md](architecture.md) | Three-tier model, extension points, trust levels, directory layout | +| [plugin-kit.md](plugin-kit.md) | TableProPluginKit framework: protocols, transfer types, versioning | +| [plugin-manager.md](plugin-manager.md) | PluginManager singleton: loading, registration, enable/disable, install/uninstall | +| [developer-guide.md](developer-guide.md) | How to build a new plugin from scratch | +| [ui-design.md](ui-design.md) | Settings tab wireframes and implementation | +| [roadmap.md](roadmap.md) | Phased rollout plan (Phase 0-6) | +| [security.md](security.md) | Code signing model, threat model, trust levels, known limitations | +| [troubleshooting.md](troubleshooting.md) | Common errors, signature failures, SourceKit noise, testing tips | +| [migration-guide.md](migration-guide.md) | Versioning policy, compatibility matrix, migration templates | + +## File Map + +``` +Plugins/ + TableProPluginKit/ # Shared framework (linked by all plugins + main app) + TableProPlugin.swift # Base plugin protocol + DriverPlugin.swift # Driver extension protocol + PluginDatabaseDriver.swift # Driver implementation protocol (50+ methods) + PluginCapability.swift # Capability enum + DriverConnectionConfig.swift # Connection config passed to createDriver() + ConnectionField.swift # Custom connection dialog fields + PluginQueryResult.swift # Query result transfer type + PluginColumnInfo.swift # Column metadata + PluginIndexInfo.swift # Index metadata + PluginForeignKeyInfo.swift # Foreign key metadata + PluginTableInfo.swift # Table list entry + PluginTableMetadata.swift # Table stats (size, row count, engine) + PluginDatabaseMetadata.swift # Database stats + ArrayExtension.swift # Safe subscript helper + MongoShellParser.swift # Shared MongoDB shell parsing utilities + + ClickHouseDriverPlugin/ # ClickHouse driver + MongoDBDriverPlugin/ # MongoDB driver + MSSQLDriverPlugin/ # SQL Server driver (FreeTDS) + MySQLDriverPlugin/ # MySQL/MariaDB driver (libmariadb) + OracleDriverPlugin/ # Oracle driver (OCI stub) + PostgreSQLDriverPlugin/ # PostgreSQL/Redshift driver (libpq) + RedisDriverPlugin/ # Redis driver + SQLiteDriverPlugin/ # SQLite driver (Foundation sqlite3) + +TablePro/Core/Plugins/ # Main app infrastructure + PluginManager.swift # Singleton: load, register, enable/disable, install/uninstall + PluginDriverAdapter.swift # Bridges PluginDatabaseDriver -> DatabaseDriver protocol + PluginModels.swift # PluginEntry, PluginSource + PluginError.swift # Error types for plugin operations + +TablePro/Views/Settings/ + PluginsSettingsView.swift # Settings > Plugins tab UI (list, enable/disable, install/uninstall) +``` + +## Current Driver Plugins + +| Plugin | Type ID | Additional IDs | C Library | Port | +|--------|---------|----------------|-----------|------| +| MySQL | `MySQL` | `MariaDB` | libmariadb | 3306 | +| PostgreSQL | `PostgreSQL` | `Redshift` | libpq | 5432 | +| SQLite | `SQLite` | -- | sqlite3 | 0 | +| SQL Server | `SQL Server` | -- | FreeTDS | 1433 | +| ClickHouse | `ClickHouse` | -- | HTTP API | 8123 | +| MongoDB | `MongoDB` | -- | libmongoc | 27017 | +| Redis | `Redis` | -- | hiredis | 6379 | +| Oracle | `Oracle` | -- | OCI stub | 1521 | diff --git a/docs/development/plugin-system/architecture.md b/docs/development/plugin-system/architecture.md new file mode 100644 index 00000000..f659bc40 --- /dev/null +++ b/docs/development/plugin-system/architecture.md @@ -0,0 +1,115 @@ +# Plugin Architecture + +## Three-Tier Plugin Model + +The plugin system defines three tiers of increasing complexity. Only Tier 3 is implemented today. + +| Tier | Type | Runtime | Sandboxing | Status | +|------|------|---------|------------|--------| +| 1 | File-based (themes, snippets) | JSON/plist parsing | Full (data only) | Future | +| 2 | Script-based | JavaScriptCore | JSContext sandbox | Future | +| 3 | Native bundles (.tableplugin) | NSBundle + dynamic linking | Code signature required | **Shipped (Phase 0-2)** | + +**Tier 1** plugins are plain data files: JSON theme definitions, SQL snippet packs, keyboard shortcut maps. The app reads and validates them without executing any code. + +**Tier 2** plugins run JavaScript in a `JSContext` with a controlled API surface. Useful for custom cell formatters, simple transformations, and scripted export logic. + +**Tier 3** plugins are compiled macOS bundles that link `TableProPluginKit`. They have full access to Swift/ObjC runtime and system frameworks. This is the only tier that exists today, used for all 8 database drivers. Phase 2 (sideload install/uninstall via Settings > Plugins tab) is complete. + +## Extension Points + +Nine extension points are identified. Only `databaseDriver` is implemented. + +| Extension Point | Capability Enum | Tier | Status | +|-----------------|----------------|------|--------| +| Database drivers | `.databaseDriver` | 3 | Shipped | +| Export formats | `.exportFormat` | 2-3 | Planned | +| Import formats | `.importFormat` | 2-3 | Planned | +| SQL dialects | `.sqlDialect` | 3 | Planned | +| AI providers | `.aiProvider` | 3 | Planned | +| Cell renderers | `.cellRenderer` | 1-2 | Planned | +| Themes | -- | 1 | Planned | +| Sidebar panels | `.sidebarPanel` | 3 | Planned | +| Snippet packs | -- | 1 | Planned | + +The `PluginCapability` enum currently defines 7 cases. Theme and snippet pack extension points will use Tier 1 file-based loading and do not need capability enum values. + +## Trust Levels + +Plugins are loaded with different trust levels depending on their origin: + +``` +Built-in > Verified > Community > Sideloaded +``` + +| Level | Source | Code Signature | Review | +|-------|--------|---------------|--------| +| Built-in | Shipped in app bundle `Contents/PlugIns/` | App signature covers them | N/A | +| Verified | Future marketplace, signed by TablePro team | Team ID verified | Manual review | +| Community | Future marketplace, signed by developer | Valid signature required | Automated checks | +| Sideloaded | User installs from `.zip` file | Team-pinned signature required | None | + +User-installed plugins must pass `SecStaticCodeCheckValidity` with a team-pinned `SecRequirement` before loading. The requirement string pins to the TablePro team identifier, so only plugins signed by a known team ID are accepted. Built-in plugins are covered by the app's own code signature. + +## Directory Layout + +``` +TablePro.app/ + Contents/ + PlugIns/ # Built-in plugins (read-only) + MySQLDriverPlugin.tableplugin + PostgreSQLDriverPlugin.tableplugin + SQLiteDriverPlugin.tableplugin + ... + Frameworks/ + TableProPluginKit.framework # Shared framework + +~/Library/Application Support/TablePro/ + Plugins/ # User-installed plugins (read-write) + SomeThirdParty.tableplugin +``` + +## Data Flow + +```mermaid +graph LR + A[PluginManager] -->|loads| B[.tableplugin Bundle] + B -->|instantiates| C[NSPrincipalClass] + C -->|conforms to| D[DriverPlugin] + D -->|createDriver| E[PluginDatabaseDriver] + E -->|wrapped by| F[PluginDriverAdapter] + F -->|conforms to| G[DatabaseDriver protocol] + G -->|used by| H[DatabaseManager] +``` + +1. `PluginManager` scans both plugin directories at startup. +2. Each `.tableplugin` bundle is loaded via `Bundle(url:)`. +3. The `NSPrincipalClass` is cast to `TableProPlugin` and instantiated. +4. If the plugin conforms to `DriverPlugin`, it is registered by its `databaseTypeId` (and any `additionalDatabaseTypeIds`). +5. When a connection is opened, `DatabaseManager` looks up the driver plugin by type ID, calls `createDriver(config:)`, and wraps the result in a `PluginDriverAdapter`. +6. `PluginDriverAdapter` conforms to the main app's `DatabaseDriver` protocol, translating between plugin transfer types (`PluginQueryResult`, `PluginColumnInfo`, etc.) and internal app types (`QueryResult`, `ColumnInfo`, etc.). + +`DatabaseDriverFactory` is `@MainActor` isolated, ensuring thread-safe access to the `driverPlugins` dictionary without explicit locking. + +## Bundle Structure + +Each `.tableplugin` bundle follows standard macOS bundle conventions: + +``` +MyDriver.tableplugin/ + Contents/ + Info.plist # Must set NSPrincipalClass, TableProPluginKitVersion + MacOS/ + MyDriver # Compiled binary (Universal Binary) + Frameworks/ # Embedded dependencies (if any) + Resources/ # Assets, localization (optional) +``` + +Required Info.plist keys: + +| Key | Type | Description | +|-----|------|-------------| +| `NSPrincipalClass` | String | Fully qualified class name (e.g., `MySQLPlugin`) | +| `CFBundleIdentifier` | String | Unique bundle ID | +| `TableProPluginKitVersion` | Integer | Protocol version (currently `1`) | +| `TableProMinAppVersion` | String | Minimum app version required (optional) | diff --git a/docs/development/plugin-system/developer-guide.md b/docs/development/plugin-system/developer-guide.md new file mode 100644 index 00000000..0db9017d --- /dev/null +++ b/docs/development/plugin-system/developer-guide.md @@ -0,0 +1,243 @@ +# Developer Guide: Building a Plugin + +This guide walks through creating a new database driver plugin. The SQLite plugin is the simplest reference implementation. + +## Prerequisites + +- Xcode (same version used to build TablePro) +- Access to the `TableProPluginKit` framework +- A code signing identity (required for user-installed plugins) + +## 1. Create the Bundle Target + +In Xcode, add a new target: + +1. File > New > Target > macOS > Bundle +2. Set product name (e.g., `MyDBDriverPlugin`) +3. Set bundle extension to `tableplugin` +4. Link `TableProPluginKit.framework` + +### Info.plist + +Set these keys: + +```xml +NSPrincipalClass +MyDBPlugin + +TableProPluginKitVersion +1 + +CFBundleIdentifier +com.example.mydb-driver +``` + +Optionally set `TableProMinAppVersion` if your plugin uses APIs added in a specific app version. + +## 2. Implement the Plugin Entry Point + +Create the principal class. It must: +- Subclass `NSObject` (required for `NSPrincipalClass` loading) +- Conform to `TableProPlugin` and `DriverPlugin` +- Have a `required init()` (inherited from `NSObject`) + +```swift +import Foundation +import TableProPluginKit + +final class MyDBPlugin: NSObject, TableProPlugin, DriverPlugin { + static let pluginName = "MyDB Driver" + static let pluginVersion = "1.0.0" + static let pluginDescription = "MyDB database support" + static let capabilities: [PluginCapability] = [.databaseDriver] + + static let databaseTypeId = "MyDB" + static let databaseDisplayName = "MyDB" + static let iconName = "cylinder.fill" // SF Symbol name + static let defaultPort = 5555 + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { + MyDBPluginDriver(config: config) + } +} +``` + +### Optional: Additional Connection Fields + +If your database needs extra connection parameters beyond host/port/user/pass/database: + +```swift +static let additionalConnectionFields: [ConnectionField] = [ + ConnectionField( + id: "myOption", + label: "Custom Option", + placeholder: "value", + required: false, + secure: false, + defaultValue: "default" + ) +] +``` + +These values arrive in `config.additionalFields["myOption"]`. + +### Optional: Multi-Type Support + +If one driver handles multiple database types (e.g., MySQL also handles MariaDB): + +```swift +static let additionalDatabaseTypeIds: [String] = ["MyDB-Variant"] +``` + +The plugin is registered under both `"MyDB"` and `"MyDB-Variant"`. + +## 3. Implement PluginDatabaseDriver + +This is the core of the plugin. Create a class conforming to `PluginDatabaseDriver`. + +### Minimum Required Methods + +```swift +final class MyDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { + private let config: DriverConnectionConfig + + init(config: DriverConnectionConfig) { + self.config = config + } + + // -- Connection -- + + func connect() async throws { + // Open connection using config.host, config.port, etc. + } + + func disconnect() { + // Close connection + } + + // -- Queries -- + + func execute(query: String) async throws -> PluginQueryResult { + // Run query, return results + // All cell values must be stringified (String? per cell) + } + + // -- Schema -- + + 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 { ... } + + // -- Databases -- + + func fetchDatabases() async throws -> [String] { ... } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { ... } +} +``` + +### Methods with Default Implementations + +These have working defaults in the protocol extension. Override only if your database needs different behavior: + +| Method | Default Behavior | +|--------|-----------------| +| `ping()` | Runs `SELECT 1` | +| `fetchRowCount(query:)` | Wraps query in `SELECT COUNT(*) FROM (...)` | +| `fetchRows(query:offset:limit:)` | Appends `LIMIT N OFFSET M` | +| `executeParameterized(query:parameters:)` | String-replaces `?` placeholders | +| `beginTransaction()` | Runs `BEGIN` | +| `commitTransaction()` | Runs `COMMIT` | +| `rollbackTransaction()` | Runs `ROLLBACK` | +| `switchDatabase(to:)` | Throws "unsupported" error | +| `cancelQuery()` | No-op | +| `applyQueryTimeout(_:)` | No-op | +| `fetchAllColumns(schema:)` | Iterates `fetchTables` + `fetchColumns` per table | +| `fetchAllForeignKeys(schema:)` | Iterates `fetchTables` + `fetchForeignKeys` per table | +| `fetchAllDatabaseMetadata()` | Iterates `fetchDatabases` + `fetchDatabaseMetadata` per db | + +**`switchDatabase(to:)` note**: The default implementation throws an "unsupported" error. Drivers that support database switching must override it with their own logic. For reference: +- MySQL overrides with backtick-escaped `USE \`name\`` syntax. +- MSSQL overrides using the native FreeTDS API (not a SQL statement). +- ClickHouse has its own override that reconnects with the new database in the URL. + +### Concurrency + +`PluginDatabaseDriver` requires `Sendable` conformance. Common patterns: + +- **Actor isolation**: Use a private actor to wrap the native connection handle (see SQLite plugin's `SQLiteConnectionActor`). +- **`@unchecked Sendable`**: If you manage thread safety manually with locks, mark the class `@unchecked Sendable`. +- **NSLock for interrupt handles**: For `cancelQuery()`, store the connection handle behind an `NSLock` so it can be accessed from any thread. + +## 4. Column Type Names + +Return raw type name strings in `PluginQueryResult.columnTypeNames`. The app maps these to its internal `ColumnType` enum. Recognized names include: + +`BOOL`, `INT`, `INTEGER`, `BIGINT`, `SMALLINT`, `TINYINT`, `FLOAT`, `DOUBLE`, `DECIMAL`, `NUMERIC`, `REAL`, `DATE`, `DATETIME`, `TIMESTAMP`, `TIME`, `JSON`, `JSONB`, `BLOB`, `BYTEA`, `BINARY`, `GEOMETRY`, `POINT`, `LINESTRING`, `POLYGON`, `ENUM`, `SET`. + +Unrecognized type names map to `.text`, which is a safe fallback. + +## 5. Build and Test + +### Build the Plugin + +The plugin target produces a `.tableplugin` bundle. Ensure: + +- It builds as a Universal Binary (arm64 + x86_64) for distribution. +- The `TableProPluginKit` framework is linked (not embedded -- it ships with the app). + +### Testing + +For unit tests, use the inline-copy pattern: copy the plugin's source files into the test target rather than loading the bundle dynamically. This avoids bundle-loading complexity in test runs. + +```swift +// In your test target: +// 1. Add MyDBPluginDriver.swift to the test target's Compile Sources +// 2. Test the driver directly + +func testConnect() async throws { + let config = DriverConnectionConfig( + host: "localhost", + port: 5555, + username: "test", + password: "test", + database: "testdb" + ) + let driver = MyDBPluginDriver(config: config) + try await driver.connect() + // assertions... + driver.disconnect() +} +``` + +Note: `DatabaseDriverFactory.createDriver` now throws (rather than calling `fatalError`) when a plugin is not found. For tests that need a `DatabaseDriver` without loading real plugin bundles, use a `StubDriver` mock that conforms to `DatabaseDriver` directly. This avoids the need to have `.tableplugin` bundles available in the test environment. + +### Manual Testing + +1. Build the plugin target. +2. Copy the `.tableplugin` bundle to `~/Library/Application Support/TablePro/Plugins/`. +3. Launch TablePro. Check the log for `"Loaded plugin 'MyDB Driver'"`. +4. Create a connection using your database type. +5. Alternatively, install via Settings > Plugins: click "Install from File...", select a `.zip` containing your `.tableplugin` bundle, and verify it appears in the plugin list. + +For built-in plugin development, the plugin target is embedded in the app bundle automatically via Xcode's "Embed Without Signing" build phase. + +## Reference: SQLite Plugin Structure + +The SQLite plugin (`Plugins/SQLiteDriverPlugin/`) is the simplest driver and a good starting point: + +``` +SQLiteDriverPlugin/ + SQLitePlugin.swift # Entry point + driver implementation (single file) +``` + +Key patterns to copy: +- `NSObject` subclass for the entry point +- Actor-based connection wrapper for thread safety +- `NSLock` for the interrupt handle +- Raw result struct to pass data out of the actor +- `stripLimitOffset` helper for pagination +- Error enum conforming to `LocalizedError` diff --git a/docs/development/plugin-system/migration-guide.md b/docs/development/plugin-system/migration-guide.md new file mode 100644 index 00000000..b99db1f6 --- /dev/null +++ b/docs/development/plugin-system/migration-guide.md @@ -0,0 +1,164 @@ +# PluginKit Migration Guide + +This guide covers how to handle breaking changes when TableProPluginKit versions bump. If you maintain a third-party plugin, read this whenever you upgrade to a new TablePro release. + +## Versioning Policy + +TableProPluginKit uses a single integer version (`TableProPluginKitVersion`) declared in each plugin's `Info.plist`. This version tracks binary-incompatible changes to protocols and transfer types. + +**Current version: `1`** + +Rules: + +- **Additive changes do not bump the version.** New methods on `PluginDatabaseDriver` with default implementations, new optional fields on transfer types with default init values - these are backwards-compatible. Your plugin keeps working without changes. +- **Breaking changes bump the version.** Removing methods, changing method signatures, renaming protocol requirements, changing transfer type field types or removing fields - these require a version bump. +- **Plugins declaring a version higher than the app supports are rejected at load time.** If your plugin has `TableProPluginKitVersion = 3` but the app only supports up to `2`, it won't load. The app logs: `incompatibleVersion(required: 3, current: 2)`. +- **The version only goes up.** There are no minor versions or patch levels. Each bump means "review the migration steps below." + +## When to Update Your Plugin + +Existing plugins continue to work after a PluginKit version bump until the old version is deprecated and removed (which will be announced at least one major release in advance). + +Here's what to check on each TablePro release: + +1. Read the release notes for any PluginKit version changes. +2. If the version bumped, find the corresponding section below for step-by-step migration. +3. Update `TableProPluginKitVersion` in your `Info.plist` to the new version. +4. Rebuild against the new `TableProPluginKit.framework`. +5. Test with the target TablePro version. + +### Info.plist Keys + +| Key | Type | Description | +|-----|------|-------------| +| `TableProPluginKitVersion` | Integer | Which PluginKit protocol version this plugin targets. Must be <= the app's `PluginManager.currentPluginKitVersion`. | +| `TableProMinAppVersion` | String | Minimum TablePro app version required (e.g., `"0.15.0"`). Optional. If set, the app rejects the plugin when running an older version. | + +## Version 1 (Current - Baseline) + +This is the initial PluginKit release. No migration needed. + +### Protocols + +**`TableProPlugin`** - base protocol for all plugins: + +```swift +public protocol TableProPlugin: AnyObject { + static var pluginName: String { get } + static var pluginVersion: String { get } + static var pluginDescription: String { get } + static var capabilities: [PluginCapability] { get } + init() +} +``` + +**`DriverPlugin`** - entry point for database driver plugins: + +```swift +public protocol DriverPlugin: TableProPlugin { + static var databaseTypeId: String { get } // Required + static var databaseDisplayName: String { get } // Required + static var iconName: String { get } // Required + static var defaultPort: Int { get } // Required + static var additionalConnectionFields: [ConnectionField] { get } // Default: [] + static var additionalDatabaseTypeIds: [String] { get } // Default: [] + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver +} +``` + +**`PluginDatabaseDriver`** - the driver implementation protocol. Required methods (no default): + +- `connect() async throws` +- `disconnect()` +- `execute(query:) async throws -> PluginQueryResult` +- `fetchTables(schema:) async throws -> [PluginTableInfo]` +- `fetchColumns(table:schema:) async throws -> [PluginColumnInfo]` +- `fetchIndexes(table:schema:) async throws -> [PluginIndexInfo]` +- `fetchForeignKeys(table:schema:) async throws -> [PluginForeignKeyInfo]` +- `fetchTableDDL(table:schema:) async throws -> String` +- `fetchViewDefinition(view:schema:) async throws -> String` +- `fetchTableMetadata(table:schema:) async throws -> PluginTableMetadata` +- `fetchDatabases() async throws -> [String]` +- `fetchDatabaseMetadata(_:) async throws -> PluginDatabaseMetadata` + +All other methods have default implementations. See `PluginDatabaseDriver.swift` for the full list and defaults. + +### Transfer Types + +All transfer types are `Codable` and `Sendable`: + +| Type | Fields | +|------|--------| +| `PluginQueryResult` | `columns: [String]`, `columnTypeNames: [String]`, `rows: [[String?]]`, `rowsAffected: Int`, `executionTime: TimeInterval` | +| `PluginColumnInfo` | `name`, `dataType`, `isNullable`, `isPrimaryKey`, `defaultValue?`, `extra?`, `charset?`, `collation?`, `comment?` | +| `PluginTableInfo` | `name`, `type`, `rowCount?` | +| `PluginIndexInfo` | See source | +| `PluginForeignKeyInfo` | See source | +| `PluginTableMetadata` | See source | +| `PluginDatabaseMetadata` | See source | +| `DriverConnectionConfig` | `host`, `port`, `username`, `password`, `database`, `additionalFields: [String: String]` | +| `ConnectionField` | `id`, `label`, `placeholder`, `isRequired`, `isSecure`, `defaultValue?` | +| `PluginCapability` | Enum: `.databaseDriver`, `.exportFormat`, `.importFormat`, `.sqlDialect`, `.aiProvider`, `.cellRenderer`, `.sidebarPanel` | + +## Migration Template - Version N to N+1 + +Future version bumps will add a section here following this format: + +``` +## Version N to Version N+1 + +Released in TablePro vX.Y.Z. + +### Breaking Changes + +- `methodX(old:)` renamed to `methodX(new:)` +- `TransferTypeY.fieldZ` type changed from String to Int +- `removedMethod()` removed (use `replacementMethod()` instead) + +### New Required Methods (no default) + +- `newMethod()` - what it does, how to implement it + +### New Optional Methods (have defaults) + +- `optionalMethod()` - default behavior, when you'd want to override + +### Transfer Type Changes + +- `PluginQueryResult` added field `newField: Type` (default value: X) +- `PluginColumnInfo.oldField` renamed to `newFieldName` + +### Migration Steps + +1. Update `TableProPluginKitVersion` to `N+1` in Info.plist +2. Rename `methodX(old:)` to `methodX(new:)` +3. Update `TransferTypeY.fieldZ` from String to Int +4. Implement `newMethod()` +5. Rebuild and test + +### Before / After + +// Before (version N) +func methodX(old: String) async throws -> Result { ... } + +// After (version N+1) +func methodX(new: String) async throws -> Result { ... } +``` + +## Compatibility Matrix + +| PluginKit Version | Minimum App Version | Status | +|-------------------|---------------------|--------| +| 1 | 0.15.0 | Current | + +This table will be updated with each version bump. + +## Best Practices for Forward Compatibility + +- **Only import TableProPluginKit.** Never import the main `TablePro` app target or reference its internal types. The plugin boundary is the PluginKit framework. +- **Implement all protocol methods explicitly.** Don't rely on default implementations staying the same across versions. If a default changes behavior, your plugin won't notice unless you override it. +- **Keep `TableProMinAppVersion` as low as possible.** This maximizes the range of app versions your plugin works with. Only bump it when you actually need a feature from a newer app version. +- **Don't depend on undocumented behavior.** If a default implementation uses `SELECT COUNT(*) FROM (query) _t` for `fetchRowCount`, don't assume that exact SQL. Implement your own if the default doesn't work for your database. +- **Test against both the minimum and latest app versions.** The minimum ensures backwards compatibility; the latest catches any deprecation warnings. +- **All driver classes must be `Sendable`.** `PluginDatabaseDriver` requires `AnyObject & Sendable`. Use actors or proper synchronization for mutable state. +- **Return `PluginQueryResult.empty` for no-op results.** Don't construct zero-valued results manually when there's a static `.empty` available. diff --git a/docs/development/plugin-system/plugin-kit.md b/docs/development/plugin-system/plugin-kit.md new file mode 100644 index 00000000..9ee43ff8 --- /dev/null +++ b/docs/development/plugin-system/plugin-kit.md @@ -0,0 +1,323 @@ +# TableProPluginKit Framework + +`TableProPluginKit` is a shared framework linked by both the main app and all plugins. It defines the protocol contracts and transfer types that cross the plugin boundary. + +## Protocols + +### TableProPlugin + +Base protocol for all plugins. Every plugin's principal class must conform to this. + +```swift +public protocol TableProPlugin: AnyObject { + static var pluginName: String { get } + static var pluginVersion: String { get } + static var pluginDescription: String { get } + static var capabilities: [PluginCapability] { get } + + init() +} +``` + +All metadata is on the type itself (static properties), not on instances. The `init()` requirement enables `PluginManager` to instantiate plugins without knowing their concrete type. + +### DriverPlugin + +Extends `TableProPlugin` for database driver plugins. + +```swift +public protocol DriverPlugin: TableProPlugin { + static var databaseTypeId: String { get } + static var databaseDisplayName: String { get } + static var iconName: String { get } + static var defaultPort: Int { get } + static var additionalConnectionFields: [ConnectionField] { get } // default: [] + static var additionalDatabaseTypeIds: [String] { get } // default: [] + + func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver +} +``` + +Key design points: + +- **`databaseTypeId`**: Primary lookup key (e.g., `"MySQL"`, `"PostgreSQL"`). Must match the `DatabaseConnection.type` string used throughout the app. +- **`additionalDatabaseTypeIds`**: Allows one plugin to handle multiple database types. MySQL handles `"MariaDB"`, PostgreSQL handles `"Redshift"`. +- **`additionalConnectionFields`**: Extra fields shown in the connection dialog. SQL Server uses this for a schema field. +- **`createDriver(config:)`**: Factory method. Called each time a connection is opened. + +### PluginDatabaseDriver + +The main implementation protocol. This is what plugin authors spend most of their time on. + +```swift +public protocol PluginDatabaseDriver: AnyObject, Sendable { + // Connection lifecycle + func connect() async throws + func disconnect() + func ping() async throws + + // Query execution + func execute(query: String) async throws -> PluginQueryResult + func fetchRowCount(query: String) async throws -> Int + func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult + func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult + + // Schema inspection + 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 + + // Database/schema navigation + func fetchDatabases() async throws -> [String] + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata + func fetchSchemas() async throws -> [String] + func switchSchema(to schema: String) async throws + func switchDatabase(to database: String) async throws + + // Batch operations + func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] + func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] + func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] + func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] + func createDatabase(name: String, charset: String, collation: String?) async throws + + // Transactions + func beginTransaction() async throws + func commitTransaction() async throws + func rollbackTransaction() async throws + + // Execution control + func cancelQuery() throws + func applyQueryTimeout(_ seconds: Int) async throws + + // Properties + var supportsSchemas: Bool { get } + var supportsTransactions: Bool { get } + var currentSchema: String? { get } + var serverVersion: String? { get } +} +``` + +#### Minimum Required Methods + +Most methods have default implementations in a protocol extension. The minimum a driver must implement: + +- `connect()` / `disconnect()` +- `execute(query:)` +- `fetchTables(schema:)` / `fetchColumns(table:schema:)` +- `fetchIndexes(table:schema:)` / `fetchForeignKeys(table:schema:)` +- `fetchTableDDL(table:schema:)` / `fetchViewDefinition(view:schema:)` +- `fetchTableMetadata(table:schema:)` +- `fetchDatabases()` / `fetchDatabaseMetadata(_:)` + +Everything else falls back to sensible defaults (e.g., `ping()` runs `SELECT 1`, `fetchRowCount()` wraps the query in `SELECT COUNT(*)`, `fetchRows()` appends `LIMIT/OFFSET`). + +#### Methods with Default Implementations + +| Method | Default behavior | +|--------|-----------------| +| `ping()` | Runs `SELECT 1` | +| `fetchRowCount(query:)` | Wraps in `SELECT COUNT(*) FROM (...) _t` | +| `fetchRows(query:offset:limit:)` | Appends `LIMIT/OFFSET` to query | +| `executeParameterized(query:parameters:)` | Replaces `?` placeholders with escaped values | +| `fetchSchemas()` | Returns `[]` | +| `switchSchema(to:)` | No-op | +| `switchDatabase(to:)` | Throws "This driver does not support database switching" | +| `createDatabase(name:charset:collation:)` | Throws NSError "createDatabase not supported" | +| `fetchApproximateRowCount(table:schema:)` | Returns `nil` | +| `fetchAllColumns(schema:)` | Iterates `fetchTables` + `fetchColumns` per table | +| `fetchAllForeignKeys(schema:)` | Iterates `fetchTables` + `fetchForeignKeys` per table | +| `fetchAllDatabaseMetadata()` | Iterates `fetchDatabases` + `fetchDatabaseMetadata` per DB | +| `fetchDependentTypes(table:schema:)` | Returns `[]` | +| `fetchDependentSequences(table:schema:)` | Returns `[]` | +| `beginTransaction()` / `commitTransaction()` / `rollbackTransaction()` | Runs `BEGIN` / `COMMIT` / `ROLLBACK` | +| `cancelQuery()` | No-op | +| `applyQueryTimeout(_:)` | No-op | +| `supportsSchemas` | `false` | +| `supportsTransactions` | `true` | +| `currentSchema` | `nil` | +| `serverVersion` | `nil` | + +#### switchDatabase(to:) - Driver Overrides + +The default implementation throws an error. Drivers that support database switching must override this method with database-specific logic: + +| Driver | Override behavior | +|--------|-----------------| +| MySQL | Runs `USE \`escapedName\`` | +| MSSQL | Uses FreeTDS native `dbuse()` API | +| ClickHouse | Has its own override for ClickHouse database switching | +| PostgreSQL | Does not override -- database switching requires a full reconnect | +| Redis | Does not override -- not applicable | +| MongoDB | Does not override -- not applicable | + +This is an optional override. Drivers only need to implement it if the database engine supports switching databases on an existing connection. + +## Transfer Types + +All data crossing the plugin boundary uses plain `Codable, Sendable` structs. No classes, no app-internal types. + +### PluginQueryResult + +```swift +public struct PluginQueryResult: Codable, Sendable { + public let columns: [String] + public let columnTypeNames: [String] + public let rows: [[String?]] + public let rowsAffected: Int + public let executionTime: TimeInterval +} +``` + +All cell values are stringified. The main app maps `columnTypeNames` to its internal `ColumnType` enum via `PluginDriverAdapter.mapColumnType()`. + +### PluginColumnInfo + +```swift +public struct PluginColumnInfo: Codable, Sendable { + public let name: String + public let dataType: String + public let isNullable: Bool + public let isPrimaryKey: Bool + public let defaultValue: String? + public let extra: String? // e.g., "auto_increment" + public let charset: String? + public let collation: String? + public let comment: String? +} +``` + +### PluginIndexInfo + +```swift +public struct PluginIndexInfo: Codable, Sendable { + public let name: String + public let columns: [String] + public let isUnique: Bool + public let isPrimary: Bool + public let type: String // e.g., "BTREE", "HASH" +} +``` + +### PluginForeignKeyInfo + +```swift +public struct PluginForeignKeyInfo: Codable, Sendable { + public let name: String + public let column: String + public let referencedTable: String + public let referencedColumn: String + public let onDelete: String // default: "NO ACTION" + public let onUpdate: String // default: "NO ACTION" +} +``` + +### PluginTableInfo + +```swift +public struct PluginTableInfo: Codable, Sendable { + public let name: String + public let type: String // "TABLE", "VIEW", "SYSTEM TABLE" + public let rowCount: Int? +} +``` + +### PluginTableMetadata + +```swift +public struct PluginTableMetadata: Codable, Sendable { + public let tableName: String + public let dataSize: Int64? + public let indexSize: Int64? + public let totalSize: Int64? + public let rowCount: Int64? + public let comment: String? + public let engine: String? +} +``` + +### PluginDatabaseMetadata + +```swift +public struct PluginDatabaseMetadata: Codable, Sendable { + public let name: String + public let tableCount: Int? + public let sizeBytes: Int64? + public let isSystemDatabase: Bool +} +``` + +## Connection Configuration + +### DriverConnectionConfig + +Passed to `createDriver(config:)`. Contains standard fields plus a dictionary for plugin-specific extras. + +```swift +public struct DriverConnectionConfig: Sendable { + public let host: String + public let port: Int + public let username: String + public let password: String + public let database: String + public let additionalFields: [String: String] +} +``` + +The `additionalFields` dictionary carries values from any `ConnectionField` entries declared by the plugin, plus internal fields like `driverVariant` (used by PostgreSQL to distinguish Redshift connections). + +### ConnectionField + +Declares a custom field in the connection dialog. + +```swift +public struct ConnectionField: Codable, Sendable { + public let id: String // Key in additionalFields dictionary + public let label: String // Display label + public let placeholder: String // Placeholder text + public let isRequired: Bool + public let isSecure: Bool // Renders as secure text field + public let defaultValue: String? +} +``` + +## PluginCapability + +```swift +public enum PluginCapability: Int, Codable, Sendable { + case databaseDriver + case exportFormat + case importFormat + case sqlDialect + case aiProvider + case cellRenderer + case sidebarPanel +} +``` + +A plugin declares its capabilities in `TableProPlugin.capabilities`. The `PluginManager` uses this to route registration. Currently only `.databaseDriver` triggers any registration logic. + +## Multi-Type Support + +A single plugin can handle multiple database types via `additionalDatabaseTypeIds`. The plugin is registered under all declared type IDs: + +| Plugin | Primary ID | Additional IDs | +|--------|-----------|----------------| +| MySQLPlugin | `MySQL` | `MariaDB` | +| PostgreSQLPlugin | `PostgreSQL` | `Redshift` | + +The PostgreSQL plugin uses `config.additionalFields["driverVariant"]` to decide whether to create a standard PostgreSQL driver or a Redshift-specific variant. + +## Versioning + +- **`TableProPluginKitVersion`** (Info.plist integer): Protocol version. Currently `1`. If a plugin declares a version higher than `PluginManager.currentPluginKitVersion`, loading is rejected. +- **`TableProMinAppVersion`** (Info.plist string, optional): Minimum app version. Compared via `.numeric` string comparison. Throws `appVersionTooOld(minimumRequired:currentApp:)` if the running app is older. +- **`pluginVersion`** (static property): Semver string for display purposes. Not enforced by the runtime. + +The protocol version will increment when breaking changes are made to `PluginDatabaseDriver` or transfer types. Additive changes (new methods with default implementations) do not require a version bump. diff --git a/docs/development/plugin-system/plugin-manager.md b/docs/development/plugin-system/plugin-manager.md new file mode 100644 index 00000000..347c9f8e --- /dev/null +++ b/docs/development/plugin-system/plugin-manager.md @@ -0,0 +1,233 @@ +# PluginManager + +`PluginManager` is a `@MainActor @Observable` singleton that handles plugin discovery, loading, registration, and lifecycle management. + +**File**: `TablePro/Core/Plugins/PluginManager.swift` + +## Loading Flow + +Called once at app startup via `PluginManager.shared.loadAllPlugins()`. + +```mermaid +graph TD + A[loadAllPlugins] --> B[Ensure ~/Library/.../Plugins/ exists] + B --> C[Scan Contents/PlugIns/ - built-in] + B --> D[Scan ~/Library/.../Plugins/ - user-installed] + C --> E[For each .tableplugin] + D --> E + E --> F{Bundle valid?} + F -->|No| G[Log error, skip] + F -->|Yes| H{PluginKit version OK?} + H -->|Too new| G + H -->|OK| I{Min app version OK?} + I -->|Too old| G + I -->|OK| J{User-installed?} + J -->|Yes| K{Code signature valid?} + K -->|No| G + K -->|Yes| L[Load bundle] + J -->|No| L + L --> M[Cast NSPrincipalClass to TableProPlugin] + M --> N[Create PluginEntry] + N --> O{Plugin enabled?} + O -->|Yes| P[Instantiate + register capabilities] + O -->|No| Q[Store entry only] +``` + +### Step-by-step + +1. **Directory setup**: Creates `~/Library/Application Support/TablePro/Plugins/` if missing. +2. **Scan**: Lists all `.tableplugin` items in both the built-in and user directories. +3. **Bundle creation**: `Bundle(url:)` -- fails if the bundle structure is invalid. +4. **Version check**: Reads `TableProPluginKitVersion` from Info.plist. Rejects if higher than `PluginManager.currentPluginKitVersion` (currently `1`). Throws `incompatibleVersion(required:current:)`. +5. **App version check**: If `TableProMinAppVersion` is set, compares against the running app version using `.numeric` string comparison. Throws `appVersionTooOld(minimumRequired:currentApp:)` with the actual version strings if the app is too old. +6. **Code signature** (user-installed only): Calls `verifyCodeSignature(bundle:)` which throws `PluginError.signatureInvalid(detail:)` on failure. See [Code Signature Verification](#code-signature-verification). +7. **Load executable**: `bundle.load()` -- loads the Mach-O binary into the process. +8. **Principal class**: Casts `bundle.principalClass` to `TableProPlugin.Type`. +9. **Entry creation**: Stores a `PluginEntry` with metadata (name, version, source, capabilities, enabled state). +10. **Registration**: If enabled, instantiates the class and calls `registerCapabilities()`. + +The `loadPlugin(at:source:)` method is `@discardableResult` and returns the `PluginEntry` directly. + +## Registration + +### Driver Plugins + +When a `TableProPlugin` instance also conforms to `DriverPlugin`: + +```swift +let typeId = type(of: driver).databaseTypeId +driverPlugins[typeId] = driver + +for additionalId in type(of: driver).additionalDatabaseTypeIds { + driverPlugins[additionalId] = driver +} +``` + +The `driverPlugins` dictionary maps type ID strings to `DriverPlugin` instances. It is a regular `@MainActor`-isolated property on `PluginManager`. `DatabaseDriverFactory` is also `@MainActor`, ensuring safe access to `driverPlugins` without data races. + +### Unregistration + +When a plugin is disabled, `unregisterCapabilities(pluginId:)` removes **all** entries from `driverPlugins` that were registered by that plugin. This includes both the primary type ID and any additional type IDs: + +```swift +private func unregisterCapabilities(pluginId: String) { + driverPlugins = driverPlugins.filter { _, value in + guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true } + if let principalClass = entry.bundle.principalClass as? any DriverPlugin.Type { + let allTypeIds = Set([principalClass.databaseTypeId] + principalClass.additionalDatabaseTypeIds) + return !allTypeIds.contains(type(of: value).databaseTypeId) + } + return true + } +} +``` + +The `Set` ensures both the primary ID and all `additionalDatabaseTypeIds` are removed in one pass. For example, disabling the MySQL plugin removes both `"MySQL"` and `"MariaDB"` entries. + +## Enable / Disable + +```swift +func setEnabled(_ enabled: Bool, pluginId: String) +``` + +- Updates the `PluginEntry.isEnabled` flag. +- Persists the disabled set to `UserDefaults` under the key `"disabledPlugins"`. +- If enabling: instantiates the plugin class and registers capabilities. +- If disabling: unregisters capabilities (removes from `driverPlugins`). + +Disabled plugins remain in the `plugins` array and their bundles stay loaded in memory. They just have no registered capabilities. + +## Install / Uninstall + +### installPlugin(from:) + +```swift +func installPlugin(from zipURL: URL) async throws -> PluginEntry +``` + +1. Extracts the `.zip` archive to a temp directory using `/usr/bin/ditto`. +2. The `ditto` process runs asynchronously via `withCheckedThrowingContinuation` -- it does not block the calling thread with `waitUntilExit`. +3. Finds the first `.tableplugin` bundle in the extracted contents. +4. Verifies code signature on the extracted bundle. +5. Checks for conflicts with built-in plugins. If a built-in plugin already has the same bundle ID, throws `pluginConflict(existingName:)`. +6. Copies to `~/Library/Application Support/TablePro/Plugins/`. +7. Loads the plugin via `loadPlugin(at:source:)` and returns the entry directly from that call. + +If a plugin with the same filename already exists in the user plugins directory, it is replaced. + +### uninstallPlugin(id:) + +```swift +func uninstallPlugin(id: String) throws +``` + +1. Finds the plugin entry by ID. +2. Rejects if the plugin is built-in (`PluginError.cannotUninstallBuiltIn`). +3. Unregisters capabilities. +4. Calls `bundle.unload()`. +5. Removes the entry from the `plugins` array. +6. Deletes the `.tableplugin` directory from disk. +7. Removes from the disabled plugins set. + +## Code Signature Verification + +User-installed plugins must pass macOS code signature validation pinned to the app's team ID: + +```swift +private func verifyCodeSignature(bundle: Bundle) throws +``` + +Uses Security.framework: + +1. `SecStaticCodeCreateWithPath` to get a `SecStaticCode` reference. Throws `signatureInvalid(detail:)` if this fails. +2. `createSigningRequirement()` builds a `SecRequirement` pinned to the team ID: `anchor apple generic and certificate leaf[subject.OU] = "YOURTEAMID"`. +3. `SecStaticCodeCheckValidity` with `kSecCSCheckAllArchitectures` and the team ID requirement. Throws `signatureInvalid(detail:)` if validation fails. + +The `signingTeamId` static property holds the team identifier (currently a placeholder `"YOURTEAMID"` -- must be replaced before shipping user-installed plugin support). + +Error details come from `describeOSStatus(_:)`, which maps common Security.framework codes to readable strings: + +| OSStatus | Description | +|----------|-------------| +| -67062 | bundle is not signed | +| -67061 | code signature is invalid | +| -67030 | code signature has been modified or corrupted | +| -67013 | signing certificate has expired | +| -67058 | code signature is missing required fields | +| -67028 | resource envelope has been modified | +| other | verification failed (OSStatus N) | + +Built-in plugins skip this check because they are covered by the app's own code signature. + +## Data Model + +### PluginEntry + +```swift +struct PluginEntry: Identifiable { + let id: String // Bundle identifier or filename + let bundle: Bundle + let url: URL + let source: PluginSource // .builtIn or .userInstalled + let name: String + let version: String + let pluginDescription: String + let capabilities: [PluginCapability] + var isEnabled: Bool +} +``` + +### PluginSource + +```swift +enum PluginSource { + case builtIn + case userInstalled +} +``` + +### PluginError + +| Case | When | +|------|------| +| `invalidBundle(String)` | Bundle cannot be created or loaded | +| `signatureInvalid(detail: String)` | Code signature check failed, with human-readable OSStatus description | +| `checksumMismatch` | Future: content hash verification | +| `incompatibleVersion(required:current:)` | PluginKit version too new for the running app | +| `appVersionTooOld(minimumRequired:currentApp:)` | App version is older than the plugin's `TableProMinAppVersion` | +| `pluginConflict(existingName: String)` | User-installed plugin has the same bundle ID as a built-in plugin | +| `cannotUninstallBuiltIn` | Tried to uninstall a built-in plugin | +| `notFound` | Plugin ID not in registry | +| `noCompatibleBinary` | Future: universal binary check | +| `installFailed(String)` | Archive extraction or copy failed | + +## PluginDriverAdapter + +**File**: `TablePro/Core/Plugins/PluginDriverAdapter.swift` + +Bridges `PluginDatabaseDriver` (plugin side) to `DatabaseDriver` (app side). This is where transfer types are mapped: + +- `PluginQueryResult` -> `QueryResult` (includes column type mapping from raw type name strings to `ColumnType` enum) +- `PluginColumnInfo` -> `ColumnInfo` +- `PluginIndexInfo` -> `IndexInfo` +- `PluginForeignKeyInfo` -> `ForeignKeyInfo` +- `PluginTableInfo` -> `TableInfo` +- `PluginTableMetadata` -> `TableMetadata` +- `PluginDatabaseMetadata` -> `DatabaseMetadata` + +The adapter also conforms to `SchemaSwitchable` and delegates `switchSchema(to:)` / `switchDatabase(to:)` to the plugin driver. + +### Column Type Mapping + +`PluginDriverAdapter.mapColumnType(rawTypeName:)` converts raw type name strings from plugins into the app's `ColumnType` enum. The mapping handles common SQL type names: + +- `BOOL*` -> `.boolean` +- `INT`, `INTEGER`, `BIGINT`, etc. -> `.integer` +- `FLOAT`, `DOUBLE`, `DECIMAL`, etc. -> `.decimal` +- `DATE` -> `.date` +- `TIMESTAMP*` -> `.timestamp` +- `JSON`, `JSONB` -> `.json` +- `BLOB`, `BYTEA`, `BINARY` -> `.blob` +- `ENUM*` -> `.enumType` +- `GEOMETRY`, `POINT`, etc. -> `.spatial` +- Everything else -> `.text` diff --git a/docs/development/plugin-system/roadmap.md b/docs/development/plugin-system/roadmap.md new file mode 100644 index 00000000..86b4d5fa --- /dev/null +++ b/docs/development/plugin-system/roadmap.md @@ -0,0 +1,143 @@ +# Plugin System Roadmap + +## Phase 0: Foundation - COMPLETE + +Laid the groundwork for the plugin system. + +**Delivered:** +- `TableProPluginKit` shared framework with all protocols and transfer types +- `PluginManager` singleton with bundle loading, version checking, code signature verification +- `PluginDriverAdapter` bridging plugin drivers to the app's `DatabaseDriver` protocol +- `PluginEntry`, `PluginSource`, `PluginError` data model +- Oracle driver as proof-of-concept plugin (OCI stub) + +## Phase 1: Built-in Plugins - COMPLETE + +Extracted all database drivers from the main app into plugin bundles. + +**Delivered:** +- 8 driver plugins: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, MongoDB, Redis, Oracle +- Multi-type support: MySQL handles MariaDB, PostgreSQL handles Redshift +- Custom connection fields (SQL Server schema field) +- `DatabaseManager` factory simplified to plugin lookup +- Removed all direct driver imports from main app target +- C bridge headers moved into respective plugin bundles (CMariaDB, CLibPQ, CFreeTDS, CLibMongoc, CRedis, COracle) + +## Phase 2: Sideload - COMPLETE + +Users can install third-party plugins from `.zip` files via Settings. + +**Delivered:** +- Settings > Plugins tab (`PluginsSettingsView.swift`) with installed plugin list, enable/disable toggles, detail view +- "Install from File..." flow: `NSOpenPanel` -> zip extraction via `ditto` (async, non-blocking) -> code signature verification -> bundle loading +- Uninstall for user-installed plugins with confirmation dialog +- Team-pinned code signature verification using `SecRequirement` (not just any valid cert) +- Detailed error reporting: `signatureInvalid(detail:)`, `pluginConflict`, `appVersionTooOld` +- SwiftUI `.alert`-based error presentation (replaced unreliable `NSApp.keyWindow`) +- `DatabaseDriverFactory.createDriver` now throws instead of `fatalError` -- graceful error when plugin missing +- Thread-safe `driverPlugins` access (removed `nonisolated(unsafe)`, made factory `@MainActor`) +- Plugin tests: `PluginModelsTests` (7 tests), `ExportServiceStateTests` rewritten with `StubDriver` mock + +## Phase 3: Marketplace - COMPLETE + +GitHub-based plugin registry with in-app discovery. + +**Delivered:** +- RegistryClient fetches flat JSON manifest from `datlechin/tablepro-plugins` GitHub repo +- ETag/If-None-Match caching with UserDefaults offline fallback +- Browse tab in Settings > Plugins with search bar and category filter chips +- One-click install from registry: streaming download with progress, SHA-256 checksum verification, delegates to existing installPlugin(from:) +- PluginInstallTracker for per-plugin download/install state (downloading, installing, completed, failed) +- RegistryPluginRow with verified badge, author info, and contextual Install/Installed/Retry button +- RegistryPluginDetailView with expandable summary, category, compatibility info, and homepage link +- New error types: downloadFailed, incompatibleWithCurrentApp + +## Phase 4: Auto-Updates + +Version checking and update notifications for installed plugins. + +**Scope:** +- Periodic version check against the registry +- Badge on Settings > Plugins when updates are available +- One-click update (download + replace bundle) +- Update tab showing changelog diff +- Opt-out per plugin + +**Dependencies:** Phase 3 (requires registry with version metadata). + +## Phase 5: Export Plugins - COMPLETE + +Extracted all 5 built-in export formats into plugin bundles. + +**Delivered:** +- `ExportFormatPlugin` protocol in TableProPluginKit with `formatId`, `export()`, `optionsView()`, `perTableOptionColumns` +- `PluginExportDataSource` protocol bridging `DatabaseDriver` for plugin data access +- `PluginExportProgress` thread-safe progress reporter with cancellation and UI throttling +- `PluginExportUtilities` shared helpers (JSON escaping, SQL comment sanitization, UTF-8 encoding) +- 5 export plugin bundles: CSVExport, JSONExport, SQLExport, XLSXExport, MQLExport +- Each plugin provides its own SwiftUI options view via `optionsView() -> AnyView?` +- Generic per-table option columns (SQL: Structure/Drop/Data, MQL: Drop/Indexes/Data) +- Dynamic format picker in Export dialog, filtered by database type compatibility +- `ExportDataSourceAdapter` bridges `DatabaseDriver` to `PluginExportDataSource` +- `ExportService` simplified to thin orchestrator delegating to plugins +- Removed 11 format-specific files from main app (5 ExportService extensions, XLSXWriter, 5 options views) + +### Theme Plugins (Future) + +**Scope:** +- JSON-based theme definitions (no executable code) +- Colors for editor, data grid, sidebar, toolbar +- Font overrides +- Stored in `~/Library/Application Support/TablePro/Themes/` +- Theme picker in Settings > Editor +- Shareable as single `.json` files + +## Phase 6: Cell Renderers + Developer Portal + +### Cell Renderers (Tier 1-2) + +**Scope:** +- Custom rendering for specific column types (e.g., image preview, map view, color swatch) +- Tier 1: JSON config mapping column type patterns to built-in renderers +- Tier 2: JavaScript-based custom renderers in a `JSContext` + +### Developer Portal + +**Scope:** +- Documentation site for plugin developers +- Plugin submission and review workflow +- Code signing certificate distribution +- SDK download with Xcode project templates +- Example plugins repository + +## Known Limitations / Tech Debt + +Issues identified during Phase 2 implementation that should be addressed in future phases: + +1. **Team ID placeholder**: `PluginManager.signingTeamId` is set to `"YOURTEAMID"` -- must be replaced with the actual Apple Developer Team ID before shipping sideloaded plugin support to users. + +2. **`Bundle.unload()` unreliability**: macOS `Bundle.unload()` is not guaranteed to actually unload code. Disabled/uninstalled plugins may leave code in memory until app restart. + +3. **No hot-reload**: Enabling a previously disabled plugin re-instantiates the class but doesn't reconnect existing sessions using that driver. + +4. **`executeParameterized` default is SQL injection-adjacent**: The default implementation does string replacement of `?` placeholders, which relies on single-quote escaping. Drivers should override with native prepared statements. + +5. **`PluginDriverAdapter.beginTransaction` uses hardcoded SQL**: Sends `BEGIN` regardless of database type. Drivers that need different transaction syntax (e.g., Oracle's implicit transactions) must handle this at the plugin level. + +6. **No plugin dependency resolution**: Plugins cannot declare dependencies on other plugins. Each plugin must be self-contained. + +7. **Single-zip install format**: Only `.zip` archives supported. No support for direct `.tableplugin` bundle drag-and-drop. + +## Timeline + +| Phase | Status | Dependencies | +|-------|--------|-------------| +| 0: Foundation | Done | -- | +| 1: Built-in Plugins | Done | Phase 0 | +| 2: Sideload | **Done** | Phase 1 | +| 3: Marketplace | **Done** | Phase 2 | +| 4: Auto-Updates | Next | Phase 3 | +| 5: Export Plugins | **Done** | Phase 2 | +| 6: Renderers + Portal | Planned | Phase 3, 5 | + +Phases 5 and 3 can proceed in parallel now that Phase 2 is complete. Phase 5 (themes specifically) has no dependency on Phase 3 since themes are local files. diff --git a/docs/development/plugin-system/security.md b/docs/development/plugin-system/security.md new file mode 100644 index 00000000..32dfc0fa --- /dev/null +++ b/docs/development/plugin-system/security.md @@ -0,0 +1,154 @@ +# Plugin Security Model + +TablePro plugins are native macOS bundles (`.tableplugin`) loaded into the app process at runtime. This means plugins run with the same privileges as the app itself. This document describes the security mechanisms in place, what they protect against, and what they don't. + +## Code Signing + +### Built-in plugins + +Plugins shipped inside the app bundle (in `Contents/PlugIns/`) are covered by the app's own code signature. macOS validates the app signature at launch, which transitively covers everything inside the bundle. No separate signature check is performed by `PluginManager`. + +### User-installed plugins + +Plugins installed to `~/Library/Application Support/TablePro/Plugins/` go through explicit code signature verification before loading. The flow: + +1. `SecStaticCodeCreateWithPath` creates a `SecStaticCode` reference from the plugin bundle URL. +2. `createSigningRequirement()` builds a `SecRequirement` string: + ``` + anchor apple generic and certificate leaf[subject.OU] = "TEAMID" + ``` +3. `SecStaticCodeCheckValidity` validates the bundle against this requirement, using the `kSecCSCheckAllArchitectures` flag to verify all architecture slices (arm64 + x86_64). + +If verification fails, the plugin is rejected with a `PluginError.signatureInvalid` error. The user never gets the option to override this. + +### Team ID pinning + +The `signingTeamId` static property on `PluginManager` determines which Apple Developer Team ID is accepted. The requirement string pins to the leaf certificate's Organizational Unit (`subject.OU`), meaning only plugins signed by that specific team are accepted. + +**Current status**: `signingTeamId` is set to the placeholder `"YOURTEAMID"`. This must be replaced with a real team identifier before user-installed plugin support ships. + +### OSStatus error codes + +When signature verification fails, `describeOSStatus()` maps Security framework codes to human-readable messages: + +| OSStatus | Meaning | +|----------|---------| +| -67062 | Bundle is not signed | +| -67061 | Code signature is invalid | +| -67030 | Code signature has been modified or corrupted | +| -67013 | Signing certificate has expired | +| -67058 | Code signature is missing required fields | +| -67028 | Resource envelope has been modified | + +Any other status code falls through to a generic "verification failed (OSStatus N)" message. + +## Trust Levels + +Plugins fall into four trust tiers based on how they were distributed: + +| Level | Source | Signature check | What it means | +|-------|--------|----------------|---------------| +| **Built-in** | Shipped inside app bundle | App signature covers it | First-party code, maintained by the TablePro team | +| **Verified** | Downloaded from official marketplace | Team ID pinned signature check | Third-party code reviewed and signed by the TablePro team | +| **Community** | Downloaded from marketplace, signed by author | Author's Developer ID check | Third-party code signed by its developer, not reviewed by TablePro | +| **Sideloaded** | Manually placed in plugins directory | Team ID pinned signature check | Must still pass signature verification to load | + +Built-in plugins cannot be uninstalled (`PluginError.cannotUninstallBuiltIn`). User-installed plugins can be enabled, disabled, or removed at any time. + +## Threat Model + +### What a malicious plugin CAN do + +Plugins are native Mach-O bundles loaded via `NSBundle.load()` into the app's address space. Once loaded, a plugin has: + +- **Full process access**: arbitrary Swift/ObjC code execution in the same process. A plugin can call any framework, swizzle methods, read process memory. +- **File system access**: read and write any file the app can access. +- **Network access**: open arbitrary network connections, send data anywhere. +- **Keychain access**: read Keychain items available to the app (connection passwords are stored in Keychain via `ConnectionStorage`). +- **Connection credentials**: plugins receive `DriverConnectionConfig` with plaintext host, port, username, password, and database name. + +### What mitigations exist today + +- **Code signature verification**: user-installed plugins must be signed with a specific Apple Developer ID. An unsigned or tampered bundle is rejected before `NSBundle.load()` is called. +- **Team ID pinning**: only plugins signed by the pinned team ID are accepted. A valid Apple Developer ID from a different team is rejected. +- **All-architectures check**: `kSecCSCheckAllArchitectures` prevents attacks that target only one architecture slice. +- **Conflict detection**: a user-installed plugin cannot shadow a built-in plugin's bundle ID (`PluginError.pluginConflict`). +- **User must explicitly install**: plugins don't auto-download. The user initiates installation from a `.zip` archive. +- **Version gating**: `TableProPluginKitVersion` and `TableProMinAppVersion` in Info.plist prevent loading plugins built against incompatible SDK versions. + +### What mitigations are planned + +- **Marketplace review process**: reviewing plugin code and behavior before listing in an official marketplace. +- **Runtime sandboxing**: restricting plugin capabilities using macOS sandbox profiles or XPC (future work). +- **Capability declarations**: enforcing that a plugin declaring only `.databaseDriver` cannot access export or AI APIs. + +### What the system does NOT protect against + +- A validly-signed plugin from a **compromised developer account**. If an attacker obtains the signing key, they can produce plugins that pass verification. +- **Supply chain attacks** on plugin dependencies. A plugin linking against a compromised C library will pass signature checks if the final bundle is signed. +- **Runtime misbehavior** by a signed plugin. Once loaded, there is no monitoring of what the plugin code actually does. +- A plugin that **exfiltrates connection credentials** it legitimately receives via `DriverConnectionConfig`. + +## Plugin Capabilities and Access + +The `PluginCapability` enum declares what a plugin intends to provide: + +```swift +public enum PluginCapability: Int, Codable, Sendable { + case databaseDriver + case exportFormat + case importFormat + case sqlDialect + case aiProvider + case cellRenderer + case sidebarPanel +} +``` + +These are currently **declarations only**, not enforced restrictions. A plugin declaring `.databaseDriver` has the same runtime access as one declaring `.aiProvider`. There is no sandbox boundary between capability types. + +Database driver plugins receive connection credentials via `DriverConnectionConfig`: + +```swift +public struct DriverConnectionConfig: Sendable { + public let host: String + public let port: Int + public let username: String + public let password: String + public let database: String + public let additionalFields: [String: String] +} +``` + +The password is passed in plaintext. This is necessary for the plugin to establish a database connection, but it means any loaded plugin has access to the credentials. + +## Bundle Integrity + +Several checks run before a plugin's executable code is loaded: + +1. **NSBundle creation**: `Bundle(url:)` validates basic bundle structure (correct directory layout, Info.plist present). +2. **PluginKit version check**: `TableProPluginKitVersion` in Info.plist must be less than or equal to `PluginManager.currentPluginKitVersion`. A plugin built against a newer SDK is rejected with `PluginError.incompatibleVersion`. +3. **App version check**: if `TableProMinAppVersion` is set in Info.plist, the current app version is compared. If the app is older, loading fails with `PluginError.appVersionTooOld`. +4. **Code signature verification** (user-installed only): as described above. +5. **Principal class check**: `bundle.principalClass` must conform to `TableProPlugin`. If not, the plugin is rejected with `PluginError.invalidBundle`. +6. **Architecture verification**: `kSecCSCheckAllArchitectures` ensures all Mach-O slices in a Universal Binary are validly signed. + +During installation (via `installPlugin(from:)`), the signature is verified on the extracted bundle *before* copying it to the user plugins directory. A plugin that fails verification is never persisted. + +## Recommendations for Plugin Developers + +- **Always code-sign** with a valid Apple Developer ID certificate. Unsigned plugins will be rejected. +- **Build as Universal Binary** (arm64 + x86_64) to work on both Apple Silicon and Intel Macs. The `kSecCSCheckAllArchitectures` flag validates all slices. +- **Don't store secrets in the plugin bundle**. Anything inside the `.tableplugin` directory is readable by anyone with file access. +- **Use HTTPS for all network connections**. Database protocols that don't support TLS should document this clearly. +- **Minimize dependencies** to reduce attack surface. Every linked library is a potential vulnerability. +- **Set `TableProPluginKitVersion` and `TableProMinAppVersion`** in your Info.plist to prevent your plugin from loading in incompatible environments. +- **Use a unique bundle identifier**. Your plugin cannot share a bundle ID with a built-in plugin. + +## Known Limitations + +- **`signingTeamId` is a placeholder**: set to `"YOURTEAMID"`. Must be replaced with the actual Apple Developer Team ID before sideloaded plugin support ships. Until then, no user-installed plugins will pass verification. +- **`Bundle.unload()` is unreliable on macOS**: when a user-installed plugin is uninstalled, `PluginManager` calls `bundle.unload()`, but Apple's documentation notes this may not actually unload the code. The plugin's executable code may remain mapped in memory until the app restarts. +- **No runtime sandboxing**: plugins run in the same process with the same entitlements as the app. There is no XPC boundary, no sandbox profile, no capability enforcement. +- **No capability restrictions**: the `PluginCapability` enum is advisory. A plugin declaring only `.databaseDriver` has the same runtime access as the host app. +- **Credentials in plaintext**: `DriverConnectionConfig` passes database passwords as plain `String` values. There is no way for the app to restrict which plugins see which credentials. diff --git a/docs/development/plugin-system/troubleshooting.md b/docs/development/plugin-system/troubleshooting.md new file mode 100644 index 00000000..8f3b54ce --- /dev/null +++ b/docs/development/plugin-system/troubleshooting.md @@ -0,0 +1,235 @@ +# Plugin Troubleshooting Guide + +Common issues when developing and installing TablePro plugins, with solutions derived from the actual error paths in `PluginManager` and `PluginError`. + +--- + +## Bundle Won't Load + +### "Cannot create bundle from X" + +`Bundle(url:)` returned `nil`. The `.tableplugin` directory structure is wrong. + +**Fix:** +- Verify your bundle has the correct layout: + ``` + MyPlugin.tableplugin/ + Contents/ + Info.plist + MacOS/ + MyPlugin ← compiled binary + ``` +- Check that `Info.plist` contains `NSPrincipalClass` pointing to your plugin class. +- The bundle must have a `.tableplugin` extension. `PluginManager` skips anything else. + +### "Bundle failed to load executable" + +`bundle.load()` returned `false`. The binary exists but macOS refused to load it. + +**Fix:** +- **Architecture mismatch.** If you built arm64-only and the user runs on Intel (or vice versa), the load fails silently. Build as Universal Binary: `lipo -create arm64/MyPlugin x86_64/MyPlugin -output MyPlugin`. +- **Missing linked frameworks.** Your plugin must link against `TableProPluginKit.framework`, not embed it. If the framework isn't found at load time, `dlopen` fails. +- **Check Console.app** for `dyld` errors. Filter by your plugin name. Common causes: missing `@rpath`, unresolved symbols, wrong install name. +- Run `file MyPlugin.tableplugin/Contents/MacOS/MyPlugin` to confirm the binary contains both architectures. + +### "Principal class does not conform to TableProPlugin" + +`bundle.principalClass` loaded, but the cast to `TableProPlugin.Type` failed. + +**Fix:** +- Your principal class must subclass `NSObject` AND conform to `TableProPlugin`. +- The `NSPrincipalClass` value in `Info.plist` must match the class name exactly. For Swift classes, use the unqualified name (no module prefix) if the class is `@objc`-compatible. +- If your class is pure Swift without `@objc`, you need the module-qualified name: `MyPluginModule.MyPluginClass`. + +--- + +## Signature Verification Fails + +Signature checks only apply to user-installed plugins (under `~/Library/Application Support/TablePro/Plugins/`). Built-in plugins bundled with the app skip this check. + +### "bundle is not signed" (OSStatus -67062) + +The plugin has no code signature at all. + +**Fix:** +- Sign with a valid Apple Developer ID: `codesign --sign "Developer ID Application: Your Name (TEAMID)" --deep MyPlugin.tableplugin` +- Ad-hoc signatures (`codesign -s -`) are not accepted for user-installed plugins. + +### "code signature is invalid" (OSStatus -67061) + +A signature exists but doesn't validate. + +**Fix:** +- Re-sign the bundle. This often happens when the binary was modified after signing (e.g., `install_name_tool` or `strip` ran post-signing). +- Verify with: `codesign -v --deep --strict MyPlugin.tableplugin` + +### "code signature has been modified or corrupted" (OSStatus -67030) + +Files in the bundle changed after signing. + +**Fix:** +- Do all modifications (adding resources, fixing rpaths) *before* signing. +- Re-sign the entire bundle after any change. + +### "signing certificate has expired" (OSStatus -67013) + +**Fix:** +- Renew your Apple Developer certificate at developer.apple.com, download the new cert, and re-sign. + +### "resource envelope has been modified" (OSStatus -67028) + +A file in `Contents/Resources/` was added, removed, or changed after signing. + +**Fix:** +- Finalize all resources before signing. Re-sign if you need to change anything. + +### "code signature is missing required fields" (OSStatus -67058) + +The signature exists but is incomplete. + +**Fix:** +- Re-sign with `--deep` to ensure all nested code is signed. +- Check that your signing identity is a full Developer ID, not a self-signed cert. + +### Team ID mismatch + +The signature is valid, but the Team Identifier doesn't match what `PluginManager` expects. The app checks `certificate leaf[subject.OU]` against a configured team ID. + +**Fix:** +- Check your plugin's team ID: `codesign -dvvv MyPlugin.tableplugin 2>&1 | grep TeamIdentifier` +- The team ID must match `PluginManager.signingTeamId`. Contact the TablePro team if you need your team ID allowlisted. + +--- + +## Version Compatibility + +### "Plugin requires PluginKit version X, but app provides version Y" + +Your plugin's `TableProPluginKitVersion` in `Info.plist` is higher than `PluginManager.currentPluginKitVersion` (currently `1`). + +**Fix:** +- Rebuild your plugin against the version of `TableProPluginKit` that ships with the target app version. +- If you set `TableProPluginKitVersion` manually, lower it to match. But only do this if your plugin genuinely doesn't use newer API. + +### "Plugin requires app version X or later, but current version is Y" + +Your `TableProMinAppVersion` in `Info.plist` is newer than the running app. + +**Fix:** +- Update the app to the required version, or lower `TableProMinAppVersion` in your plugin's `Info.plist` if the dependency isn't real. +- Version comparison uses `.numeric` ordering, so `1.10.0` > `1.9.0`. + +--- + +## Plugin Conflicts + +### "A built-in plugin 'X' already provides this bundle ID" + +Your plugin's `CFBundleIdentifier` collides with a built-in plugin. The app blocks user-installed plugins from overriding built-in ones. + +**Fix:** +- Change your `CFBundleIdentifier` to something unique (e.g., `com.yourcompany.tablepro.myplugin`). +- You cannot replace built-in drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) with user-installed plugins. + +--- + +## Installation Issues + +### "No .tableplugin bundle found in archive" + +The ZIP file doesn't contain a `.tableplugin` bundle at the top level. + +**Fix:** +- Package your plugin so the ZIP extracts to `MyPlugin.tableplugin/` directly, not nested inside another folder. +- The installer uses `ditto -xk` and looks for the first item with `.tableplugin` extension in the extracted directory. + +### "Failed to extract archive (ditto exit code N)" + +The ZIP file is corrupted or not a valid ZIP. + +**Fix:** +- Re-create the archive. Use `ditto -ck --keepParent MyPlugin.tableplugin MyPlugin.zip` for best compatibility. + +--- + +## Runtime Issues + +### Plugin loads but database type doesn't appear + +The plugin loaded successfully (check Console.app for "Loaded plugin" log), but the database type isn't available in the connection dialog. + +**Fix:** +- Verify `databaseTypeId` on your `DriverPlugin` matches a `DatabaseType` case that the app knows about. For custom database types, the app must explicitly support the type in its enum. +- Check that your plugin declares `.databaseDriver` in its `capabilities` array. +- If your plugin serves multiple database types, implement `additionalDatabaseTypeIds` on your `DriverPlugin` conformance. +- Make sure the plugin isn't disabled. Check `UserDefaults` key `disabledPlugins` or the Plugins preference pane. + +### Connection fails immediately after plugin loads + +`createDriver(config:)` returns a driver, but `connect()` throws. + +**Fix:** +- Check Console.app filtered to the "PluginDriverAdapter" category. The adapter logs connection errors. +- Verify your `PluginDatabaseDriver.connect()` implementation handles the connection config correctly (host, port, credentials, SSL settings). +- The `PluginDriverAdapter` sets status to `.error(message)` on failure. The error message propagates to the UI. + +### Plugin is disabled and won't re-enable + +**Fix:** +- The disable state is stored in `UserDefaults` under the `disabledPlugins` key as a string array of bundle IDs. +- To manually clear: `defaults delete com.TablePro disabledPlugins` (or remove your specific bundle ID from the array). + +--- + +## SourceKit / Xcode Indexing Noise + +### "No such module 'TableProPluginKit'" + +This is an Xcode indexing issue, not a real build error. SourceKit sometimes can't resolve cross-target module imports. + +**Fix:** +- Build with `xcodebuild` to confirm the project compiles. +- Clean derived data if it persists: `rm -rf ~/Library/Developer/Xcode/DerivedData/TablePro-*` +- The project uses `objectVersion 77` (PBXFileSystemSynchronizedRootGroup), which can confuse older Xcode indexing. + +### "Cannot find type 'PluginQueryResult' in scope" + +Same indexing noise. Types from `TableProPluginKit` (like `PluginQueryResult`, `PluginColumnInfo`, `PluginTableInfo`) sometimes aren't visible to SourceKit in plugin targets. + +**Fix:** +- Build with `xcodebuild` to verify. If it builds, ignore the SourceKit errors. + +--- + +## Testing + +### Can't load plugin bundles in unit tests + +Plugin bundles require the full app context: framework search paths, code signing, runtime bundle loading. The test runner doesn't provide this. + +**Fix:** +- Don't call `PluginManager.loadAllPlugins()` in tests. Plugin bundles aren't available in the test runner. +- Use `StubDriver` mocks that implement `DatabaseDriver` protocol directly. +- To test plugin source code, add the plugin's Swift files to the test target (inline-copy pattern) so you can test logic without bundle loading. + +### DatabaseDriverFactory.createDriver throws in tests + +The factory throws when a plugin isn't loaded (it no longer calls `fatalError`). + +**Fix:** +- Tests should not go through `DatabaseDriverFactory`. Use `StubDriver` mocks instead. +- If you must test factory behavior, mock the `PluginManager.driverPlugins` dictionary. + +--- + +## Debugging Tips + +- **Console.app**: Filter by subsystem `com.TablePro`. Two categories matter: + - `PluginManager` - load, register, enable, disable events + - `PluginDriverAdapter` - adapter-level errors during query execution and schema operations +- **Code signature inspection**: `codesign -dvvv MyPlugin.tableplugin 2>&1` shows signing identity, team ID, entitlements, and flags. +- **Binary architecture**: `file MyPlugin.tableplugin/Contents/MacOS/MyPlugin` shows which architectures the binary contains. +- **dyld debugging**: Set `DYLD_PRINT_LIBRARIES=1` in Xcode scheme environment variables to see all library loads at launch. +- **Plugin directories**: + - Built-in: `TablePro.app/Contents/PlugIns/` + - User-installed: `~/Library/Application Support/TablePro/Plugins/` diff --git a/docs/development/plugin-system/ui-design.md b/docs/development/plugin-system/ui-design.md new file mode 100644 index 00000000..4f10203c --- /dev/null +++ b/docs/development/plugin-system/ui-design.md @@ -0,0 +1,216 @@ +# UI Design: Plugin Management + +The Settings > Plugins tab is implemented and shipping. Users can view all loaded plugins, enable/disable them, and install third-party plugins from `.zip` files. + +## Settings > Plugins Tab + +### Installed Plugins List + +``` ++---------------------------------------------------------------+ +| Settings | +|---------------------------------------------------------------| +| General | Editor | Plugins | ... | +|---------------------------------------------------------------| +| | +| Installed Plugins | +| | +| +-----------------------------------------------------------+| +| | [icon] MySQL Driver v1.0.0 Built-in [ON] || +| | [icon] PostgreSQL Driver v1.0.0 Built-in [ON] || +| | [icon] SQLite Driver v1.0.0 Built-in [ON] || +| | [icon] SQL Server Driver v1.0.0 Built-in [ON] || +| | [icon] ClickHouse Driver v1.0.0 Built-in [ON] || +| | [icon] MongoDB Driver v1.0.0 Built-in [ON] || +| | [icon] Redis Driver v1.0.0 Built-in [ON] || +| | [icon] Oracle Driver v1.0.0 Built-in [ON] || +| +-----------------------------------------------------------+| +| | +| [Install from File...] | +| | ++---------------------------------------------------------------+ +``` + +Each row displays: +- SF Symbol icon from `DriverPlugin.iconName` +- Plugin name and version string +- Source badge: blue "Built-in" for bundled plugins, green "User" for user-installed plugins +- Enable/disable Toggle + +Clicking a row expands or collapses an inline detail section below it. + +The "Install from File..." button opens an `NSOpenPanel` filtered to `.zip` files. A progress indicator is shown during extraction and validation. + +### Plugin Detail Section + +Clicking a plugin row expands a detail section inline: + +``` ++---------------------------------------------------------------+ +| MySQL Driver | +|---------------------------------------------------------------| +| | +| Version: 1.0.0 | +| Bundle ID: com.TablePro.MySQLDriverPlugin | +| Source: Built-in | +| | +| Capabilities: Database Driver | +| Database Type: MySQL | +| Also handles: MariaDB | +| Default Port: 3306 | +| | +| Description: | +| MySQL/MariaDB support via libmariadb | +| | +| [Uninstall] (user only) | +| | ++---------------------------------------------------------------+ +``` + +Implementation details: +- Uses a SwiftUI `Form` with `.grouped` style. +- Fields shown: Version, Bundle ID, Source, Capabilities, Database Type, Also handles (if `additionalDatabaseTypeIds` is non-empty), Default Port, Description. +- The Uninstall button only appears for user-installed plugins. Clicking it shows a confirmation dialog before removal. +- Built-in plugins cannot be uninstalled; they can only be disabled via the toggle in the list row. + +### Disabled State + +When a plugin is disabled: +- The toggle shows OFF. +- The row appears dimmed. +- The database type is no longer available in the connection dialog. +- Existing connections using that type show an error on next connect attempt. + +## Connection Dialog: Dynamic Fields + +When creating a new connection, the dialog renders fields based on the selected driver plugin. + +### Standard Fields (always shown) + +``` ++-----------------------------------------------+ +| New Connection | +|-----------------------------------------------| +| Type: [MySQL v] | +| Name: [ ] | +| Host: [localhost ] | +| Port: [3306 ] | +| Username: [root ] | +| Password: [******** ] | +| Database: [mydb ] | ++-----------------------------------------------+ +``` + +### With Additional Fields (e.g., SQL Server) + +``` ++-----------------------------------------------+ +| New Connection | +|-----------------------------------------------| +| Type: [SQL Server v] | +| Name: [ ] | +| Host: [localhost ] | +| Port: [1433 ] | +| Username: [sa ] | +| Password: [******** ] | +| Database: [master ] | +| | +| --- Driver-specific --- | +| Schema: [dbo ] | ++-----------------------------------------------+ +``` + +The "Driver-specific" section is generated from `DriverPlugin.additionalConnectionFields`. Each `ConnectionField` maps to a text field (or secure text field if `isSecure` is true). Required fields show validation errors if left empty. + +## Error Handling + +Errors during plugin install and uninstall are handled with native SwiftUI and AppKit alerts: + +- **Install errors**: Shown via a SwiftUI `.alert` modifier on the settings view. Error cases include: + - Invalid bundle (missing `NSPrincipalClass`, wrong extension, no `DriverPlugin` conformance) + - Signature verification failed + - Plugin conflict (a plugin with the same bundle ID is already installed) + - App version too old (`TableProMinAppVersion` exceeds current app version) +- **Uninstall confirmation**: Uses `AlertHelper.confirmDestructive` to show a confirmation dialog before removing a user-installed plugin. +- **Runtime load failures**: Logged via OSLog. If a plugin fails to load at startup, it is skipped and the remaining plugins continue loading. + +## Browse Tab (Phase 3) + +The Plugins settings pane uses a segmented picker to switch between Installed and Browse sub-tabs. + +``` ++---------------------------------------------------------------+ +| Settings | +|---------------------------------------------------------------| +| General | Editor | Plugins | ... | +|---------------------------------------------------------------| +| | +| [ Installed | Browse ] (segmented picker) | +| | ++---------------------------------------------------------------+ +``` + +When "Browse" is selected, the view shows the registry contents fetched from GitHub. + +### Browse Tab Layout + +``` ++---------------------------------------------------------------+ +| [Search plugins... ] | +| | +| [All] [Database Drivers] [Export Formats] [Themes] | +| | +| +-----------------------------------------------------------+| +| | [icon] CockroachDB Driver ✓ v0.1.0 by dev [Install]|| +| | [icon] DuckDB Driver v0.2.0 by dev [Install] || +| | [icon] Parquet Export ✓ v1.0.0 by dev [Installed] || +| +-----------------------------------------------------------+| +| | ++---------------------------------------------------------------+ +``` + +- Search bar filters the plugin list by name and description (local client-side filtering). +- Category filter chips sit below the search bar. Tapping a chip filters the list to that category. "All" shows everything. +- The plugin list scrolls vertically. Each entry is a `RegistryPluginRow`. + +### RegistryPluginRow + +Each row displays: +- SF Symbol icon from the registry manifest's `iconName` field +- Plugin name, with a checkmark badge inline if the plugin has verified trust level +- Version string and author name (e.g., "v0.1.0 by dev") +- Action button on the trailing edge (see install flow states below) + +Clicking a row expands a `RegistryPluginDetailView` inline below it, showing: +- Description text (multi-line, truncated with a "Show More" toggle if longer than 3 lines) +- Category label +- Compatibility info (minimum app version required) +- Homepage link (opens in default browser) + +### Install Flow States + +The action button on each row transitions through these states: + +| State | Button | Behavior | +|-------|--------|----------| +| Not installed | "Install" (blue) | Starts streaming download from the registry URL | +| Downloading | Progress bar with percentage | `PluginInstallTracker` updates progress; cancellable | +| Installing | Spinner with "Installing..." | Zip extraction, signature check, bundle load via `installPlugin(from:)` | +| Completed | "Installed" (gray, disabled) | Plugin now appears in the Installed tab | +| Failed | "Retry" (red) | Resets to downloading state on tap | + +`PluginInstallTracker` holds per-plugin state keyed by bundle ID. It publishes state changes so the row updates reactively. + +Download and install steps: +1. Streaming download with `URLSession` data task, tracking bytes received vs. expected content length. +2. SHA-256 checksum of the downloaded zip verified against the manifest's `checksum` field. +3. On checksum match, delegates to the existing `installPlugin(from:)` path (zip extraction, code signature verification, bundle loading). +4. On failure, the row shows the error inline and switches to the Retry state. + +### Error, Loading, and Empty States + +- **Loading**: A `ProgressView` spinner centered in the Browse tab while the initial registry fetch is in progress. +- **Fetch error**: A centered message with the error description and a "Try Again" button that re-triggers `RegistryClient.fetchManifest()`. +- **Offline fallback**: If the network request fails but a cached manifest exists in UserDefaults, the cached data is shown with a subtle "Showing cached data" label below the search bar. +- **Empty search results**: "No plugins match your search." text centered in the list area. +- **Incompatible plugin**: If a plugin's `minAppVersion` exceeds the current app version, the Install button is replaced with "Requires vX.Y.Z" in gray text. The detail view explains the version requirement. From 14a77ac79328b5888768237c8ae55a3903342893 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 09:28:36 +0700 Subject: [PATCH 2/5] chore: exclude plugin-system docs from PR --- docs/development/plugin-system/README.md | 74 ---- .../development/plugin-system/architecture.md | 115 ------- .../plugin-system/developer-guide.md | 243 ------------- .../plugin-system/migration-guide.md | 164 --------- docs/development/plugin-system/plugin-kit.md | 323 ------------------ .../plugin-system/plugin-manager.md | 233 ------------- docs/development/plugin-system/roadmap.md | 143 -------- docs/development/plugin-system/security.md | 154 --------- .../plugin-system/troubleshooting.md | 235 ------------- docs/development/plugin-system/ui-design.md | 216 ------------ 10 files changed, 1900 deletions(-) delete mode 100644 docs/development/plugin-system/README.md delete mode 100644 docs/development/plugin-system/architecture.md delete mode 100644 docs/development/plugin-system/developer-guide.md delete mode 100644 docs/development/plugin-system/migration-guide.md delete mode 100644 docs/development/plugin-system/plugin-kit.md delete mode 100644 docs/development/plugin-system/plugin-manager.md delete mode 100644 docs/development/plugin-system/roadmap.md delete mode 100644 docs/development/plugin-system/security.md delete mode 100644 docs/development/plugin-system/troubleshooting.md delete mode 100644 docs/development/plugin-system/ui-design.md diff --git a/docs/development/plugin-system/README.md b/docs/development/plugin-system/README.md deleted file mode 100644 index 0f199b1f..00000000 --- a/docs/development/plugin-system/README.md +++ /dev/null @@ -1,74 +0,0 @@ -# Plugin System - -TablePro uses a native-bundle plugin architecture to load database drivers (and eventually other extensions) at runtime. Each plugin is a `.tableplugin` bundle that links the `TableProPluginKit` framework and exposes a principal class conforming to `TableProPlugin`. - -Phase 0 (foundation), Phase 1 (all 8 built-in drivers extracted), and Phase 2 (sideload install/uninstall via Settings > Plugins tab) are all complete. The system is live and shipping. - -Code review improvements applied during Phase 2: data race fix on plugin registry access, async process execution for install/uninstall operations, team-pinned `SecRequirement` signature verification, and proper error propagation throughout the plugin lifecycle. - -## Documents - -| Document | Description | -|----------|-------------| -| [architecture.md](architecture.md) | Three-tier model, extension points, trust levels, directory layout | -| [plugin-kit.md](plugin-kit.md) | TableProPluginKit framework: protocols, transfer types, versioning | -| [plugin-manager.md](plugin-manager.md) | PluginManager singleton: loading, registration, enable/disable, install/uninstall | -| [developer-guide.md](developer-guide.md) | How to build a new plugin from scratch | -| [ui-design.md](ui-design.md) | Settings tab wireframes and implementation | -| [roadmap.md](roadmap.md) | Phased rollout plan (Phase 0-6) | -| [security.md](security.md) | Code signing model, threat model, trust levels, known limitations | -| [troubleshooting.md](troubleshooting.md) | Common errors, signature failures, SourceKit noise, testing tips | -| [migration-guide.md](migration-guide.md) | Versioning policy, compatibility matrix, migration templates | - -## File Map - -``` -Plugins/ - TableProPluginKit/ # Shared framework (linked by all plugins + main app) - TableProPlugin.swift # Base plugin protocol - DriverPlugin.swift # Driver extension protocol - PluginDatabaseDriver.swift # Driver implementation protocol (50+ methods) - PluginCapability.swift # Capability enum - DriverConnectionConfig.swift # Connection config passed to createDriver() - ConnectionField.swift # Custom connection dialog fields - PluginQueryResult.swift # Query result transfer type - PluginColumnInfo.swift # Column metadata - PluginIndexInfo.swift # Index metadata - PluginForeignKeyInfo.swift # Foreign key metadata - PluginTableInfo.swift # Table list entry - PluginTableMetadata.swift # Table stats (size, row count, engine) - PluginDatabaseMetadata.swift # Database stats - ArrayExtension.swift # Safe subscript helper - MongoShellParser.swift # Shared MongoDB shell parsing utilities - - ClickHouseDriverPlugin/ # ClickHouse driver - MongoDBDriverPlugin/ # MongoDB driver - MSSQLDriverPlugin/ # SQL Server driver (FreeTDS) - MySQLDriverPlugin/ # MySQL/MariaDB driver (libmariadb) - OracleDriverPlugin/ # Oracle driver (OCI stub) - PostgreSQLDriverPlugin/ # PostgreSQL/Redshift driver (libpq) - RedisDriverPlugin/ # Redis driver - SQLiteDriverPlugin/ # SQLite driver (Foundation sqlite3) - -TablePro/Core/Plugins/ # Main app infrastructure - PluginManager.swift # Singleton: load, register, enable/disable, install/uninstall - PluginDriverAdapter.swift # Bridges PluginDatabaseDriver -> DatabaseDriver protocol - PluginModels.swift # PluginEntry, PluginSource - PluginError.swift # Error types for plugin operations - -TablePro/Views/Settings/ - PluginsSettingsView.swift # Settings > Plugins tab UI (list, enable/disable, install/uninstall) -``` - -## Current Driver Plugins - -| Plugin | Type ID | Additional IDs | C Library | Port | -|--------|---------|----------------|-----------|------| -| MySQL | `MySQL` | `MariaDB` | libmariadb | 3306 | -| PostgreSQL | `PostgreSQL` | `Redshift` | libpq | 5432 | -| SQLite | `SQLite` | -- | sqlite3 | 0 | -| SQL Server | `SQL Server` | -- | FreeTDS | 1433 | -| ClickHouse | `ClickHouse` | -- | HTTP API | 8123 | -| MongoDB | `MongoDB` | -- | libmongoc | 27017 | -| Redis | `Redis` | -- | hiredis | 6379 | -| Oracle | `Oracle` | -- | OCI stub | 1521 | diff --git a/docs/development/plugin-system/architecture.md b/docs/development/plugin-system/architecture.md deleted file mode 100644 index f659bc40..00000000 --- a/docs/development/plugin-system/architecture.md +++ /dev/null @@ -1,115 +0,0 @@ -# Plugin Architecture - -## Three-Tier Plugin Model - -The plugin system defines three tiers of increasing complexity. Only Tier 3 is implemented today. - -| Tier | Type | Runtime | Sandboxing | Status | -|------|------|---------|------------|--------| -| 1 | File-based (themes, snippets) | JSON/plist parsing | Full (data only) | Future | -| 2 | Script-based | JavaScriptCore | JSContext sandbox | Future | -| 3 | Native bundles (.tableplugin) | NSBundle + dynamic linking | Code signature required | **Shipped (Phase 0-2)** | - -**Tier 1** plugins are plain data files: JSON theme definitions, SQL snippet packs, keyboard shortcut maps. The app reads and validates them without executing any code. - -**Tier 2** plugins run JavaScript in a `JSContext` with a controlled API surface. Useful for custom cell formatters, simple transformations, and scripted export logic. - -**Tier 3** plugins are compiled macOS bundles that link `TableProPluginKit`. They have full access to Swift/ObjC runtime and system frameworks. This is the only tier that exists today, used for all 8 database drivers. Phase 2 (sideload install/uninstall via Settings > Plugins tab) is complete. - -## Extension Points - -Nine extension points are identified. Only `databaseDriver` is implemented. - -| Extension Point | Capability Enum | Tier | Status | -|-----------------|----------------|------|--------| -| Database drivers | `.databaseDriver` | 3 | Shipped | -| Export formats | `.exportFormat` | 2-3 | Planned | -| Import formats | `.importFormat` | 2-3 | Planned | -| SQL dialects | `.sqlDialect` | 3 | Planned | -| AI providers | `.aiProvider` | 3 | Planned | -| Cell renderers | `.cellRenderer` | 1-2 | Planned | -| Themes | -- | 1 | Planned | -| Sidebar panels | `.sidebarPanel` | 3 | Planned | -| Snippet packs | -- | 1 | Planned | - -The `PluginCapability` enum currently defines 7 cases. Theme and snippet pack extension points will use Tier 1 file-based loading and do not need capability enum values. - -## Trust Levels - -Plugins are loaded with different trust levels depending on their origin: - -``` -Built-in > Verified > Community > Sideloaded -``` - -| Level | Source | Code Signature | Review | -|-------|--------|---------------|--------| -| Built-in | Shipped in app bundle `Contents/PlugIns/` | App signature covers them | N/A | -| Verified | Future marketplace, signed by TablePro team | Team ID verified | Manual review | -| Community | Future marketplace, signed by developer | Valid signature required | Automated checks | -| Sideloaded | User installs from `.zip` file | Team-pinned signature required | None | - -User-installed plugins must pass `SecStaticCodeCheckValidity` with a team-pinned `SecRequirement` before loading. The requirement string pins to the TablePro team identifier, so only plugins signed by a known team ID are accepted. Built-in plugins are covered by the app's own code signature. - -## Directory Layout - -``` -TablePro.app/ - Contents/ - PlugIns/ # Built-in plugins (read-only) - MySQLDriverPlugin.tableplugin - PostgreSQLDriverPlugin.tableplugin - SQLiteDriverPlugin.tableplugin - ... - Frameworks/ - TableProPluginKit.framework # Shared framework - -~/Library/Application Support/TablePro/ - Plugins/ # User-installed plugins (read-write) - SomeThirdParty.tableplugin -``` - -## Data Flow - -```mermaid -graph LR - A[PluginManager] -->|loads| B[.tableplugin Bundle] - B -->|instantiates| C[NSPrincipalClass] - C -->|conforms to| D[DriverPlugin] - D -->|createDriver| E[PluginDatabaseDriver] - E -->|wrapped by| F[PluginDriverAdapter] - F -->|conforms to| G[DatabaseDriver protocol] - G -->|used by| H[DatabaseManager] -``` - -1. `PluginManager` scans both plugin directories at startup. -2. Each `.tableplugin` bundle is loaded via `Bundle(url:)`. -3. The `NSPrincipalClass` is cast to `TableProPlugin` and instantiated. -4. If the plugin conforms to `DriverPlugin`, it is registered by its `databaseTypeId` (and any `additionalDatabaseTypeIds`). -5. When a connection is opened, `DatabaseManager` looks up the driver plugin by type ID, calls `createDriver(config:)`, and wraps the result in a `PluginDriverAdapter`. -6. `PluginDriverAdapter` conforms to the main app's `DatabaseDriver` protocol, translating between plugin transfer types (`PluginQueryResult`, `PluginColumnInfo`, etc.) and internal app types (`QueryResult`, `ColumnInfo`, etc.). - -`DatabaseDriverFactory` is `@MainActor` isolated, ensuring thread-safe access to the `driverPlugins` dictionary without explicit locking. - -## Bundle Structure - -Each `.tableplugin` bundle follows standard macOS bundle conventions: - -``` -MyDriver.tableplugin/ - Contents/ - Info.plist # Must set NSPrincipalClass, TableProPluginKitVersion - MacOS/ - MyDriver # Compiled binary (Universal Binary) - Frameworks/ # Embedded dependencies (if any) - Resources/ # Assets, localization (optional) -``` - -Required Info.plist keys: - -| Key | Type | Description | -|-----|------|-------------| -| `NSPrincipalClass` | String | Fully qualified class name (e.g., `MySQLPlugin`) | -| `CFBundleIdentifier` | String | Unique bundle ID | -| `TableProPluginKitVersion` | Integer | Protocol version (currently `1`) | -| `TableProMinAppVersion` | String | Minimum app version required (optional) | diff --git a/docs/development/plugin-system/developer-guide.md b/docs/development/plugin-system/developer-guide.md deleted file mode 100644 index 0db9017d..00000000 --- a/docs/development/plugin-system/developer-guide.md +++ /dev/null @@ -1,243 +0,0 @@ -# Developer Guide: Building a Plugin - -This guide walks through creating a new database driver plugin. The SQLite plugin is the simplest reference implementation. - -## Prerequisites - -- Xcode (same version used to build TablePro) -- Access to the `TableProPluginKit` framework -- A code signing identity (required for user-installed plugins) - -## 1. Create the Bundle Target - -In Xcode, add a new target: - -1. File > New > Target > macOS > Bundle -2. Set product name (e.g., `MyDBDriverPlugin`) -3. Set bundle extension to `tableplugin` -4. Link `TableProPluginKit.framework` - -### Info.plist - -Set these keys: - -```xml -NSPrincipalClass -MyDBPlugin - -TableProPluginKitVersion -1 - -CFBundleIdentifier -com.example.mydb-driver -``` - -Optionally set `TableProMinAppVersion` if your plugin uses APIs added in a specific app version. - -## 2. Implement the Plugin Entry Point - -Create the principal class. It must: -- Subclass `NSObject` (required for `NSPrincipalClass` loading) -- Conform to `TableProPlugin` and `DriverPlugin` -- Have a `required init()` (inherited from `NSObject`) - -```swift -import Foundation -import TableProPluginKit - -final class MyDBPlugin: NSObject, TableProPlugin, DriverPlugin { - static let pluginName = "MyDB Driver" - static let pluginVersion = "1.0.0" - static let pluginDescription = "MyDB database support" - static let capabilities: [PluginCapability] = [.databaseDriver] - - static let databaseTypeId = "MyDB" - static let databaseDisplayName = "MyDB" - static let iconName = "cylinder.fill" // SF Symbol name - static let defaultPort = 5555 - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { - MyDBPluginDriver(config: config) - } -} -``` - -### Optional: Additional Connection Fields - -If your database needs extra connection parameters beyond host/port/user/pass/database: - -```swift -static let additionalConnectionFields: [ConnectionField] = [ - ConnectionField( - id: "myOption", - label: "Custom Option", - placeholder: "value", - required: false, - secure: false, - defaultValue: "default" - ) -] -``` - -These values arrive in `config.additionalFields["myOption"]`. - -### Optional: Multi-Type Support - -If one driver handles multiple database types (e.g., MySQL also handles MariaDB): - -```swift -static let additionalDatabaseTypeIds: [String] = ["MyDB-Variant"] -``` - -The plugin is registered under both `"MyDB"` and `"MyDB-Variant"`. - -## 3. Implement PluginDatabaseDriver - -This is the core of the plugin. Create a class conforming to `PluginDatabaseDriver`. - -### Minimum Required Methods - -```swift -final class MyDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { - private let config: DriverConnectionConfig - - init(config: DriverConnectionConfig) { - self.config = config - } - - // -- Connection -- - - func connect() async throws { - // Open connection using config.host, config.port, etc. - } - - func disconnect() { - // Close connection - } - - // -- Queries -- - - func execute(query: String) async throws -> PluginQueryResult { - // Run query, return results - // All cell values must be stringified (String? per cell) - } - - // -- Schema -- - - 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 { ... } - - // -- Databases -- - - func fetchDatabases() async throws -> [String] { ... } - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { ... } -} -``` - -### Methods with Default Implementations - -These have working defaults in the protocol extension. Override only if your database needs different behavior: - -| Method | Default Behavior | -|--------|-----------------| -| `ping()` | Runs `SELECT 1` | -| `fetchRowCount(query:)` | Wraps query in `SELECT COUNT(*) FROM (...)` | -| `fetchRows(query:offset:limit:)` | Appends `LIMIT N OFFSET M` | -| `executeParameterized(query:parameters:)` | String-replaces `?` placeholders | -| `beginTransaction()` | Runs `BEGIN` | -| `commitTransaction()` | Runs `COMMIT` | -| `rollbackTransaction()` | Runs `ROLLBACK` | -| `switchDatabase(to:)` | Throws "unsupported" error | -| `cancelQuery()` | No-op | -| `applyQueryTimeout(_:)` | No-op | -| `fetchAllColumns(schema:)` | Iterates `fetchTables` + `fetchColumns` per table | -| `fetchAllForeignKeys(schema:)` | Iterates `fetchTables` + `fetchForeignKeys` per table | -| `fetchAllDatabaseMetadata()` | Iterates `fetchDatabases` + `fetchDatabaseMetadata` per db | - -**`switchDatabase(to:)` note**: The default implementation throws an "unsupported" error. Drivers that support database switching must override it with their own logic. For reference: -- MySQL overrides with backtick-escaped `USE \`name\`` syntax. -- MSSQL overrides using the native FreeTDS API (not a SQL statement). -- ClickHouse has its own override that reconnects with the new database in the URL. - -### Concurrency - -`PluginDatabaseDriver` requires `Sendable` conformance. Common patterns: - -- **Actor isolation**: Use a private actor to wrap the native connection handle (see SQLite plugin's `SQLiteConnectionActor`). -- **`@unchecked Sendable`**: If you manage thread safety manually with locks, mark the class `@unchecked Sendable`. -- **NSLock for interrupt handles**: For `cancelQuery()`, store the connection handle behind an `NSLock` so it can be accessed from any thread. - -## 4. Column Type Names - -Return raw type name strings in `PluginQueryResult.columnTypeNames`. The app maps these to its internal `ColumnType` enum. Recognized names include: - -`BOOL`, `INT`, `INTEGER`, `BIGINT`, `SMALLINT`, `TINYINT`, `FLOAT`, `DOUBLE`, `DECIMAL`, `NUMERIC`, `REAL`, `DATE`, `DATETIME`, `TIMESTAMP`, `TIME`, `JSON`, `JSONB`, `BLOB`, `BYTEA`, `BINARY`, `GEOMETRY`, `POINT`, `LINESTRING`, `POLYGON`, `ENUM`, `SET`. - -Unrecognized type names map to `.text`, which is a safe fallback. - -## 5. Build and Test - -### Build the Plugin - -The plugin target produces a `.tableplugin` bundle. Ensure: - -- It builds as a Universal Binary (arm64 + x86_64) for distribution. -- The `TableProPluginKit` framework is linked (not embedded -- it ships with the app). - -### Testing - -For unit tests, use the inline-copy pattern: copy the plugin's source files into the test target rather than loading the bundle dynamically. This avoids bundle-loading complexity in test runs. - -```swift -// In your test target: -// 1. Add MyDBPluginDriver.swift to the test target's Compile Sources -// 2. Test the driver directly - -func testConnect() async throws { - let config = DriverConnectionConfig( - host: "localhost", - port: 5555, - username: "test", - password: "test", - database: "testdb" - ) - let driver = MyDBPluginDriver(config: config) - try await driver.connect() - // assertions... - driver.disconnect() -} -``` - -Note: `DatabaseDriverFactory.createDriver` now throws (rather than calling `fatalError`) when a plugin is not found. For tests that need a `DatabaseDriver` without loading real plugin bundles, use a `StubDriver` mock that conforms to `DatabaseDriver` directly. This avoids the need to have `.tableplugin` bundles available in the test environment. - -### Manual Testing - -1. Build the plugin target. -2. Copy the `.tableplugin` bundle to `~/Library/Application Support/TablePro/Plugins/`. -3. Launch TablePro. Check the log for `"Loaded plugin 'MyDB Driver'"`. -4. Create a connection using your database type. -5. Alternatively, install via Settings > Plugins: click "Install from File...", select a `.zip` containing your `.tableplugin` bundle, and verify it appears in the plugin list. - -For built-in plugin development, the plugin target is embedded in the app bundle automatically via Xcode's "Embed Without Signing" build phase. - -## Reference: SQLite Plugin Structure - -The SQLite plugin (`Plugins/SQLiteDriverPlugin/`) is the simplest driver and a good starting point: - -``` -SQLiteDriverPlugin/ - SQLitePlugin.swift # Entry point + driver implementation (single file) -``` - -Key patterns to copy: -- `NSObject` subclass for the entry point -- Actor-based connection wrapper for thread safety -- `NSLock` for the interrupt handle -- Raw result struct to pass data out of the actor -- `stripLimitOffset` helper for pagination -- Error enum conforming to `LocalizedError` diff --git a/docs/development/plugin-system/migration-guide.md b/docs/development/plugin-system/migration-guide.md deleted file mode 100644 index b99db1f6..00000000 --- a/docs/development/plugin-system/migration-guide.md +++ /dev/null @@ -1,164 +0,0 @@ -# PluginKit Migration Guide - -This guide covers how to handle breaking changes when TableProPluginKit versions bump. If you maintain a third-party plugin, read this whenever you upgrade to a new TablePro release. - -## Versioning Policy - -TableProPluginKit uses a single integer version (`TableProPluginKitVersion`) declared in each plugin's `Info.plist`. This version tracks binary-incompatible changes to protocols and transfer types. - -**Current version: `1`** - -Rules: - -- **Additive changes do not bump the version.** New methods on `PluginDatabaseDriver` with default implementations, new optional fields on transfer types with default init values - these are backwards-compatible. Your plugin keeps working without changes. -- **Breaking changes bump the version.** Removing methods, changing method signatures, renaming protocol requirements, changing transfer type field types or removing fields - these require a version bump. -- **Plugins declaring a version higher than the app supports are rejected at load time.** If your plugin has `TableProPluginKitVersion = 3` but the app only supports up to `2`, it won't load. The app logs: `incompatibleVersion(required: 3, current: 2)`. -- **The version only goes up.** There are no minor versions or patch levels. Each bump means "review the migration steps below." - -## When to Update Your Plugin - -Existing plugins continue to work after a PluginKit version bump until the old version is deprecated and removed (which will be announced at least one major release in advance). - -Here's what to check on each TablePro release: - -1. Read the release notes for any PluginKit version changes. -2. If the version bumped, find the corresponding section below for step-by-step migration. -3. Update `TableProPluginKitVersion` in your `Info.plist` to the new version. -4. Rebuild against the new `TableProPluginKit.framework`. -5. Test with the target TablePro version. - -### Info.plist Keys - -| Key | Type | Description | -|-----|------|-------------| -| `TableProPluginKitVersion` | Integer | Which PluginKit protocol version this plugin targets. Must be <= the app's `PluginManager.currentPluginKitVersion`. | -| `TableProMinAppVersion` | String | Minimum TablePro app version required (e.g., `"0.15.0"`). Optional. If set, the app rejects the plugin when running an older version. | - -## Version 1 (Current - Baseline) - -This is the initial PluginKit release. No migration needed. - -### Protocols - -**`TableProPlugin`** - base protocol for all plugins: - -```swift -public protocol TableProPlugin: AnyObject { - static var pluginName: String { get } - static var pluginVersion: String { get } - static var pluginDescription: String { get } - static var capabilities: [PluginCapability] { get } - init() -} -``` - -**`DriverPlugin`** - entry point for database driver plugins: - -```swift -public protocol DriverPlugin: TableProPlugin { - static var databaseTypeId: String { get } // Required - static var databaseDisplayName: String { get } // Required - static var iconName: String { get } // Required - static var defaultPort: Int { get } // Required - static var additionalConnectionFields: [ConnectionField] { get } // Default: [] - static var additionalDatabaseTypeIds: [String] { get } // Default: [] - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver -} -``` - -**`PluginDatabaseDriver`** - the driver implementation protocol. Required methods (no default): - -- `connect() async throws` -- `disconnect()` -- `execute(query:) async throws -> PluginQueryResult` -- `fetchTables(schema:) async throws -> [PluginTableInfo]` -- `fetchColumns(table:schema:) async throws -> [PluginColumnInfo]` -- `fetchIndexes(table:schema:) async throws -> [PluginIndexInfo]` -- `fetchForeignKeys(table:schema:) async throws -> [PluginForeignKeyInfo]` -- `fetchTableDDL(table:schema:) async throws -> String` -- `fetchViewDefinition(view:schema:) async throws -> String` -- `fetchTableMetadata(table:schema:) async throws -> PluginTableMetadata` -- `fetchDatabases() async throws -> [String]` -- `fetchDatabaseMetadata(_:) async throws -> PluginDatabaseMetadata` - -All other methods have default implementations. See `PluginDatabaseDriver.swift` for the full list and defaults. - -### Transfer Types - -All transfer types are `Codable` and `Sendable`: - -| Type | Fields | -|------|--------| -| `PluginQueryResult` | `columns: [String]`, `columnTypeNames: [String]`, `rows: [[String?]]`, `rowsAffected: Int`, `executionTime: TimeInterval` | -| `PluginColumnInfo` | `name`, `dataType`, `isNullable`, `isPrimaryKey`, `defaultValue?`, `extra?`, `charset?`, `collation?`, `comment?` | -| `PluginTableInfo` | `name`, `type`, `rowCount?` | -| `PluginIndexInfo` | See source | -| `PluginForeignKeyInfo` | See source | -| `PluginTableMetadata` | See source | -| `PluginDatabaseMetadata` | See source | -| `DriverConnectionConfig` | `host`, `port`, `username`, `password`, `database`, `additionalFields: [String: String]` | -| `ConnectionField` | `id`, `label`, `placeholder`, `isRequired`, `isSecure`, `defaultValue?` | -| `PluginCapability` | Enum: `.databaseDriver`, `.exportFormat`, `.importFormat`, `.sqlDialect`, `.aiProvider`, `.cellRenderer`, `.sidebarPanel` | - -## Migration Template - Version N to N+1 - -Future version bumps will add a section here following this format: - -``` -## Version N to Version N+1 - -Released in TablePro vX.Y.Z. - -### Breaking Changes - -- `methodX(old:)` renamed to `methodX(new:)` -- `TransferTypeY.fieldZ` type changed from String to Int -- `removedMethod()` removed (use `replacementMethod()` instead) - -### New Required Methods (no default) - -- `newMethod()` - what it does, how to implement it - -### New Optional Methods (have defaults) - -- `optionalMethod()` - default behavior, when you'd want to override - -### Transfer Type Changes - -- `PluginQueryResult` added field `newField: Type` (default value: X) -- `PluginColumnInfo.oldField` renamed to `newFieldName` - -### Migration Steps - -1. Update `TableProPluginKitVersion` to `N+1` in Info.plist -2. Rename `methodX(old:)` to `methodX(new:)` -3. Update `TransferTypeY.fieldZ` from String to Int -4. Implement `newMethod()` -5. Rebuild and test - -### Before / After - -// Before (version N) -func methodX(old: String) async throws -> Result { ... } - -// After (version N+1) -func methodX(new: String) async throws -> Result { ... } -``` - -## Compatibility Matrix - -| PluginKit Version | Minimum App Version | Status | -|-------------------|---------------------|--------| -| 1 | 0.15.0 | Current | - -This table will be updated with each version bump. - -## Best Practices for Forward Compatibility - -- **Only import TableProPluginKit.** Never import the main `TablePro` app target or reference its internal types. The plugin boundary is the PluginKit framework. -- **Implement all protocol methods explicitly.** Don't rely on default implementations staying the same across versions. If a default changes behavior, your plugin won't notice unless you override it. -- **Keep `TableProMinAppVersion` as low as possible.** This maximizes the range of app versions your plugin works with. Only bump it when you actually need a feature from a newer app version. -- **Don't depend on undocumented behavior.** If a default implementation uses `SELECT COUNT(*) FROM (query) _t` for `fetchRowCount`, don't assume that exact SQL. Implement your own if the default doesn't work for your database. -- **Test against both the minimum and latest app versions.** The minimum ensures backwards compatibility; the latest catches any deprecation warnings. -- **All driver classes must be `Sendable`.** `PluginDatabaseDriver` requires `AnyObject & Sendable`. Use actors or proper synchronization for mutable state. -- **Return `PluginQueryResult.empty` for no-op results.** Don't construct zero-valued results manually when there's a static `.empty` available. diff --git a/docs/development/plugin-system/plugin-kit.md b/docs/development/plugin-system/plugin-kit.md deleted file mode 100644 index 9ee43ff8..00000000 --- a/docs/development/plugin-system/plugin-kit.md +++ /dev/null @@ -1,323 +0,0 @@ -# TableProPluginKit Framework - -`TableProPluginKit` is a shared framework linked by both the main app and all plugins. It defines the protocol contracts and transfer types that cross the plugin boundary. - -## Protocols - -### TableProPlugin - -Base protocol for all plugins. Every plugin's principal class must conform to this. - -```swift -public protocol TableProPlugin: AnyObject { - static var pluginName: String { get } - static var pluginVersion: String { get } - static var pluginDescription: String { get } - static var capabilities: [PluginCapability] { get } - - init() -} -``` - -All metadata is on the type itself (static properties), not on instances. The `init()` requirement enables `PluginManager` to instantiate plugins without knowing their concrete type. - -### DriverPlugin - -Extends `TableProPlugin` for database driver plugins. - -```swift -public protocol DriverPlugin: TableProPlugin { - static var databaseTypeId: String { get } - static var databaseDisplayName: String { get } - static var iconName: String { get } - static var defaultPort: Int { get } - static var additionalConnectionFields: [ConnectionField] { get } // default: [] - static var additionalDatabaseTypeIds: [String] { get } // default: [] - - func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver -} -``` - -Key design points: - -- **`databaseTypeId`**: Primary lookup key (e.g., `"MySQL"`, `"PostgreSQL"`). Must match the `DatabaseConnection.type` string used throughout the app. -- **`additionalDatabaseTypeIds`**: Allows one plugin to handle multiple database types. MySQL handles `"MariaDB"`, PostgreSQL handles `"Redshift"`. -- **`additionalConnectionFields`**: Extra fields shown in the connection dialog. SQL Server uses this for a schema field. -- **`createDriver(config:)`**: Factory method. Called each time a connection is opened. - -### PluginDatabaseDriver - -The main implementation protocol. This is what plugin authors spend most of their time on. - -```swift -public protocol PluginDatabaseDriver: AnyObject, Sendable { - // Connection lifecycle - func connect() async throws - func disconnect() - func ping() async throws - - // Query execution - func execute(query: String) async throws -> PluginQueryResult - func fetchRowCount(query: String) async throws -> Int - func fetchRows(query: String, offset: Int, limit: Int) async throws -> PluginQueryResult - func executeParameterized(query: String, parameters: [String?]) async throws -> PluginQueryResult - - // Schema inspection - 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 - - // Database/schema navigation - func fetchDatabases() async throws -> [String] - func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata - func fetchSchemas() async throws -> [String] - func switchSchema(to schema: String) async throws - func switchDatabase(to database: String) async throws - - // Batch operations - func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? - func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] - func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] - func fetchAllDatabaseMetadata() async throws -> [PluginDatabaseMetadata] - func fetchDependentTypes(table: String, schema: String?) async throws -> [(name: String, labels: [String])] - func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] - func createDatabase(name: String, charset: String, collation: String?) async throws - - // Transactions - func beginTransaction() async throws - func commitTransaction() async throws - func rollbackTransaction() async throws - - // Execution control - func cancelQuery() throws - func applyQueryTimeout(_ seconds: Int) async throws - - // Properties - var supportsSchemas: Bool { get } - var supportsTransactions: Bool { get } - var currentSchema: String? { get } - var serverVersion: String? { get } -} -``` - -#### Minimum Required Methods - -Most methods have default implementations in a protocol extension. The minimum a driver must implement: - -- `connect()` / `disconnect()` -- `execute(query:)` -- `fetchTables(schema:)` / `fetchColumns(table:schema:)` -- `fetchIndexes(table:schema:)` / `fetchForeignKeys(table:schema:)` -- `fetchTableDDL(table:schema:)` / `fetchViewDefinition(view:schema:)` -- `fetchTableMetadata(table:schema:)` -- `fetchDatabases()` / `fetchDatabaseMetadata(_:)` - -Everything else falls back to sensible defaults (e.g., `ping()` runs `SELECT 1`, `fetchRowCount()` wraps the query in `SELECT COUNT(*)`, `fetchRows()` appends `LIMIT/OFFSET`). - -#### Methods with Default Implementations - -| Method | Default behavior | -|--------|-----------------| -| `ping()` | Runs `SELECT 1` | -| `fetchRowCount(query:)` | Wraps in `SELECT COUNT(*) FROM (...) _t` | -| `fetchRows(query:offset:limit:)` | Appends `LIMIT/OFFSET` to query | -| `executeParameterized(query:parameters:)` | Replaces `?` placeholders with escaped values | -| `fetchSchemas()` | Returns `[]` | -| `switchSchema(to:)` | No-op | -| `switchDatabase(to:)` | Throws "This driver does not support database switching" | -| `createDatabase(name:charset:collation:)` | Throws NSError "createDatabase not supported" | -| `fetchApproximateRowCount(table:schema:)` | Returns `nil` | -| `fetchAllColumns(schema:)` | Iterates `fetchTables` + `fetchColumns` per table | -| `fetchAllForeignKeys(schema:)` | Iterates `fetchTables` + `fetchForeignKeys` per table | -| `fetchAllDatabaseMetadata()` | Iterates `fetchDatabases` + `fetchDatabaseMetadata` per DB | -| `fetchDependentTypes(table:schema:)` | Returns `[]` | -| `fetchDependentSequences(table:schema:)` | Returns `[]` | -| `beginTransaction()` / `commitTransaction()` / `rollbackTransaction()` | Runs `BEGIN` / `COMMIT` / `ROLLBACK` | -| `cancelQuery()` | No-op | -| `applyQueryTimeout(_:)` | No-op | -| `supportsSchemas` | `false` | -| `supportsTransactions` | `true` | -| `currentSchema` | `nil` | -| `serverVersion` | `nil` | - -#### switchDatabase(to:) - Driver Overrides - -The default implementation throws an error. Drivers that support database switching must override this method with database-specific logic: - -| Driver | Override behavior | -|--------|-----------------| -| MySQL | Runs `USE \`escapedName\`` | -| MSSQL | Uses FreeTDS native `dbuse()` API | -| ClickHouse | Has its own override for ClickHouse database switching | -| PostgreSQL | Does not override -- database switching requires a full reconnect | -| Redis | Does not override -- not applicable | -| MongoDB | Does not override -- not applicable | - -This is an optional override. Drivers only need to implement it if the database engine supports switching databases on an existing connection. - -## Transfer Types - -All data crossing the plugin boundary uses plain `Codable, Sendable` structs. No classes, no app-internal types. - -### PluginQueryResult - -```swift -public struct PluginQueryResult: Codable, Sendable { - public let columns: [String] - public let columnTypeNames: [String] - public let rows: [[String?]] - public let rowsAffected: Int - public let executionTime: TimeInterval -} -``` - -All cell values are stringified. The main app maps `columnTypeNames` to its internal `ColumnType` enum via `PluginDriverAdapter.mapColumnType()`. - -### PluginColumnInfo - -```swift -public struct PluginColumnInfo: Codable, Sendable { - public let name: String - public let dataType: String - public let isNullable: Bool - public let isPrimaryKey: Bool - public let defaultValue: String? - public let extra: String? // e.g., "auto_increment" - public let charset: String? - public let collation: String? - public let comment: String? -} -``` - -### PluginIndexInfo - -```swift -public struct PluginIndexInfo: Codable, Sendable { - public let name: String - public let columns: [String] - public let isUnique: Bool - public let isPrimary: Bool - public let type: String // e.g., "BTREE", "HASH" -} -``` - -### PluginForeignKeyInfo - -```swift -public struct PluginForeignKeyInfo: Codable, Sendable { - public let name: String - public let column: String - public let referencedTable: String - public let referencedColumn: String - public let onDelete: String // default: "NO ACTION" - public let onUpdate: String // default: "NO ACTION" -} -``` - -### PluginTableInfo - -```swift -public struct PluginTableInfo: Codable, Sendable { - public let name: String - public let type: String // "TABLE", "VIEW", "SYSTEM TABLE" - public let rowCount: Int? -} -``` - -### PluginTableMetadata - -```swift -public struct PluginTableMetadata: Codable, Sendable { - public let tableName: String - public let dataSize: Int64? - public let indexSize: Int64? - public let totalSize: Int64? - public let rowCount: Int64? - public let comment: String? - public let engine: String? -} -``` - -### PluginDatabaseMetadata - -```swift -public struct PluginDatabaseMetadata: Codable, Sendable { - public let name: String - public let tableCount: Int? - public let sizeBytes: Int64? - public let isSystemDatabase: Bool -} -``` - -## Connection Configuration - -### DriverConnectionConfig - -Passed to `createDriver(config:)`. Contains standard fields plus a dictionary for plugin-specific extras. - -```swift -public struct DriverConnectionConfig: Sendable { - public let host: String - public let port: Int - public let username: String - public let password: String - public let database: String - public let additionalFields: [String: String] -} -``` - -The `additionalFields` dictionary carries values from any `ConnectionField` entries declared by the plugin, plus internal fields like `driverVariant` (used by PostgreSQL to distinguish Redshift connections). - -### ConnectionField - -Declares a custom field in the connection dialog. - -```swift -public struct ConnectionField: Codable, Sendable { - public let id: String // Key in additionalFields dictionary - public let label: String // Display label - public let placeholder: String // Placeholder text - public let isRequired: Bool - public let isSecure: Bool // Renders as secure text field - public let defaultValue: String? -} -``` - -## PluginCapability - -```swift -public enum PluginCapability: Int, Codable, Sendable { - case databaseDriver - case exportFormat - case importFormat - case sqlDialect - case aiProvider - case cellRenderer - case sidebarPanel -} -``` - -A plugin declares its capabilities in `TableProPlugin.capabilities`. The `PluginManager` uses this to route registration. Currently only `.databaseDriver` triggers any registration logic. - -## Multi-Type Support - -A single plugin can handle multiple database types via `additionalDatabaseTypeIds`. The plugin is registered under all declared type IDs: - -| Plugin | Primary ID | Additional IDs | -|--------|-----------|----------------| -| MySQLPlugin | `MySQL` | `MariaDB` | -| PostgreSQLPlugin | `PostgreSQL` | `Redshift` | - -The PostgreSQL plugin uses `config.additionalFields["driverVariant"]` to decide whether to create a standard PostgreSQL driver or a Redshift-specific variant. - -## Versioning - -- **`TableProPluginKitVersion`** (Info.plist integer): Protocol version. Currently `1`. If a plugin declares a version higher than `PluginManager.currentPluginKitVersion`, loading is rejected. -- **`TableProMinAppVersion`** (Info.plist string, optional): Minimum app version. Compared via `.numeric` string comparison. Throws `appVersionTooOld(minimumRequired:currentApp:)` if the running app is older. -- **`pluginVersion`** (static property): Semver string for display purposes. Not enforced by the runtime. - -The protocol version will increment when breaking changes are made to `PluginDatabaseDriver` or transfer types. Additive changes (new methods with default implementations) do not require a version bump. diff --git a/docs/development/plugin-system/plugin-manager.md b/docs/development/plugin-system/plugin-manager.md deleted file mode 100644 index 347c9f8e..00000000 --- a/docs/development/plugin-system/plugin-manager.md +++ /dev/null @@ -1,233 +0,0 @@ -# PluginManager - -`PluginManager` is a `@MainActor @Observable` singleton that handles plugin discovery, loading, registration, and lifecycle management. - -**File**: `TablePro/Core/Plugins/PluginManager.swift` - -## Loading Flow - -Called once at app startup via `PluginManager.shared.loadAllPlugins()`. - -```mermaid -graph TD - A[loadAllPlugins] --> B[Ensure ~/Library/.../Plugins/ exists] - B --> C[Scan Contents/PlugIns/ - built-in] - B --> D[Scan ~/Library/.../Plugins/ - user-installed] - C --> E[For each .tableplugin] - D --> E - E --> F{Bundle valid?} - F -->|No| G[Log error, skip] - F -->|Yes| H{PluginKit version OK?} - H -->|Too new| G - H -->|OK| I{Min app version OK?} - I -->|Too old| G - I -->|OK| J{User-installed?} - J -->|Yes| K{Code signature valid?} - K -->|No| G - K -->|Yes| L[Load bundle] - J -->|No| L - L --> M[Cast NSPrincipalClass to TableProPlugin] - M --> N[Create PluginEntry] - N --> O{Plugin enabled?} - O -->|Yes| P[Instantiate + register capabilities] - O -->|No| Q[Store entry only] -``` - -### Step-by-step - -1. **Directory setup**: Creates `~/Library/Application Support/TablePro/Plugins/` if missing. -2. **Scan**: Lists all `.tableplugin` items in both the built-in and user directories. -3. **Bundle creation**: `Bundle(url:)` -- fails if the bundle structure is invalid. -4. **Version check**: Reads `TableProPluginKitVersion` from Info.plist. Rejects if higher than `PluginManager.currentPluginKitVersion` (currently `1`). Throws `incompatibleVersion(required:current:)`. -5. **App version check**: If `TableProMinAppVersion` is set, compares against the running app version using `.numeric` string comparison. Throws `appVersionTooOld(minimumRequired:currentApp:)` with the actual version strings if the app is too old. -6. **Code signature** (user-installed only): Calls `verifyCodeSignature(bundle:)` which throws `PluginError.signatureInvalid(detail:)` on failure. See [Code Signature Verification](#code-signature-verification). -7. **Load executable**: `bundle.load()` -- loads the Mach-O binary into the process. -8. **Principal class**: Casts `bundle.principalClass` to `TableProPlugin.Type`. -9. **Entry creation**: Stores a `PluginEntry` with metadata (name, version, source, capabilities, enabled state). -10. **Registration**: If enabled, instantiates the class and calls `registerCapabilities()`. - -The `loadPlugin(at:source:)` method is `@discardableResult` and returns the `PluginEntry` directly. - -## Registration - -### Driver Plugins - -When a `TableProPlugin` instance also conforms to `DriverPlugin`: - -```swift -let typeId = type(of: driver).databaseTypeId -driverPlugins[typeId] = driver - -for additionalId in type(of: driver).additionalDatabaseTypeIds { - driverPlugins[additionalId] = driver -} -``` - -The `driverPlugins` dictionary maps type ID strings to `DriverPlugin` instances. It is a regular `@MainActor`-isolated property on `PluginManager`. `DatabaseDriverFactory` is also `@MainActor`, ensuring safe access to `driverPlugins` without data races. - -### Unregistration - -When a plugin is disabled, `unregisterCapabilities(pluginId:)` removes **all** entries from `driverPlugins` that were registered by that plugin. This includes both the primary type ID and any additional type IDs: - -```swift -private func unregisterCapabilities(pluginId: String) { - driverPlugins = driverPlugins.filter { _, value in - guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true } - if let principalClass = entry.bundle.principalClass as? any DriverPlugin.Type { - let allTypeIds = Set([principalClass.databaseTypeId] + principalClass.additionalDatabaseTypeIds) - return !allTypeIds.contains(type(of: value).databaseTypeId) - } - return true - } -} -``` - -The `Set` ensures both the primary ID and all `additionalDatabaseTypeIds` are removed in one pass. For example, disabling the MySQL plugin removes both `"MySQL"` and `"MariaDB"` entries. - -## Enable / Disable - -```swift -func setEnabled(_ enabled: Bool, pluginId: String) -``` - -- Updates the `PluginEntry.isEnabled` flag. -- Persists the disabled set to `UserDefaults` under the key `"disabledPlugins"`. -- If enabling: instantiates the plugin class and registers capabilities. -- If disabling: unregisters capabilities (removes from `driverPlugins`). - -Disabled plugins remain in the `plugins` array and their bundles stay loaded in memory. They just have no registered capabilities. - -## Install / Uninstall - -### installPlugin(from:) - -```swift -func installPlugin(from zipURL: URL) async throws -> PluginEntry -``` - -1. Extracts the `.zip` archive to a temp directory using `/usr/bin/ditto`. -2. The `ditto` process runs asynchronously via `withCheckedThrowingContinuation` -- it does not block the calling thread with `waitUntilExit`. -3. Finds the first `.tableplugin` bundle in the extracted contents. -4. Verifies code signature on the extracted bundle. -5. Checks for conflicts with built-in plugins. If a built-in plugin already has the same bundle ID, throws `pluginConflict(existingName:)`. -6. Copies to `~/Library/Application Support/TablePro/Plugins/`. -7. Loads the plugin via `loadPlugin(at:source:)` and returns the entry directly from that call. - -If a plugin with the same filename already exists in the user plugins directory, it is replaced. - -### uninstallPlugin(id:) - -```swift -func uninstallPlugin(id: String) throws -``` - -1. Finds the plugin entry by ID. -2. Rejects if the plugin is built-in (`PluginError.cannotUninstallBuiltIn`). -3. Unregisters capabilities. -4. Calls `bundle.unload()`. -5. Removes the entry from the `plugins` array. -6. Deletes the `.tableplugin` directory from disk. -7. Removes from the disabled plugins set. - -## Code Signature Verification - -User-installed plugins must pass macOS code signature validation pinned to the app's team ID: - -```swift -private func verifyCodeSignature(bundle: Bundle) throws -``` - -Uses Security.framework: - -1. `SecStaticCodeCreateWithPath` to get a `SecStaticCode` reference. Throws `signatureInvalid(detail:)` if this fails. -2. `createSigningRequirement()` builds a `SecRequirement` pinned to the team ID: `anchor apple generic and certificate leaf[subject.OU] = "YOURTEAMID"`. -3. `SecStaticCodeCheckValidity` with `kSecCSCheckAllArchitectures` and the team ID requirement. Throws `signatureInvalid(detail:)` if validation fails. - -The `signingTeamId` static property holds the team identifier (currently a placeholder `"YOURTEAMID"` -- must be replaced before shipping user-installed plugin support). - -Error details come from `describeOSStatus(_:)`, which maps common Security.framework codes to readable strings: - -| OSStatus | Description | -|----------|-------------| -| -67062 | bundle is not signed | -| -67061 | code signature is invalid | -| -67030 | code signature has been modified or corrupted | -| -67013 | signing certificate has expired | -| -67058 | code signature is missing required fields | -| -67028 | resource envelope has been modified | -| other | verification failed (OSStatus N) | - -Built-in plugins skip this check because they are covered by the app's own code signature. - -## Data Model - -### PluginEntry - -```swift -struct PluginEntry: Identifiable { - let id: String // Bundle identifier or filename - let bundle: Bundle - let url: URL - let source: PluginSource // .builtIn or .userInstalled - let name: String - let version: String - let pluginDescription: String - let capabilities: [PluginCapability] - var isEnabled: Bool -} -``` - -### PluginSource - -```swift -enum PluginSource { - case builtIn - case userInstalled -} -``` - -### PluginError - -| Case | When | -|------|------| -| `invalidBundle(String)` | Bundle cannot be created or loaded | -| `signatureInvalid(detail: String)` | Code signature check failed, with human-readable OSStatus description | -| `checksumMismatch` | Future: content hash verification | -| `incompatibleVersion(required:current:)` | PluginKit version too new for the running app | -| `appVersionTooOld(minimumRequired:currentApp:)` | App version is older than the plugin's `TableProMinAppVersion` | -| `pluginConflict(existingName: String)` | User-installed plugin has the same bundle ID as a built-in plugin | -| `cannotUninstallBuiltIn` | Tried to uninstall a built-in plugin | -| `notFound` | Plugin ID not in registry | -| `noCompatibleBinary` | Future: universal binary check | -| `installFailed(String)` | Archive extraction or copy failed | - -## PluginDriverAdapter - -**File**: `TablePro/Core/Plugins/PluginDriverAdapter.swift` - -Bridges `PluginDatabaseDriver` (plugin side) to `DatabaseDriver` (app side). This is where transfer types are mapped: - -- `PluginQueryResult` -> `QueryResult` (includes column type mapping from raw type name strings to `ColumnType` enum) -- `PluginColumnInfo` -> `ColumnInfo` -- `PluginIndexInfo` -> `IndexInfo` -- `PluginForeignKeyInfo` -> `ForeignKeyInfo` -- `PluginTableInfo` -> `TableInfo` -- `PluginTableMetadata` -> `TableMetadata` -- `PluginDatabaseMetadata` -> `DatabaseMetadata` - -The adapter also conforms to `SchemaSwitchable` and delegates `switchSchema(to:)` / `switchDatabase(to:)` to the plugin driver. - -### Column Type Mapping - -`PluginDriverAdapter.mapColumnType(rawTypeName:)` converts raw type name strings from plugins into the app's `ColumnType` enum. The mapping handles common SQL type names: - -- `BOOL*` -> `.boolean` -- `INT`, `INTEGER`, `BIGINT`, etc. -> `.integer` -- `FLOAT`, `DOUBLE`, `DECIMAL`, etc. -> `.decimal` -- `DATE` -> `.date` -- `TIMESTAMP*` -> `.timestamp` -- `JSON`, `JSONB` -> `.json` -- `BLOB`, `BYTEA`, `BINARY` -> `.blob` -- `ENUM*` -> `.enumType` -- `GEOMETRY`, `POINT`, etc. -> `.spatial` -- Everything else -> `.text` diff --git a/docs/development/plugin-system/roadmap.md b/docs/development/plugin-system/roadmap.md deleted file mode 100644 index 86b4d5fa..00000000 --- a/docs/development/plugin-system/roadmap.md +++ /dev/null @@ -1,143 +0,0 @@ -# Plugin System Roadmap - -## Phase 0: Foundation - COMPLETE - -Laid the groundwork for the plugin system. - -**Delivered:** -- `TableProPluginKit` shared framework with all protocols and transfer types -- `PluginManager` singleton with bundle loading, version checking, code signature verification -- `PluginDriverAdapter` bridging plugin drivers to the app's `DatabaseDriver` protocol -- `PluginEntry`, `PluginSource`, `PluginError` data model -- Oracle driver as proof-of-concept plugin (OCI stub) - -## Phase 1: Built-in Plugins - COMPLETE - -Extracted all database drivers from the main app into plugin bundles. - -**Delivered:** -- 8 driver plugins: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, MongoDB, Redis, Oracle -- Multi-type support: MySQL handles MariaDB, PostgreSQL handles Redshift -- Custom connection fields (SQL Server schema field) -- `DatabaseManager` factory simplified to plugin lookup -- Removed all direct driver imports from main app target -- C bridge headers moved into respective plugin bundles (CMariaDB, CLibPQ, CFreeTDS, CLibMongoc, CRedis, COracle) - -## Phase 2: Sideload - COMPLETE - -Users can install third-party plugins from `.zip` files via Settings. - -**Delivered:** -- Settings > Plugins tab (`PluginsSettingsView.swift`) with installed plugin list, enable/disable toggles, detail view -- "Install from File..." flow: `NSOpenPanel` -> zip extraction via `ditto` (async, non-blocking) -> code signature verification -> bundle loading -- Uninstall for user-installed plugins with confirmation dialog -- Team-pinned code signature verification using `SecRequirement` (not just any valid cert) -- Detailed error reporting: `signatureInvalid(detail:)`, `pluginConflict`, `appVersionTooOld` -- SwiftUI `.alert`-based error presentation (replaced unreliable `NSApp.keyWindow`) -- `DatabaseDriverFactory.createDriver` now throws instead of `fatalError` -- graceful error when plugin missing -- Thread-safe `driverPlugins` access (removed `nonisolated(unsafe)`, made factory `@MainActor`) -- Plugin tests: `PluginModelsTests` (7 tests), `ExportServiceStateTests` rewritten with `StubDriver` mock - -## Phase 3: Marketplace - COMPLETE - -GitHub-based plugin registry with in-app discovery. - -**Delivered:** -- RegistryClient fetches flat JSON manifest from `datlechin/tablepro-plugins` GitHub repo -- ETag/If-None-Match caching with UserDefaults offline fallback -- Browse tab in Settings > Plugins with search bar and category filter chips -- One-click install from registry: streaming download with progress, SHA-256 checksum verification, delegates to existing installPlugin(from:) -- PluginInstallTracker for per-plugin download/install state (downloading, installing, completed, failed) -- RegistryPluginRow with verified badge, author info, and contextual Install/Installed/Retry button -- RegistryPluginDetailView with expandable summary, category, compatibility info, and homepage link -- New error types: downloadFailed, incompatibleWithCurrentApp - -## Phase 4: Auto-Updates - -Version checking and update notifications for installed plugins. - -**Scope:** -- Periodic version check against the registry -- Badge on Settings > Plugins when updates are available -- One-click update (download + replace bundle) -- Update tab showing changelog diff -- Opt-out per plugin - -**Dependencies:** Phase 3 (requires registry with version metadata). - -## Phase 5: Export Plugins - COMPLETE - -Extracted all 5 built-in export formats into plugin bundles. - -**Delivered:** -- `ExportFormatPlugin` protocol in TableProPluginKit with `formatId`, `export()`, `optionsView()`, `perTableOptionColumns` -- `PluginExportDataSource` protocol bridging `DatabaseDriver` for plugin data access -- `PluginExportProgress` thread-safe progress reporter with cancellation and UI throttling -- `PluginExportUtilities` shared helpers (JSON escaping, SQL comment sanitization, UTF-8 encoding) -- 5 export plugin bundles: CSVExport, JSONExport, SQLExport, XLSXExport, MQLExport -- Each plugin provides its own SwiftUI options view via `optionsView() -> AnyView?` -- Generic per-table option columns (SQL: Structure/Drop/Data, MQL: Drop/Indexes/Data) -- Dynamic format picker in Export dialog, filtered by database type compatibility -- `ExportDataSourceAdapter` bridges `DatabaseDriver` to `PluginExportDataSource` -- `ExportService` simplified to thin orchestrator delegating to plugins -- Removed 11 format-specific files from main app (5 ExportService extensions, XLSXWriter, 5 options views) - -### Theme Plugins (Future) - -**Scope:** -- JSON-based theme definitions (no executable code) -- Colors for editor, data grid, sidebar, toolbar -- Font overrides -- Stored in `~/Library/Application Support/TablePro/Themes/` -- Theme picker in Settings > Editor -- Shareable as single `.json` files - -## Phase 6: Cell Renderers + Developer Portal - -### Cell Renderers (Tier 1-2) - -**Scope:** -- Custom rendering for specific column types (e.g., image preview, map view, color swatch) -- Tier 1: JSON config mapping column type patterns to built-in renderers -- Tier 2: JavaScript-based custom renderers in a `JSContext` - -### Developer Portal - -**Scope:** -- Documentation site for plugin developers -- Plugin submission and review workflow -- Code signing certificate distribution -- SDK download with Xcode project templates -- Example plugins repository - -## Known Limitations / Tech Debt - -Issues identified during Phase 2 implementation that should be addressed in future phases: - -1. **Team ID placeholder**: `PluginManager.signingTeamId` is set to `"YOURTEAMID"` -- must be replaced with the actual Apple Developer Team ID before shipping sideloaded plugin support to users. - -2. **`Bundle.unload()` unreliability**: macOS `Bundle.unload()` is not guaranteed to actually unload code. Disabled/uninstalled plugins may leave code in memory until app restart. - -3. **No hot-reload**: Enabling a previously disabled plugin re-instantiates the class but doesn't reconnect existing sessions using that driver. - -4. **`executeParameterized` default is SQL injection-adjacent**: The default implementation does string replacement of `?` placeholders, which relies on single-quote escaping. Drivers should override with native prepared statements. - -5. **`PluginDriverAdapter.beginTransaction` uses hardcoded SQL**: Sends `BEGIN` regardless of database type. Drivers that need different transaction syntax (e.g., Oracle's implicit transactions) must handle this at the plugin level. - -6. **No plugin dependency resolution**: Plugins cannot declare dependencies on other plugins. Each plugin must be self-contained. - -7. **Single-zip install format**: Only `.zip` archives supported. No support for direct `.tableplugin` bundle drag-and-drop. - -## Timeline - -| Phase | Status | Dependencies | -|-------|--------|-------------| -| 0: Foundation | Done | -- | -| 1: Built-in Plugins | Done | Phase 0 | -| 2: Sideload | **Done** | Phase 1 | -| 3: Marketplace | **Done** | Phase 2 | -| 4: Auto-Updates | Next | Phase 3 | -| 5: Export Plugins | **Done** | Phase 2 | -| 6: Renderers + Portal | Planned | Phase 3, 5 | - -Phases 5 and 3 can proceed in parallel now that Phase 2 is complete. Phase 5 (themes specifically) has no dependency on Phase 3 since themes are local files. diff --git a/docs/development/plugin-system/security.md b/docs/development/plugin-system/security.md deleted file mode 100644 index 32dfc0fa..00000000 --- a/docs/development/plugin-system/security.md +++ /dev/null @@ -1,154 +0,0 @@ -# Plugin Security Model - -TablePro plugins are native macOS bundles (`.tableplugin`) loaded into the app process at runtime. This means plugins run with the same privileges as the app itself. This document describes the security mechanisms in place, what they protect against, and what they don't. - -## Code Signing - -### Built-in plugins - -Plugins shipped inside the app bundle (in `Contents/PlugIns/`) are covered by the app's own code signature. macOS validates the app signature at launch, which transitively covers everything inside the bundle. No separate signature check is performed by `PluginManager`. - -### User-installed plugins - -Plugins installed to `~/Library/Application Support/TablePro/Plugins/` go through explicit code signature verification before loading. The flow: - -1. `SecStaticCodeCreateWithPath` creates a `SecStaticCode` reference from the plugin bundle URL. -2. `createSigningRequirement()` builds a `SecRequirement` string: - ``` - anchor apple generic and certificate leaf[subject.OU] = "TEAMID" - ``` -3. `SecStaticCodeCheckValidity` validates the bundle against this requirement, using the `kSecCSCheckAllArchitectures` flag to verify all architecture slices (arm64 + x86_64). - -If verification fails, the plugin is rejected with a `PluginError.signatureInvalid` error. The user never gets the option to override this. - -### Team ID pinning - -The `signingTeamId` static property on `PluginManager` determines which Apple Developer Team ID is accepted. The requirement string pins to the leaf certificate's Organizational Unit (`subject.OU`), meaning only plugins signed by that specific team are accepted. - -**Current status**: `signingTeamId` is set to the placeholder `"YOURTEAMID"`. This must be replaced with a real team identifier before user-installed plugin support ships. - -### OSStatus error codes - -When signature verification fails, `describeOSStatus()` maps Security framework codes to human-readable messages: - -| OSStatus | Meaning | -|----------|---------| -| -67062 | Bundle is not signed | -| -67061 | Code signature is invalid | -| -67030 | Code signature has been modified or corrupted | -| -67013 | Signing certificate has expired | -| -67058 | Code signature is missing required fields | -| -67028 | Resource envelope has been modified | - -Any other status code falls through to a generic "verification failed (OSStatus N)" message. - -## Trust Levels - -Plugins fall into four trust tiers based on how they were distributed: - -| Level | Source | Signature check | What it means | -|-------|--------|----------------|---------------| -| **Built-in** | Shipped inside app bundle | App signature covers it | First-party code, maintained by the TablePro team | -| **Verified** | Downloaded from official marketplace | Team ID pinned signature check | Third-party code reviewed and signed by the TablePro team | -| **Community** | Downloaded from marketplace, signed by author | Author's Developer ID check | Third-party code signed by its developer, not reviewed by TablePro | -| **Sideloaded** | Manually placed in plugins directory | Team ID pinned signature check | Must still pass signature verification to load | - -Built-in plugins cannot be uninstalled (`PluginError.cannotUninstallBuiltIn`). User-installed plugins can be enabled, disabled, or removed at any time. - -## Threat Model - -### What a malicious plugin CAN do - -Plugins are native Mach-O bundles loaded via `NSBundle.load()` into the app's address space. Once loaded, a plugin has: - -- **Full process access**: arbitrary Swift/ObjC code execution in the same process. A plugin can call any framework, swizzle methods, read process memory. -- **File system access**: read and write any file the app can access. -- **Network access**: open arbitrary network connections, send data anywhere. -- **Keychain access**: read Keychain items available to the app (connection passwords are stored in Keychain via `ConnectionStorage`). -- **Connection credentials**: plugins receive `DriverConnectionConfig` with plaintext host, port, username, password, and database name. - -### What mitigations exist today - -- **Code signature verification**: user-installed plugins must be signed with a specific Apple Developer ID. An unsigned or tampered bundle is rejected before `NSBundle.load()` is called. -- **Team ID pinning**: only plugins signed by the pinned team ID are accepted. A valid Apple Developer ID from a different team is rejected. -- **All-architectures check**: `kSecCSCheckAllArchitectures` prevents attacks that target only one architecture slice. -- **Conflict detection**: a user-installed plugin cannot shadow a built-in plugin's bundle ID (`PluginError.pluginConflict`). -- **User must explicitly install**: plugins don't auto-download. The user initiates installation from a `.zip` archive. -- **Version gating**: `TableProPluginKitVersion` and `TableProMinAppVersion` in Info.plist prevent loading plugins built against incompatible SDK versions. - -### What mitigations are planned - -- **Marketplace review process**: reviewing plugin code and behavior before listing in an official marketplace. -- **Runtime sandboxing**: restricting plugin capabilities using macOS sandbox profiles or XPC (future work). -- **Capability declarations**: enforcing that a plugin declaring only `.databaseDriver` cannot access export or AI APIs. - -### What the system does NOT protect against - -- A validly-signed plugin from a **compromised developer account**. If an attacker obtains the signing key, they can produce plugins that pass verification. -- **Supply chain attacks** on plugin dependencies. A plugin linking against a compromised C library will pass signature checks if the final bundle is signed. -- **Runtime misbehavior** by a signed plugin. Once loaded, there is no monitoring of what the plugin code actually does. -- A plugin that **exfiltrates connection credentials** it legitimately receives via `DriverConnectionConfig`. - -## Plugin Capabilities and Access - -The `PluginCapability` enum declares what a plugin intends to provide: - -```swift -public enum PluginCapability: Int, Codable, Sendable { - case databaseDriver - case exportFormat - case importFormat - case sqlDialect - case aiProvider - case cellRenderer - case sidebarPanel -} -``` - -These are currently **declarations only**, not enforced restrictions. A plugin declaring `.databaseDriver` has the same runtime access as one declaring `.aiProvider`. There is no sandbox boundary between capability types. - -Database driver plugins receive connection credentials via `DriverConnectionConfig`: - -```swift -public struct DriverConnectionConfig: Sendable { - public let host: String - public let port: Int - public let username: String - public let password: String - public let database: String - public let additionalFields: [String: String] -} -``` - -The password is passed in plaintext. This is necessary for the plugin to establish a database connection, but it means any loaded plugin has access to the credentials. - -## Bundle Integrity - -Several checks run before a plugin's executable code is loaded: - -1. **NSBundle creation**: `Bundle(url:)` validates basic bundle structure (correct directory layout, Info.plist present). -2. **PluginKit version check**: `TableProPluginKitVersion` in Info.plist must be less than or equal to `PluginManager.currentPluginKitVersion`. A plugin built against a newer SDK is rejected with `PluginError.incompatibleVersion`. -3. **App version check**: if `TableProMinAppVersion` is set in Info.plist, the current app version is compared. If the app is older, loading fails with `PluginError.appVersionTooOld`. -4. **Code signature verification** (user-installed only): as described above. -5. **Principal class check**: `bundle.principalClass` must conform to `TableProPlugin`. If not, the plugin is rejected with `PluginError.invalidBundle`. -6. **Architecture verification**: `kSecCSCheckAllArchitectures` ensures all Mach-O slices in a Universal Binary are validly signed. - -During installation (via `installPlugin(from:)`), the signature is verified on the extracted bundle *before* copying it to the user plugins directory. A plugin that fails verification is never persisted. - -## Recommendations for Plugin Developers - -- **Always code-sign** with a valid Apple Developer ID certificate. Unsigned plugins will be rejected. -- **Build as Universal Binary** (arm64 + x86_64) to work on both Apple Silicon and Intel Macs. The `kSecCSCheckAllArchitectures` flag validates all slices. -- **Don't store secrets in the plugin bundle**. Anything inside the `.tableplugin` directory is readable by anyone with file access. -- **Use HTTPS for all network connections**. Database protocols that don't support TLS should document this clearly. -- **Minimize dependencies** to reduce attack surface. Every linked library is a potential vulnerability. -- **Set `TableProPluginKitVersion` and `TableProMinAppVersion`** in your Info.plist to prevent your plugin from loading in incompatible environments. -- **Use a unique bundle identifier**. Your plugin cannot share a bundle ID with a built-in plugin. - -## Known Limitations - -- **`signingTeamId` is a placeholder**: set to `"YOURTEAMID"`. Must be replaced with the actual Apple Developer Team ID before sideloaded plugin support ships. Until then, no user-installed plugins will pass verification. -- **`Bundle.unload()` is unreliable on macOS**: when a user-installed plugin is uninstalled, `PluginManager` calls `bundle.unload()`, but Apple's documentation notes this may not actually unload the code. The plugin's executable code may remain mapped in memory until the app restarts. -- **No runtime sandboxing**: plugins run in the same process with the same entitlements as the app. There is no XPC boundary, no sandbox profile, no capability enforcement. -- **No capability restrictions**: the `PluginCapability` enum is advisory. A plugin declaring only `.databaseDriver` has the same runtime access as the host app. -- **Credentials in plaintext**: `DriverConnectionConfig` passes database passwords as plain `String` values. There is no way for the app to restrict which plugins see which credentials. diff --git a/docs/development/plugin-system/troubleshooting.md b/docs/development/plugin-system/troubleshooting.md deleted file mode 100644 index 8f3b54ce..00000000 --- a/docs/development/plugin-system/troubleshooting.md +++ /dev/null @@ -1,235 +0,0 @@ -# Plugin Troubleshooting Guide - -Common issues when developing and installing TablePro plugins, with solutions derived from the actual error paths in `PluginManager` and `PluginError`. - ---- - -## Bundle Won't Load - -### "Cannot create bundle from X" - -`Bundle(url:)` returned `nil`. The `.tableplugin` directory structure is wrong. - -**Fix:** -- Verify your bundle has the correct layout: - ``` - MyPlugin.tableplugin/ - Contents/ - Info.plist - MacOS/ - MyPlugin ← compiled binary - ``` -- Check that `Info.plist` contains `NSPrincipalClass` pointing to your plugin class. -- The bundle must have a `.tableplugin` extension. `PluginManager` skips anything else. - -### "Bundle failed to load executable" - -`bundle.load()` returned `false`. The binary exists but macOS refused to load it. - -**Fix:** -- **Architecture mismatch.** If you built arm64-only and the user runs on Intel (or vice versa), the load fails silently. Build as Universal Binary: `lipo -create arm64/MyPlugin x86_64/MyPlugin -output MyPlugin`. -- **Missing linked frameworks.** Your plugin must link against `TableProPluginKit.framework`, not embed it. If the framework isn't found at load time, `dlopen` fails. -- **Check Console.app** for `dyld` errors. Filter by your plugin name. Common causes: missing `@rpath`, unresolved symbols, wrong install name. -- Run `file MyPlugin.tableplugin/Contents/MacOS/MyPlugin` to confirm the binary contains both architectures. - -### "Principal class does not conform to TableProPlugin" - -`bundle.principalClass` loaded, but the cast to `TableProPlugin.Type` failed. - -**Fix:** -- Your principal class must subclass `NSObject` AND conform to `TableProPlugin`. -- The `NSPrincipalClass` value in `Info.plist` must match the class name exactly. For Swift classes, use the unqualified name (no module prefix) if the class is `@objc`-compatible. -- If your class is pure Swift without `@objc`, you need the module-qualified name: `MyPluginModule.MyPluginClass`. - ---- - -## Signature Verification Fails - -Signature checks only apply to user-installed plugins (under `~/Library/Application Support/TablePro/Plugins/`). Built-in plugins bundled with the app skip this check. - -### "bundle is not signed" (OSStatus -67062) - -The plugin has no code signature at all. - -**Fix:** -- Sign with a valid Apple Developer ID: `codesign --sign "Developer ID Application: Your Name (TEAMID)" --deep MyPlugin.tableplugin` -- Ad-hoc signatures (`codesign -s -`) are not accepted for user-installed plugins. - -### "code signature is invalid" (OSStatus -67061) - -A signature exists but doesn't validate. - -**Fix:** -- Re-sign the bundle. This often happens when the binary was modified after signing (e.g., `install_name_tool` or `strip` ran post-signing). -- Verify with: `codesign -v --deep --strict MyPlugin.tableplugin` - -### "code signature has been modified or corrupted" (OSStatus -67030) - -Files in the bundle changed after signing. - -**Fix:** -- Do all modifications (adding resources, fixing rpaths) *before* signing. -- Re-sign the entire bundle after any change. - -### "signing certificate has expired" (OSStatus -67013) - -**Fix:** -- Renew your Apple Developer certificate at developer.apple.com, download the new cert, and re-sign. - -### "resource envelope has been modified" (OSStatus -67028) - -A file in `Contents/Resources/` was added, removed, or changed after signing. - -**Fix:** -- Finalize all resources before signing. Re-sign if you need to change anything. - -### "code signature is missing required fields" (OSStatus -67058) - -The signature exists but is incomplete. - -**Fix:** -- Re-sign with `--deep` to ensure all nested code is signed. -- Check that your signing identity is a full Developer ID, not a self-signed cert. - -### Team ID mismatch - -The signature is valid, but the Team Identifier doesn't match what `PluginManager` expects. The app checks `certificate leaf[subject.OU]` against a configured team ID. - -**Fix:** -- Check your plugin's team ID: `codesign -dvvv MyPlugin.tableplugin 2>&1 | grep TeamIdentifier` -- The team ID must match `PluginManager.signingTeamId`. Contact the TablePro team if you need your team ID allowlisted. - ---- - -## Version Compatibility - -### "Plugin requires PluginKit version X, but app provides version Y" - -Your plugin's `TableProPluginKitVersion` in `Info.plist` is higher than `PluginManager.currentPluginKitVersion` (currently `1`). - -**Fix:** -- Rebuild your plugin against the version of `TableProPluginKit` that ships with the target app version. -- If you set `TableProPluginKitVersion` manually, lower it to match. But only do this if your plugin genuinely doesn't use newer API. - -### "Plugin requires app version X or later, but current version is Y" - -Your `TableProMinAppVersion` in `Info.plist` is newer than the running app. - -**Fix:** -- Update the app to the required version, or lower `TableProMinAppVersion` in your plugin's `Info.plist` if the dependency isn't real. -- Version comparison uses `.numeric` ordering, so `1.10.0` > `1.9.0`. - ---- - -## Plugin Conflicts - -### "A built-in plugin 'X' already provides this bundle ID" - -Your plugin's `CFBundleIdentifier` collides with a built-in plugin. The app blocks user-installed plugins from overriding built-in ones. - -**Fix:** -- Change your `CFBundleIdentifier` to something unique (e.g., `com.yourcompany.tablepro.myplugin`). -- You cannot replace built-in drivers (MySQL, PostgreSQL, SQLite, ClickHouse, MSSQL, MongoDB, Redis, Oracle) with user-installed plugins. - ---- - -## Installation Issues - -### "No .tableplugin bundle found in archive" - -The ZIP file doesn't contain a `.tableplugin` bundle at the top level. - -**Fix:** -- Package your plugin so the ZIP extracts to `MyPlugin.tableplugin/` directly, not nested inside another folder. -- The installer uses `ditto -xk` and looks for the first item with `.tableplugin` extension in the extracted directory. - -### "Failed to extract archive (ditto exit code N)" - -The ZIP file is corrupted or not a valid ZIP. - -**Fix:** -- Re-create the archive. Use `ditto -ck --keepParent MyPlugin.tableplugin MyPlugin.zip` for best compatibility. - ---- - -## Runtime Issues - -### Plugin loads but database type doesn't appear - -The plugin loaded successfully (check Console.app for "Loaded plugin" log), but the database type isn't available in the connection dialog. - -**Fix:** -- Verify `databaseTypeId` on your `DriverPlugin` matches a `DatabaseType` case that the app knows about. For custom database types, the app must explicitly support the type in its enum. -- Check that your plugin declares `.databaseDriver` in its `capabilities` array. -- If your plugin serves multiple database types, implement `additionalDatabaseTypeIds` on your `DriverPlugin` conformance. -- Make sure the plugin isn't disabled. Check `UserDefaults` key `disabledPlugins` or the Plugins preference pane. - -### Connection fails immediately after plugin loads - -`createDriver(config:)` returns a driver, but `connect()` throws. - -**Fix:** -- Check Console.app filtered to the "PluginDriverAdapter" category. The adapter logs connection errors. -- Verify your `PluginDatabaseDriver.connect()` implementation handles the connection config correctly (host, port, credentials, SSL settings). -- The `PluginDriverAdapter` sets status to `.error(message)` on failure. The error message propagates to the UI. - -### Plugin is disabled and won't re-enable - -**Fix:** -- The disable state is stored in `UserDefaults` under the `disabledPlugins` key as a string array of bundle IDs. -- To manually clear: `defaults delete com.TablePro disabledPlugins` (or remove your specific bundle ID from the array). - ---- - -## SourceKit / Xcode Indexing Noise - -### "No such module 'TableProPluginKit'" - -This is an Xcode indexing issue, not a real build error. SourceKit sometimes can't resolve cross-target module imports. - -**Fix:** -- Build with `xcodebuild` to confirm the project compiles. -- Clean derived data if it persists: `rm -rf ~/Library/Developer/Xcode/DerivedData/TablePro-*` -- The project uses `objectVersion 77` (PBXFileSystemSynchronizedRootGroup), which can confuse older Xcode indexing. - -### "Cannot find type 'PluginQueryResult' in scope" - -Same indexing noise. Types from `TableProPluginKit` (like `PluginQueryResult`, `PluginColumnInfo`, `PluginTableInfo`) sometimes aren't visible to SourceKit in plugin targets. - -**Fix:** -- Build with `xcodebuild` to verify. If it builds, ignore the SourceKit errors. - ---- - -## Testing - -### Can't load plugin bundles in unit tests - -Plugin bundles require the full app context: framework search paths, code signing, runtime bundle loading. The test runner doesn't provide this. - -**Fix:** -- Don't call `PluginManager.loadAllPlugins()` in tests. Plugin bundles aren't available in the test runner. -- Use `StubDriver` mocks that implement `DatabaseDriver` protocol directly. -- To test plugin source code, add the plugin's Swift files to the test target (inline-copy pattern) so you can test logic without bundle loading. - -### DatabaseDriverFactory.createDriver throws in tests - -The factory throws when a plugin isn't loaded (it no longer calls `fatalError`). - -**Fix:** -- Tests should not go through `DatabaseDriverFactory`. Use `StubDriver` mocks instead. -- If you must test factory behavior, mock the `PluginManager.driverPlugins` dictionary. - ---- - -## Debugging Tips - -- **Console.app**: Filter by subsystem `com.TablePro`. Two categories matter: - - `PluginManager` - load, register, enable, disable events - - `PluginDriverAdapter` - adapter-level errors during query execution and schema operations -- **Code signature inspection**: `codesign -dvvv MyPlugin.tableplugin 2>&1` shows signing identity, team ID, entitlements, and flags. -- **Binary architecture**: `file MyPlugin.tableplugin/Contents/MacOS/MyPlugin` shows which architectures the binary contains. -- **dyld debugging**: Set `DYLD_PRINT_LIBRARIES=1` in Xcode scheme environment variables to see all library loads at launch. -- **Plugin directories**: - - Built-in: `TablePro.app/Contents/PlugIns/` - - User-installed: `~/Library/Application Support/TablePro/Plugins/` diff --git a/docs/development/plugin-system/ui-design.md b/docs/development/plugin-system/ui-design.md deleted file mode 100644 index 4f10203c..00000000 --- a/docs/development/plugin-system/ui-design.md +++ /dev/null @@ -1,216 +0,0 @@ -# UI Design: Plugin Management - -The Settings > Plugins tab is implemented and shipping. Users can view all loaded plugins, enable/disable them, and install third-party plugins from `.zip` files. - -## Settings > Plugins Tab - -### Installed Plugins List - -``` -+---------------------------------------------------------------+ -| Settings | -|---------------------------------------------------------------| -| General | Editor | Plugins | ... | -|---------------------------------------------------------------| -| | -| Installed Plugins | -| | -| +-----------------------------------------------------------+| -| | [icon] MySQL Driver v1.0.0 Built-in [ON] || -| | [icon] PostgreSQL Driver v1.0.0 Built-in [ON] || -| | [icon] SQLite Driver v1.0.0 Built-in [ON] || -| | [icon] SQL Server Driver v1.0.0 Built-in [ON] || -| | [icon] ClickHouse Driver v1.0.0 Built-in [ON] || -| | [icon] MongoDB Driver v1.0.0 Built-in [ON] || -| | [icon] Redis Driver v1.0.0 Built-in [ON] || -| | [icon] Oracle Driver v1.0.0 Built-in [ON] || -| +-----------------------------------------------------------+| -| | -| [Install from File...] | -| | -+---------------------------------------------------------------+ -``` - -Each row displays: -- SF Symbol icon from `DriverPlugin.iconName` -- Plugin name and version string -- Source badge: blue "Built-in" for bundled plugins, green "User" for user-installed plugins -- Enable/disable Toggle - -Clicking a row expands or collapses an inline detail section below it. - -The "Install from File..." button opens an `NSOpenPanel` filtered to `.zip` files. A progress indicator is shown during extraction and validation. - -### Plugin Detail Section - -Clicking a plugin row expands a detail section inline: - -``` -+---------------------------------------------------------------+ -| MySQL Driver | -|---------------------------------------------------------------| -| | -| Version: 1.0.0 | -| Bundle ID: com.TablePro.MySQLDriverPlugin | -| Source: Built-in | -| | -| Capabilities: Database Driver | -| Database Type: MySQL | -| Also handles: MariaDB | -| Default Port: 3306 | -| | -| Description: | -| MySQL/MariaDB support via libmariadb | -| | -| [Uninstall] (user only) | -| | -+---------------------------------------------------------------+ -``` - -Implementation details: -- Uses a SwiftUI `Form` with `.grouped` style. -- Fields shown: Version, Bundle ID, Source, Capabilities, Database Type, Also handles (if `additionalDatabaseTypeIds` is non-empty), Default Port, Description. -- The Uninstall button only appears for user-installed plugins. Clicking it shows a confirmation dialog before removal. -- Built-in plugins cannot be uninstalled; they can only be disabled via the toggle in the list row. - -### Disabled State - -When a plugin is disabled: -- The toggle shows OFF. -- The row appears dimmed. -- The database type is no longer available in the connection dialog. -- Existing connections using that type show an error on next connect attempt. - -## Connection Dialog: Dynamic Fields - -When creating a new connection, the dialog renders fields based on the selected driver plugin. - -### Standard Fields (always shown) - -``` -+-----------------------------------------------+ -| New Connection | -|-----------------------------------------------| -| Type: [MySQL v] | -| Name: [ ] | -| Host: [localhost ] | -| Port: [3306 ] | -| Username: [root ] | -| Password: [******** ] | -| Database: [mydb ] | -+-----------------------------------------------+ -``` - -### With Additional Fields (e.g., SQL Server) - -``` -+-----------------------------------------------+ -| New Connection | -|-----------------------------------------------| -| Type: [SQL Server v] | -| Name: [ ] | -| Host: [localhost ] | -| Port: [1433 ] | -| Username: [sa ] | -| Password: [******** ] | -| Database: [master ] | -| | -| --- Driver-specific --- | -| Schema: [dbo ] | -+-----------------------------------------------+ -``` - -The "Driver-specific" section is generated from `DriverPlugin.additionalConnectionFields`. Each `ConnectionField` maps to a text field (or secure text field if `isSecure` is true). Required fields show validation errors if left empty. - -## Error Handling - -Errors during plugin install and uninstall are handled with native SwiftUI and AppKit alerts: - -- **Install errors**: Shown via a SwiftUI `.alert` modifier on the settings view. Error cases include: - - Invalid bundle (missing `NSPrincipalClass`, wrong extension, no `DriverPlugin` conformance) - - Signature verification failed - - Plugin conflict (a plugin with the same bundle ID is already installed) - - App version too old (`TableProMinAppVersion` exceeds current app version) -- **Uninstall confirmation**: Uses `AlertHelper.confirmDestructive` to show a confirmation dialog before removing a user-installed plugin. -- **Runtime load failures**: Logged via OSLog. If a plugin fails to load at startup, it is skipped and the remaining plugins continue loading. - -## Browse Tab (Phase 3) - -The Plugins settings pane uses a segmented picker to switch between Installed and Browse sub-tabs. - -``` -+---------------------------------------------------------------+ -| Settings | -|---------------------------------------------------------------| -| General | Editor | Plugins | ... | -|---------------------------------------------------------------| -| | -| [ Installed | Browse ] (segmented picker) | -| | -+---------------------------------------------------------------+ -``` - -When "Browse" is selected, the view shows the registry contents fetched from GitHub. - -### Browse Tab Layout - -``` -+---------------------------------------------------------------+ -| [Search plugins... ] | -| | -| [All] [Database Drivers] [Export Formats] [Themes] | -| | -| +-----------------------------------------------------------+| -| | [icon] CockroachDB Driver ✓ v0.1.0 by dev [Install]|| -| | [icon] DuckDB Driver v0.2.0 by dev [Install] || -| | [icon] Parquet Export ✓ v1.0.0 by dev [Installed] || -| +-----------------------------------------------------------+| -| | -+---------------------------------------------------------------+ -``` - -- Search bar filters the plugin list by name and description (local client-side filtering). -- Category filter chips sit below the search bar. Tapping a chip filters the list to that category. "All" shows everything. -- The plugin list scrolls vertically. Each entry is a `RegistryPluginRow`. - -### RegistryPluginRow - -Each row displays: -- SF Symbol icon from the registry manifest's `iconName` field -- Plugin name, with a checkmark badge inline if the plugin has verified trust level -- Version string and author name (e.g., "v0.1.0 by dev") -- Action button on the trailing edge (see install flow states below) - -Clicking a row expands a `RegistryPluginDetailView` inline below it, showing: -- Description text (multi-line, truncated with a "Show More" toggle if longer than 3 lines) -- Category label -- Compatibility info (minimum app version required) -- Homepage link (opens in default browser) - -### Install Flow States - -The action button on each row transitions through these states: - -| State | Button | Behavior | -|-------|--------|----------| -| Not installed | "Install" (blue) | Starts streaming download from the registry URL | -| Downloading | Progress bar with percentage | `PluginInstallTracker` updates progress; cancellable | -| Installing | Spinner with "Installing..." | Zip extraction, signature check, bundle load via `installPlugin(from:)` | -| Completed | "Installed" (gray, disabled) | Plugin now appears in the Installed tab | -| Failed | "Retry" (red) | Resets to downloading state on tap | - -`PluginInstallTracker` holds per-plugin state keyed by bundle ID. It publishes state changes so the row updates reactively. - -Download and install steps: -1. Streaming download with `URLSession` data task, tracking bytes received vs. expected content length. -2. SHA-256 checksum of the downloaded zip verified against the manifest's `checksum` field. -3. On checksum match, delegates to the existing `installPlugin(from:)` path (zip extraction, code signature verification, bundle loading). -4. On failure, the row shows the error inline and switches to the Retry state. - -### Error, Loading, and Empty States - -- **Loading**: A `ProgressView` spinner centered in the Browse tab while the initial registry fetch is in progress. -- **Fetch error**: A centered message with the error description and a "Try Again" button that re-triggers `RegistryClient.fetchManifest()`. -- **Offline fallback**: If the network request fails but a cached manifest exists in UserDefaults, the cached data is shown with a subtle "Showing cached data" label below the search bar. -- **Empty search results**: "No plugins match your search." text centered in the list area. -- **Incompatible plugin**: If a plugin's `minAppVersion` exceeds the current app version, the Install button is replaced with "Requires vX.Y.Z" in gray text. The detail view explains the version requirement. From dc727a210962038be2a63559ab57f3e60e892e36 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 09:54:48 +0700 Subject: [PATCH 3/5] fix: address review issues in export format plugins - Remove duplicate finalizeTable() calls from CSV/SQL/JSON/MQL helpers - Add bundle: .main to String(localized:) in plugin option views - Clear optionValues on format switch to prevent MQL/SQL column mismatch - Add progress update coalescing to prevent main actor flooding - Extract createFileHandle to PluginExportUtilities (DRY) --- .../CSVExportOptionsView.swift | 8 +-- Plugins/CSVExportPlugin/CSVExportPlugin.swift | 10 +--- .../JSONExportPlugin/JSONExportPlugin.swift | 10 +--- Plugins/MQLExportPlugin/MQLExportPlugin.swift | 10 +--- .../SQLExportOptionsView.swift | 2 +- Plugins/SQLExportPlugin/SQLExportPlugin.swift | 11 +--- .../PluginExportUtilities.swift | 7 +++ .../Core/Services/Export/ExportService.swift | 51 +++++++++++++++---- TablePro/Views/Export/ExportDialog.swift | 11 ++++ 9 files changed, 67 insertions(+), 53 deletions(-) diff --git a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift index dbbe41dd..120a10b9 100644 --- a/Plugins/CSVExportPlugin/CSVExportOptionsView.swift +++ b/Plugins/CSVExportPlugin/CSVExportOptionsView.swift @@ -29,7 +29,7 @@ struct CSVExportOptionsView: View { .padding(.vertical, 4) VStack(alignment: .leading, spacing: 10) { - optionRow(String(localized: "Delimiter")) { + optionRow(String(localized: "Delimiter", bundle: .main)) { Picker("", selection: $plugin.options.delimiter) { ForEach(CSVDelimiter.allCases) { delimiter in Text(delimiter.displayName).tag(delimiter) @@ -40,7 +40,7 @@ struct CSVExportOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Quote")) { + optionRow(String(localized: "Quote", bundle: .main)) { Picker("", selection: $plugin.options.quoteHandling) { ForEach(CSVQuoteHandling.allCases) { handling in Text(handling.rawValue).tag(handling) @@ -51,7 +51,7 @@ struct CSVExportOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Line break")) { + optionRow(String(localized: "Line break", bundle: .main)) { Picker("", selection: $plugin.options.lineBreak) { ForEach(CSVLineBreak.allCases) { lineBreak in Text(lineBreak.rawValue).tag(lineBreak) @@ -62,7 +62,7 @@ struct CSVExportOptionsView: View { .frame(width: 140, alignment: .trailing) } - optionRow(String(localized: "Decimal")) { + optionRow(String(localized: "Decimal", bundle: .main)) { Picker("", selection: $plugin.options.decimalFormat) { ForEach(CSVDecimalFormat.allCases) { format in Text(format.rawValue).tag(format) diff --git a/Plugins/CSVExportPlugin/CSVExportPlugin.swift b/Plugins/CSVExportPlugin/CSVExportPlugin.swift index 933b043d..d1b3828e 100644 --- a/Plugins/CSVExportPlugin/CSVExportPlugin.swift +++ b/Plugins/CSVExportPlugin/CSVExportPlugin.swift @@ -34,7 +34,7 @@ final class CSVExportPlugin: ExportFormatPlugin { destination: URL, progress: PluginExportProgress ) async throws { - let fileHandle = try createFileHandle(at: destination) + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) defer { try? fileHandle.close() } let lineBreak = options.lineBreak.value @@ -141,8 +141,6 @@ final class CSVExportPlugin: ExportFormatPlugin { try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data()) progress.incrementRow() } - - progress.finalizeTable() } private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String { @@ -175,10 +173,4 @@ final class CSVExportPlugin: ExportFormatPlugin { } } - private 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) - } } diff --git a/Plugins/JSONExportPlugin/JSONExportPlugin.swift b/Plugins/JSONExportPlugin/JSONExportPlugin.swift index d1c5352a..3f56123c 100644 --- a/Plugins/JSONExportPlugin/JSONExportPlugin.swift +++ b/Plugins/JSONExportPlugin/JSONExportPlugin.swift @@ -31,7 +31,7 @@ final class JSONExportPlugin: ExportFormatPlugin { destination: URL, progress: PluginExportProgress ) async throws { - let fileHandle = try createFileHandle(at: destination) + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) defer { try? fileHandle.close() } let prettyPrint = options.prettyPrint @@ -114,8 +114,6 @@ final class JSONExportPlugin: ExportFormatPlugin { offset += result.rows.count } - progress.finalizeTable() - if hasWrittenRow { try fileHandle.write(contentsOf: newline.toUTF8Data()) } @@ -162,10 +160,4 @@ final class JSONExportPlugin: ExportFormatPlugin { return "\"\(PluginExportUtilities.escapeJSONString(val))\"" } - private 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) - } } diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index d77b14a3..125a620c 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -46,7 +46,7 @@ final class MQLExportPlugin: ExportFormatPlugin { destination: URL, progress: PluginExportProgress ) async throws { - let fileHandle = try createFileHandle(at: destination) + let fileHandle = try PluginExportUtilities.createFileHandle(at: destination) defer { try? fileHandle.close() } let dateFormatter = ISO8601DateFormatter() @@ -145,8 +145,6 @@ final class MQLExportPlugin: ExportFormatPlugin { ) } - progress.finalizeTable() - if index < tables.count - 1 { try fileHandle.write(contentsOf: "\n".toUTF8Data()) } @@ -212,10 +210,4 @@ final class MQLExportPlugin: ExportFormatPlugin { } } - private 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) - } } diff --git a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift index 49638df6..6ffed8ac 100644 --- a/Plugins/SQLExportPlugin/SQLExportOptionsView.swift +++ b/Plugins/SQLExportPlugin/SQLExportOptionsView.swift @@ -28,7 +28,7 @@ struct SQLExportOptionsView: View { Picker("", selection: $plugin.options.batchSize) { ForEach(Self.batchSizeOptions, id: \.self) { size in - Text(size == 1 ? String(localized: "1 (no batching)") : "\(size)") + Text(size == 1 ? String(localized: "1 (no batching)", bundle: .main) : "\(size)") .tag(size) } } diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index 5c782508..f2311164 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -70,7 +70,7 @@ final class SQLExportPlugin: ExportFormatPlugin { targetURL = destination } - let fileHandle = try createFileHandle(at: targetURL) + let fileHandle = try PluginExportUtilities.createFileHandle(at: targetURL) do { let dateFormatter = ISO8601DateFormatter() @@ -264,15 +264,6 @@ final class SQLExportPlugin: ExportFormatPlugin { let statement = insertPrefix + valuesBatch.joined(separator: ",\n") + ";\n\n" try fileHandle.write(contentsOf: statement.toUTF8Data()) } - - progress.finalizeTable() - } - - private 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) } private func compressFile(source: URL, destination: URL) async throws { diff --git a/Plugins/TableProPluginKit/PluginExportUtilities.swift b/Plugins/TableProPluginKit/PluginExportUtilities.swift index d83af0e2..0945adda 100644 --- a/Plugins/TableProPluginKit/PluginExportUtilities.swift +++ b/Plugins/TableProPluginKit/PluginExportUtilities.swift @@ -44,6 +44,13 @@ public enum PluginExportUtilities { 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: " ") diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 5baa05e4..7ee9d801 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -131,18 +131,23 @@ final class ExportService { currentProgress = progress progress.setTotalRows(state.totalRows) - // Wire progress updates to UI state + // 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 - Task { @MainActor [weak self] in - 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 + 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 + } } } } @@ -255,3 +260,27 @@ final class ExportService { return total } } + +// 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 + } + + func clearPending() { + lock.lock() + isPending = false + lock.unlock() + } +} diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index d5a2f12c..3a76a713 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -70,6 +70,9 @@ struct ExportDialog: View { } } } + .onChange(of: config.formatId) { + resetOptionValues() + } .onExitCommand { if !isExporting { isPresented = false @@ -388,6 +391,14 @@ struct ExportDialog: View { fileNameValidationError == nil } + private func resetOptionValues() { + for dbIndex in databaseItems.indices { + for tableIndex in databaseItems[dbIndex].tables.indices { + databaseItems[dbIndex].tables[tableIndex].optionValues = [] + } + } + } + // MARK: - Actions @MainActor From cece750f7c5274bfded1cdd1b04a0c7196a6b116 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 10:44:35 +0700 Subject: [PATCH 4/5] fix: re-enable parallel test execution - Replace PluginManager dependency in MSSQLDriverTests with a mock PluginDatabaseDriver to avoid plugin loading issues in parallel runs - Add .serialized trait to CompletionEngineTests to prevent data race on SQLKeywords statics - Restore parallelizable = YES in test scheme --- .../xcshareddata/xcschemes/TablePro.xcscheme | 2 +- .../Autocomplete/CompletionEngineTests.swift | 2 +- .../Core/Database/MSSQLDriverTests.swift | 60 ++++++++++++++----- 3 files changed, 47 insertions(+), 17 deletions(-) diff --git a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme index 35ece929..f99c67cb 100644 --- a/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme +++ b/TablePro.xcodeproj/xcshareddata/xcschemes/TablePro.xcscheme @@ -32,7 +32,7 @@ + parallelizable = "YES"> 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") From 38606350ba1a214813996262fcbc06fc35d3c00f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 9 Mar 2026 11:05:06 +0700 Subject: [PATCH 5/5] fix: address second round of review issues in export plugins - Surface DDL failure warnings from SQLExportPlugin via new protocol warnings property on ExportFormatPlugin - Propagate Swift Task cancellation in PluginExportProgress.checkCancellation - Pass databaseName through writeMQLIndexes instead of hardcoding "" - Add bundle: .main to String(localized:) in CSVExportModels displayName - Populate optionValues with plugin defaults on format switch - Wrap per-table sequence/type fetch in do/catch for fault tolerance - Restore original format picker order (csv, json, sql, xlsx, mql) --- Plugins/CSVExportPlugin/CSVExportModels.swift | 6 +-- Plugins/MQLExportPlugin/MQLExportPlugin.swift | 4 +- Plugins/SQLExportPlugin/SQLExportPlugin.swift | 52 ++++++++++++------- .../ExportFormatPlugin.swift | 2 + .../PluginExportProgress.swift | 2 +- .../Core/Services/Export/ExportService.swift | 5 ++ TablePro/Views/Export/ExportDialog.swift | 11 +++- 7 files changed, 56 insertions(+), 26 deletions(-) diff --git a/Plugins/CSVExportPlugin/CSVExportModels.swift b/Plugins/CSVExportPlugin/CSVExportModels.swift index 407b6d8e..7b8300f3 100644 --- a/Plugins/CSVExportPlugin/CSVExportModels.swift +++ b/Plugins/CSVExportPlugin/CSVExportModels.swift @@ -36,9 +36,9 @@ public enum CSVQuoteHandling: String, CaseIterable, Identifiable { public 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") + 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) } } } diff --git a/Plugins/MQLExportPlugin/MQLExportPlugin.swift b/Plugins/MQLExportPlugin/MQLExportPlugin.swift index 125a620c..35f3e369 100644 --- a/Plugins/MQLExportPlugin/MQLExportPlugin.swift +++ b/Plugins/MQLExportPlugin/MQLExportPlugin.swift @@ -139,6 +139,7 @@ final class MQLExportPlugin: ExportFormatPlugin { if includeIndexes { try await writeMQLIndexes( collection: table.name, + databaseName: table.databaseName, collectionAccessor: collectionAccessor, dataSource: dataSource, to: fileHandle @@ -175,13 +176,14 @@ final class MQLExportPlugin: ExportFormatPlugin { private func writeMQLIndexes( collection: String, + databaseName: String, collectionAccessor: String, dataSource: any PluginExportDataSource, to fileHandle: FileHandle ) async throws { let ddl = try await dataSource.fetchTableDDL( table: collection, - databaseName: "" + databaseName: databaseName ) let lines = ddl.components(separatedBy: "\n") diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index f2311164..6785ac8f 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -44,6 +44,12 @@ final class SQLExportPlugin: ExportFormatPlugin { 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)) } @@ -84,27 +90,35 @@ final class SQLExportPlugin: ExportFormatPlugin { let structureTables = tables.filter { optionValue($0, at: 0) } for table in structureTables { - 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()) + 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)") } - 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()) + 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)") } } diff --git a/Plugins/TableProPluginKit/ExportFormatPlugin.swift b/Plugins/TableProPluginKit/ExportFormatPlugin.swift index fce7333e..d7f287f1 100644 --- a/Plugins/TableProPluginKit/ExportFormatPlugin.swift +++ b/Plugins/TableProPluginKit/ExportFormatPlugin.swift @@ -19,6 +19,7 @@ public protocol ExportFormatPlugin: TableProPlugin { func isTableExportable(optionValues: [Bool]) -> Bool var currentFileExtension: String { get } + var warnings: [String] { get } func optionsView() -> AnyView? @@ -38,5 +39,6 @@ public extension ExportFormatPlugin { 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/PluginExportProgress.swift b/Plugins/TableProPluginKit/PluginExportProgress.swift index 9fa5381d..de364680 100644 --- a/Plugins/TableProPluginKit/PluginExportProgress.swift +++ b/Plugins/TableProPluginKit/PluginExportProgress.swift @@ -61,7 +61,7 @@ public final class PluginExportProgress: @unchecked Sendable { lock.lock() let cancelled = _isCancelled lock.unlock() - if cancelled { + if cancelled || Task.isCancelled { throw PluginExportCancellationError() } } diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift index 7ee9d801..529ce034 100644 --- a/TablePro/Core/Services/Export/ExportService.swift +++ b/TablePro/Core/Services/Export/ExportService.swift @@ -175,6 +175,11 @@ final class ExportService { throw error } + let pluginWarnings = plugin.warnings + if !pluginWarnings.isEmpty { + state.warningMessage = pluginWarnings.joined(separator: "\n") + } + state.progress = 1.0 } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 3a76a713..6882e224 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -124,7 +124,11 @@ struct ExportDialog: View { } return true } - .sorted { type(of: $0).formatDisplayName < type(of: $1).formatDisplayName } + .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] { @@ -343,6 +347,8 @@ struct ExportDialog: View { 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", @@ -392,9 +398,10 @@ struct ExportDialog: View { } private func resetOptionValues() { + let defaults = currentPlugin?.defaultTableOptionValues() ?? [] for dbIndex in databaseItems.indices { for tableIndex in databaseItems[dbIndex].tables.indices { - databaseItems[dbIndex].tables[tableIndex].optionValues = [] + databaseItems[dbIndex].tables[tableIndex].optionValues = defaults } } }