Skip to content

Commit abf34b1

Browse files
authored
Import: choose source format (SQL/JSON) and batch JSON inserts (#1548)
* feat(import): let Import choose source format (SQL or JSON) * perf(plugin-json): batch row inserts and report the exact import total
1 parent 127f1d4 commit abf34b1

27 files changed

Lines changed: 490 additions & 80 deletions

CHANGELOG.md

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

1010
### Added
1111

12-
- Import a JSON file into a table. A dedicated sheet accepts an array of objects, newline-delimited JSON, or TablePro's own JSON export, and lets you map each field to a column in an existing table or in a new table with inferred, editable columns.
12+
- Import a JSON file into a table. The Import menu now lets you pick the source, SQL or JSON, instead of going straight to SQL. The JSON flow accepts an array of objects, newline-delimited JSON, or TablePro's own JSON export, and lets you map each field to a column in an existing table or in a new table with inferred, editable columns. Rows insert in batched multi-row statements, so large imports over a remote connection finish in seconds instead of minutes.
1313
- The window title bar shows the open table's name, with its database and schema below, so you can tell which table you're viewing without checking the sidebar. (#1475)
1414
- iOS: open DuckDB database files and in-memory DuckDB databases, matching the Mac app. (#1526)
1515
- Save the current query as a favorite from a star button in the SQL editor toolbar.

Packages/TableProCore/Sources/TableProPluginKit/PluginImportDataSink.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public protocol PluginImportDataSink: AnyObject, Sendable {
1010
var targetTable: String? { get }
1111
func execute(statement: String) async throws
1212
func insertRow(_ values: [String: PluginCellValue]) async throws
13+
func insertRows(_ rows: [[String: PluginCellValue]]) async throws
1314
func deleteAllRowsFromTargetTable() async throws
1415
func beginTransaction() async throws
1516
func commitTransaction() async throws
@@ -23,6 +24,11 @@ public extension PluginImportDataSink {
2324
func insertRow(_ values: [String: PluginCellValue]) async throws {
2425
throw PluginImportError.importFailed("Row-based import is not supported by this connection")
2526
}
27+
func insertRows(_ rows: [[String: PluginCellValue]]) async throws {
28+
for row in rows {
29+
try await insertRow(row)
30+
}
31+
}
2632
func deleteAllRowsFromTargetTable() async throws {
2733
throw PluginImportError.importFailed("Clearing the target table is not supported by this connection")
2834
}

Packages/TableProCore/Sources/TableProPluginKit/PluginImportProgress.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ public final class PluginImportProgress: @unchecked Sendable {
2727
}
2828
}
2929

30+
public func incrementStatement(by amount: Int) {
31+
guard amount > 0 else { return }
32+
lock.lock()
33+
let previous = internalCount
34+
internalCount += amount
35+
let count = internalCount
36+
lock.unlock()
37+
if count / updateInterval != previous / updateInterval {
38+
progress.completedUnitCount = Int64(count)
39+
}
40+
}
41+
3042
public func setStatus(_ message: String) {
3143
progress.localizedAdditionalDescription = message
3244
}

Plugins/JSONImportPlugin/JSONImportPlugin.swift

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,12 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin {
4343
let url = source.fileURL()
4444
let useTransaction = settings.wrapInTransaction && settings.errorHandling != .skipAndContinue
4545

46-
progress.setEstimatedTotal(max(1, Int(source.fileSizeBytes() / 256)))
47-
46+
let batchSize = 500
4847
var inserted = 0
4948
var skipped = 0
5049
var errors: [PluginImportResult.ImportStatementError] = []
5150
let maxErrors = 1_000
51+
var batch: [(line: Int, row: [String: PluginCellValue])] = []
5252

5353
do {
5454
if settings.deleteExistingRows {
@@ -59,25 +59,35 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin {
5959
}
6060

6161
if JSONImportParsing.isLineDelimited(url) {
62+
progress.setEstimatedTotal(max(1, Int(source.fileSizeBytes() / 256)))
6263
var lineNumber = 0
6364
for try await line in url.lines {
6465
try progress.checkCancellation()
6566
lineNumber += 1
6667
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
6768
guard !trimmed.isEmpty else { continue }
68-
let row = try JSONImportParsing.parseRow(fromLine: trimmed)
69-
try await insert(row, into: sink, at: lineNumber, progress: progress,
70-
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
69+
batch.append((lineNumber, try JSONImportParsing.parseRow(fromLine: trimmed)))
70+
if batch.count >= batchSize {
71+
try await flush(&batch, into: sink, progress: progress,
72+
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
73+
}
7174
}
7275
} else {
7376
let rawRows = try JSONImportParsing.parseRows(at: url, targetTable: sink.targetTable)
77+
progress.setEstimatedTotal(rawRows.count)
7478
for (index, rawRow) in rawRows.enumerated() {
7579
try progress.checkCancellation()
76-
try await insert(JSONImportParsing.convertRow(rawRow), into: sink, at: index + 1, progress: progress,
77-
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
80+
batch.append((index + 1, JSONImportParsing.convertRow(rawRow)))
81+
if batch.count >= batchSize {
82+
try await flush(&batch, into: sink, progress: progress,
83+
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
84+
}
7885
}
7986
}
8087

88+
try await flush(&batch, into: sink, progress: progress,
89+
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
90+
8191
if useTransaction {
8292
try await sink.commitTransaction()
8393
}
@@ -131,6 +141,41 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin {
131141
}
132142
}
133143

144+
private func flush(
145+
_ batch: inout [(line: Int, row: [String: PluginCellValue])],
146+
into sink: any PluginImportDataSink,
147+
progress: PluginImportProgress,
148+
inserted: inout Int,
149+
skipped: inout Int,
150+
errors: inout [PluginImportResult.ImportStatementError],
151+
maxErrors: Int
152+
) async throws {
153+
guard !batch.isEmpty else { return }
154+
let entries = batch
155+
batch.removeAll(keepingCapacity: true)
156+
157+
do {
158+
try await sink.insertRows(entries.map(\.row))
159+
inserted += entries.count
160+
progress.incrementStatement(by: entries.count)
161+
} catch {
162+
switch settings.errorHandling {
163+
case .stopAndRollback, .stopAndCommit:
164+
let firstLine = entries.first?.line ?? 0
165+
throw PluginImportError.statementFailed(
166+
statement: "rows \(firstLine)-\(entries.last?.line ?? firstLine)",
167+
line: firstLine,
168+
underlyingError: error
169+
)
170+
case .skipAndContinue:
171+
for entry in entries {
172+
try await insert(entry.row, into: sink, at: entry.line, progress: progress,
173+
inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors)
174+
}
175+
}
176+
}
177+
}
178+
134179
// MARK: - Source introspection
135180

136181
func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] {

Plugins/TableProPluginKit/PluginImportDataSink.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public protocol PluginImportDataSink: AnyObject, Sendable {
1010
var targetTable: String? { get }
1111
func execute(statement: String) async throws
1212
func insertRow(_ values: [String: PluginCellValue]) async throws
13+
func insertRows(_ rows: [[String: PluginCellValue]]) async throws
1314
func deleteAllRowsFromTargetTable() async throws
1415
func beginTransaction() async throws
1516
func commitTransaction() async throws
@@ -23,6 +24,11 @@ public extension PluginImportDataSink {
2324
func insertRow(_ values: [String: PluginCellValue]) async throws {
2425
throw PluginImportError.importFailed("Row-based import is not supported by this connection")
2526
}
27+
func insertRows(_ rows: [[String: PluginCellValue]]) async throws {
28+
for row in rows {
29+
try await insertRow(row)
30+
}
31+
}
2632
func deleteAllRowsFromTargetTable() async throws {
2733
throw PluginImportError.importFailed("Clearing the target table is not supported by this connection")
2834
}

Plugins/TableProPluginKit/PluginImportProgress.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,18 @@ public final class PluginImportProgress: @unchecked Sendable {
2727
}
2828
}
2929

30+
public func incrementStatement(by amount: Int) {
31+
guard amount > 0 else { return }
32+
lock.lock()
33+
let previous = internalCount
34+
internalCount += amount
35+
let count = internalCount
36+
lock.unlock()
37+
if count / updateInterval != previous / updateInterval {
38+
progress.completedUnitCount = Int64(count)
39+
}
40+
}
41+
3042
public func setStatus(_ message: String) {
3143
progress.localizedAdditionalDescription = message
3244
}

TablePro/Core/ChangeTracking/SQLStatementGenerator.swift

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,36 @@ struct SQLStatementGenerator {
196196
return ParameterizedStatement(sql: sql, parameters: bindParameters)
197197
}
198198

199+
func insertStatement(columns insertColumns: [String], rows: [[PluginCellValue]])
200+
-> ParameterizedStatement?
201+
{
202+
guard !insertColumns.isEmpty, !rows.isEmpty,
203+
rows.allSatisfy({ $0.count == insertColumns.count }) else { return nil }
204+
205+
var bindParameters: [Any?] = []
206+
let columnList = insertColumns.map(quoteIdentifierFn).joined(separator: ", ")
207+
let rowTuples = rows.map { values -> String in
208+
let placeholders = values.map { value -> String in
209+
bindParameters.append(value.asAny)
210+
return placeholder(at: bindParameters.count - 1)
211+
}.joined(separator: ", ")
212+
return "(\(placeholders))"
213+
}.joined(separator: ", ")
214+
215+
let sql =
216+
"INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnList)) VALUES \(rowTuples)"
217+
218+
return ParameterizedStatement(sql: sql, parameters: bindParameters)
219+
}
220+
221+
var maxBindParameters: Int {
222+
switch databaseType {
223+
case .sqlite: 32_766
224+
case .mssql: 2_100
225+
default: 65_535
226+
}
227+
}
228+
199229
func deleteAllRowsStatement() -> String {
200230
"DELETE FROM \(quoteIdentifierFn(tableName))"
201231
}

TablePro/Core/Plugins/ImportDataSinkAdapter.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,55 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
7272
_ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters)
7373
}
7474

75+
func insertRows(_ rows: [[String: PluginCellValue]]) async throws {
76+
guard targetTable != nil else {
77+
throw PluginImportError.importFailed("No target table configured for row import")
78+
}
79+
guard let rowGenerator else {
80+
throw PluginImportError.importFailed("Could not resolve SQL dialect for row import")
81+
}
82+
83+
var index = 0
84+
while index < rows.count {
85+
let (columns, values) = mappedColumnsAndValues(rows[index])
86+
guard !columns.isEmpty else {
87+
index += 1
88+
continue
89+
}
90+
91+
var groupValues: [[PluginCellValue]] = [values]
92+
var next = index + 1
93+
while next < rows.count {
94+
let (nextColumns, nextValues) = mappedColumnsAndValues(rows[next])
95+
guard nextColumns == columns else { break }
96+
groupValues.append(nextValues)
97+
next += 1
98+
}
99+
index = next
100+
101+
let chunkSize = max(1, rowGenerator.maxBindParameters / columns.count)
102+
var offset = 0
103+
while offset < groupValues.count {
104+
let end = min(offset + chunkSize, groupValues.count)
105+
let chunk = Array(groupValues[offset..<end])
106+
if let statement = rowGenerator.insertStatement(columns: columns, rows: chunk) {
107+
_ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters)
108+
}
109+
offset = end
110+
}
111+
}
112+
}
113+
114+
private func mappedColumnsAndValues(_ values: [String: PluginCellValue]) -> ([String], [PluginCellValue]) {
115+
var pairs: [(column: String, value: PluginCellValue)] = []
116+
for (field, value) in values {
117+
guard let column = columnMapping[field.lowercased()] else { continue }
118+
pairs.append((column, value))
119+
}
120+
pairs.sort { $0.column < $1.column }
121+
return (pairs.map(\.column), pairs.map(\.value))
122+
}
123+
75124
func deleteAllRowsFromTargetTable() async throws {
76125
guard targetTable != nil, let rowGenerator else {
77126
throw PluginImportError.importFailed("No target table configured for row import")

TablePro/Core/Plugins/PluginManager+Registration.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,31 @@ extension PluginManager {
262262
return Array(importPlugins.values)
263263
}
264264

265+
func importPlugins(for databaseType: DatabaseType) -> [any ImportFormatPlugin] {
266+
guard supportsImport(for: databaseType) else { return [] }
267+
let typeId = databaseType.rawValue
268+
return allImportPlugins()
269+
.filter { plugin in
270+
let supported = type(of: plugin).supportedDatabaseTypeIds
271+
let excluded = type(of: plugin).excludedDatabaseTypeIds
272+
if !supported.isEmpty && !supported.contains(typeId) { return false }
273+
if excluded.contains(typeId) { return false }
274+
return true
275+
}
276+
.sorted { lhs, rhs in
277+
let lhsRowBased = type(of: lhs).requiresTargetTable
278+
let rhsRowBased = type(of: rhs).requiresTargetTable
279+
if lhsRowBased != rhsRowBased { return !lhsRowBased }
280+
return type(of: lhs).formatDisplayName < type(of: rhs).formatDisplayName
281+
}
282+
}
283+
284+
func importFormatOptions(for databaseType: DatabaseType) -> [ImportFormatOption] {
285+
importPlugins(for: databaseType).map {
286+
ImportFormatOption(id: type(of: $0).formatId, name: type(of: $0).formatDisplayName)
287+
}
288+
}
289+
265290
/// Returns a temporary plugin driver for query building (buildBrowseQuery), or nil
266291
/// if the plugin doesn't implement custom query building (NoSQL hooks).
267292
func queryBuildingDriver(for databaseType: DatabaseType) -> (any PluginDatabaseDriver)? {
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// ImportRouting.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
struct ImportFormatOption: Identifiable, Equatable {
9+
let id: String
10+
let name: String
11+
12+
var submenuLabel: String {
13+
String(format: String(localized: "From %@\u{2026}"), name)
14+
}
15+
16+
var standaloneLabel: String {
17+
String(format: String(localized: "Import %@\u{2026}"), name)
18+
}
19+
}
20+
21+
enum ImportSheetRoute: Equatable {
22+
case statement(formatId: String)
23+
case rowMapping(formatId: String)
24+
}
25+
26+
enum ImportRouting {
27+
static func route(formatId: String, requiresTargetTable: Bool) -> ImportSheetRoute {
28+
requiresTargetTable ? .rowMapping(formatId: formatId) : .statement(formatId: formatId)
29+
}
30+
}

0 commit comments

Comments
 (0)