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