Skip to content

Commit 9cb1cd0

Browse files
authored
feat: export query results to CSV, JSON, SQL, XLSX, or MQL (#405)
* feat: export query results to CSV, JSON, SQL, XLSX, or MQL * fix: address review issues in export query results * i18n: add vi, zh-Hans, tr translations for export query results strings
1 parent a4fedfd commit 9cb1cd0

File tree

16 files changed

+1464
-1024
lines changed

16 files changed

+1464
-1024
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- Export query results directly to CSV, JSON, SQL, XLSX, or MQL via File menu, context menu, or toolbar
1213
- Pro license gating for Safe Mode (Touch ID) and XLSX export
1314
- License activation dialog
1415

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
//
2+
// QueryResultExportDataSource.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
import TableProPluginKit
9+
10+
/// In-memory `PluginExportDataSource` backed by a RowBuffer snapshot.
11+
/// Allows export plugins (CSV, JSON, SQL, XLSX, MQL) to export query results
12+
/// without modification to the plugins themselves.
13+
final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Sendable {
14+
let databaseTypeId: String
15+
16+
private let columns: [String]
17+
private let columnTypeNames: [String]
18+
private let rows: [[String?]]
19+
private let driver: DatabaseDriver?
20+
21+
private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource")
22+
23+
init(rowBuffer: RowBuffer, databaseType: DatabaseType, driver: DatabaseDriver?) {
24+
self.databaseTypeId = databaseType.rawValue
25+
self.driver = driver
26+
27+
// Snapshot data at init time for thread safety
28+
self.columns = rowBuffer.columns
29+
self.columnTypeNames = rowBuffer.columnTypes.map { $0.rawType ?? "" }
30+
self.rows = rowBuffer.rows.map { $0.values }
31+
}
32+
33+
func fetchRows(table: String, databaseName: String, offset: Int, limit: Int) async throws -> PluginQueryResult {
34+
let start = min(offset, rows.count)
35+
let end = min(start + limit, rows.count)
36+
let slice = Array(rows[start ..< end])
37+
38+
return PluginQueryResult(
39+
columns: columns,
40+
columnTypeNames: columnTypeNames,
41+
rows: slice,
42+
rowsAffected: 0,
43+
executionTime: 0
44+
)
45+
}
46+
47+
func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? {
48+
rows.count
49+
}
50+
51+
func quoteIdentifier(_ identifier: String) -> String {
52+
if let driver {
53+
return driver.quoteIdentifier(identifier)
54+
}
55+
return "\"\(identifier.replacingOccurrences(of: "\"", with: "\"\""))\""
56+
}
57+
58+
func escapeStringLiteral(_ value: String) -> String {
59+
if let driver {
60+
return driver.escapeStringLiteral(value)
61+
}
62+
return value.replacingOccurrences(of: "'", with: "''")
63+
}
64+
65+
func fetchTableDDL(table: String, databaseName: String) async throws -> String {
66+
""
67+
}
68+
69+
func execute(query: String) async throws -> PluginQueryResult {
70+
throw ExportError.exportFailed("Execute is not supported for in-memory query result export")
71+
}
72+
73+
func fetchDependentSequences(table: String, databaseName: String) async throws -> [PluginSequenceInfo] {
74+
[]
75+
}
76+
77+
func fetchDependentTypes(table: String, databaseName: String) async throws -> [PluginEnumTypeInfo] {
78+
[]
79+
}
80+
}

TablePro/Core/Services/Export/ExportService.swift

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,20 @@ final class ExportService {
6262

6363
var state = ExportState()
6464

65-
private let driver: DatabaseDriver
65+
private let driver: DatabaseDriver?
6666
private let databaseType: DatabaseType
6767

6868
init(driver: DatabaseDriver, databaseType: DatabaseType) {
6969
self.driver = driver
7070
self.databaseType = databaseType
7171
}
7272

73+
/// Convenience initializer for query results export (no driver needed).
74+
init(databaseType: DatabaseType) {
75+
self.driver = nil
76+
self.databaseType = databaseType
77+
}
78+
7379
// MARK: - Cancellation
7480

7581
private let isCancelledLock = NSLock()
@@ -120,8 +126,12 @@ final class ExportService {
120126
currentProgress = nil
121127
}
122128

129+
guard let driver else {
130+
throw ExportError.notConnected
131+
}
132+
123133
// Fetch total row counts
124-
state.totalRows = await fetchTotalRowCount(for: tables)
134+
state.totalRows = await fetchTotalRowCount(for: tables, driver: driver)
125135

126136
// Create data source adapter
127137
let dataSource = ExportDataSourceAdapter(driver: driver, databaseType: databaseType)
@@ -183,9 +193,89 @@ final class ExportService {
183193
state.progress = 1.0
184194
}
185195

196+
// MARK: - Query Results Export
197+
198+
func exportQueryResults(
199+
rowBuffer: RowBuffer,
200+
config: ExportConfiguration,
201+
to url: URL
202+
) async throws {
203+
guard let plugin = PluginManager.shared.exportPlugins[config.formatId] else {
204+
throw ExportError.formatNotFound(config.formatId)
205+
}
206+
207+
let totalRows = rowBuffer.rows.count
208+
state = ExportState(isExporting: true, totalTables: 1, totalRows: totalRows)
209+
isCancelled = false
210+
211+
defer {
212+
state.isExporting = false
213+
isCancelled = false
214+
state.statusMessage = ""
215+
currentProgress = nil
216+
}
217+
218+
let dataSource = QueryResultExportDataSource(
219+
rowBuffer: rowBuffer,
220+
databaseType: databaseType,
221+
driver: driver
222+
)
223+
224+
let progress = PluginExportProgress()
225+
currentProgress = progress
226+
progress.setTotalRows(totalRows)
227+
228+
let pendingUpdate = ProgressUpdateCoalescer()
229+
progress.onUpdate = { [weak self] table, index, rows, total, status in
230+
let shouldDispatch = pendingUpdate.markPending()
231+
if shouldDispatch {
232+
Task { @MainActor [weak self] in
233+
pendingUpdate.clearPending()
234+
guard let self else { return }
235+
self.state.currentTable = table
236+
self.state.currentTableIndex = index
237+
self.state.processedRows = rows
238+
if total > 0 {
239+
self.state.progress = Double(rows) / Double(total)
240+
}
241+
if !status.isEmpty {
242+
self.state.statusMessage = status
243+
}
244+
}
245+
}
246+
}
247+
248+
let exportTable = PluginExportTable(
249+
name: config.fileName,
250+
databaseName: "",
251+
tableType: "query",
252+
optionValues: plugin.defaultTableOptionValues()
253+
)
254+
255+
do {
256+
try await plugin.export(
257+
tables: [exportTable],
258+
dataSource: dataSource,
259+
destination: url,
260+
progress: progress
261+
)
262+
} catch {
263+
try? FileManager.default.removeItem(at: url)
264+
state.errorMessage = error.localizedDescription
265+
throw error
266+
}
267+
268+
let pluginWarnings = plugin.warnings
269+
if !pluginWarnings.isEmpty {
270+
state.warningMessage = pluginWarnings.joined(separator: "\n")
271+
}
272+
273+
state.progress = 1.0
274+
}
275+
186276
// MARK: - Row Count Fetching
187277

188-
private func fetchTotalRowCount(for tables: [ExportTableItem]) async -> Int {
278+
private func fetchTotalRowCount(for tables: [ExportTableItem], driver: DatabaseDriver) async -> Int {
189279
guard !tables.isEmpty else { return 0 }
190280

191281
var total = 0

TablePro/Core/Services/Infrastructure/AppNotifications.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ extension Notification.Name {
2424

2525
static let licenseStatusDidChange = Notification.Name("licenseStatusDidChange")
2626

27+
// MARK: - Export
28+
29+
static let exportQueryResults = Notification.Name("exportQueryResults")
30+
2731
// MARK: - SQL Favorites
2832

2933
static let sqlFavoritesDidUpdate = Notification.Name("sqlFavoritesDidUpdate")

TablePro/Models/Export/ExportModels.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
import Foundation
77
import TableProPluginKit
88

9+
// MARK: - Export Mode
10+
11+
/// Defines the export mode: either exporting database tables or in-memory query results.
12+
enum ExportMode {
13+
case tables(connection: DatabaseConnection, preselectedTables: Set<String>)
14+
case queryResults(connection: DatabaseConnection, rowBuffer: RowBuffer, suggestedFileName: String)
15+
}
16+
917
// MARK: - Export Configuration
1018

1119
@MainActor

0 commit comments

Comments
 (0)