Skip to content

Commit f961a21

Browse files
authored
Merge pull request #241 from datlechin/feat/import-plugin-system
feat: extract import into plugin system
2 parents e9d3127 + a849607 commit f961a21

23 files changed

Lines changed: 930 additions & 436 deletions

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Added
11+
12+
- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
13+
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins
14+
- SQLImportPlugin as the first import format plugin (SQL files and .gz compressed SQL)
15+
1016
## [0.16.1] - 2026-03-09
1117

1218
### Fixed

Plugins/SQLImportPlugin/Info.plist

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<key>TableProPluginKitVersion</key>
6+
<integer>1</integer>
7+
</dict>
8+
</plist>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// SQLImportOptions.swift
3+
// SQLImportPlugin
4+
//
5+
6+
import Foundation
7+
8+
@Observable
9+
final class SQLImportOptions {
10+
var wrapInTransaction: Bool = true
11+
var disableForeignKeyChecks: Bool = true
12+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//
2+
// SQLImportOptionsView.swift
3+
// SQLImportPlugin
4+
//
5+
6+
import SwiftUI
7+
8+
struct SQLImportOptionsView: View {
9+
let plugin: SQLImportPlugin
10+
11+
var body: some View {
12+
VStack(alignment: .leading, spacing: 12) {
13+
Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin.options).wrapInTransaction)
14+
.font(.system(size: 13))
15+
.help(
16+
"Execute all statements in a single transaction. If any statement fails, all changes are rolled back."
17+
)
18+
19+
Toggle("Disable foreign key checks", isOn: Bindable(plugin.options).disableForeignKeyChecks)
20+
.font(.system(size: 13))
21+
.help(
22+
"Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies."
23+
)
24+
}
25+
}
26+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
//
2+
// SQLImportPlugin.swift
3+
// SQLImportPlugin
4+
//
5+
6+
import Foundation
7+
import SwiftUI
8+
import TableProPluginKit
9+
10+
@Observable
11+
final class SQLImportPlugin: ImportFormatPlugin {
12+
static let pluginName = "SQL Import"
13+
static let pluginVersion = "1.0.0"
14+
static let pluginDescription = "Import data from SQL files"
15+
static let formatId = "sql"
16+
static let formatDisplayName = "SQL"
17+
static let acceptedFileExtensions = ["sql", "gz"]
18+
static let iconName = "doc.text"
19+
20+
var options = SQLImportOptions()
21+
22+
required init() {}
23+
24+
func optionsView() -> AnyView? {
25+
AnyView(SQLImportOptionsView(plugin: self))
26+
}
27+
28+
func performImport(
29+
source: any PluginImportSource,
30+
sink: any PluginImportDataSink,
31+
progress: PluginImportProgress
32+
) async throws -> PluginImportResult {
33+
let startTime = Date()
34+
var executedCount = 0
35+
36+
// Estimate total from file size (~500 bytes per statement)
37+
let fileSizeBytes = source.fileSizeBytes()
38+
let estimatedTotal = max(1, Int(fileSizeBytes / 500))
39+
progress.setEstimatedTotal(estimatedTotal)
40+
41+
do {
42+
// Disable FK checks if enabled
43+
if options.disableForeignKeyChecks {
44+
try await sink.disableForeignKeyChecks()
45+
}
46+
47+
// Begin transaction if enabled
48+
if options.wrapInTransaction {
49+
try await sink.beginTransaction()
50+
}
51+
52+
// Stream and execute statements
53+
let stream = try await source.statements()
54+
55+
for try await (statement, lineNumber) in stream {
56+
try progress.checkCancellation()
57+
58+
do {
59+
try await sink.execute(statement: statement)
60+
executedCount += 1
61+
progress.incrementStatement()
62+
} catch {
63+
throw PluginImportError.statementFailed(
64+
statement: statement,
65+
line: lineNumber,
66+
underlyingError: error
67+
)
68+
}
69+
}
70+
71+
// Commit transaction
72+
if options.wrapInTransaction {
73+
try await sink.commitTransaction()
74+
}
75+
76+
// Re-enable FK checks
77+
if options.disableForeignKeyChecks {
78+
try await sink.enableForeignKeyChecks()
79+
}
80+
} catch {
81+
let importError = error
82+
83+
// Rollback on error
84+
if options.wrapInTransaction {
85+
do {
86+
try await sink.rollbackTransaction()
87+
} catch {
88+
throw PluginImportError.rollbackFailed(underlyingError: importError)
89+
}
90+
}
91+
92+
// Re-enable FK checks (best-effort)
93+
if options.disableForeignKeyChecks {
94+
try? await sink.enableForeignKeyChecks()
95+
}
96+
97+
// Re-throw cancellation as-is, wrap others
98+
if importError is PluginImportCancellationError {
99+
throw importError
100+
}
101+
if importError is PluginImportError {
102+
throw importError
103+
}
104+
throw PluginImportError.importFailed(importError.localizedDescription)
105+
}
106+
107+
progress.finalize()
108+
109+
return PluginImportResult(
110+
executedStatements: executedCount,
111+
executionTime: Date().timeIntervalSince(startTime)
112+
)
113+
}
114+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
//
2+
// ImportFormatPlugin.swift
3+
// TableProPluginKit
4+
//
5+
6+
import Foundation
7+
import SwiftUI
8+
9+
public protocol ImportFormatPlugin: TableProPlugin {
10+
static var formatId: String { get }
11+
static var formatDisplayName: String { get }
12+
static var acceptedFileExtensions: [String] { get }
13+
static var iconName: String { get }
14+
static var supportedDatabaseTypeIds: [String] { get }
15+
static var excludedDatabaseTypeIds: [String] { get }
16+
17+
func optionsView() -> AnyView?
18+
19+
func performImport(
20+
source: any PluginImportSource,
21+
sink: any PluginImportDataSink,
22+
progress: PluginImportProgress
23+
) async throws -> PluginImportResult
24+
}
25+
26+
public extension ImportFormatPlugin {
27+
static var capabilities: [PluginCapability] { [.importFormat] }
28+
static var supportedDatabaseTypeIds: [String] { [] }
29+
static var excludedDatabaseTypeIds: [String] { [] }
30+
func optionsView() -> AnyView? { nil }
31+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// PluginImportDataSink.swift
3+
// TableProPluginKit
4+
//
5+
6+
import Foundation
7+
8+
public protocol PluginImportDataSink: AnyObject, Sendable {
9+
var databaseTypeId: String { get }
10+
func execute(statement: String) async throws
11+
func beginTransaction() async throws
12+
func commitTransaction() async throws
13+
func rollbackTransaction() async throws
14+
func disableForeignKeyChecks() async throws
15+
func enableForeignKeyChecks() async throws
16+
}
17+
18+
public extension PluginImportDataSink {
19+
func disableForeignKeyChecks() async throws {}
20+
func enableForeignKeyChecks() async throws {}
21+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
//
2+
// PluginImportProgress.swift
3+
// TableProPluginKit
4+
//
5+
6+
import Foundation
7+
8+
public final class PluginImportProgress: @unchecked Sendable {
9+
private let lock = NSLock()
10+
private var _processedStatements: Int = 0
11+
private var _estimatedTotalStatements: Int = 0
12+
private var _statusMessage: String = ""
13+
private var _isCancelled: Bool = false
14+
15+
private let updateInterval: Int = 500
16+
private var internalCount: Int = 0
17+
18+
public var onUpdate: (@Sendable (Int, Int, String) -> Void)?
19+
20+
public init() {}
21+
22+
public func setEstimatedTotal(_ count: Int) {
23+
lock.lock()
24+
_estimatedTotalStatements = count
25+
lock.unlock()
26+
}
27+
28+
public func incrementStatement() {
29+
lock.lock()
30+
internalCount += 1
31+
_processedStatements = internalCount
32+
let shouldNotify = internalCount % updateInterval == 0
33+
lock.unlock()
34+
if shouldNotify {
35+
notifyUpdate()
36+
}
37+
}
38+
39+
public func setStatus(_ message: String) {
40+
lock.lock()
41+
_statusMessage = message
42+
lock.unlock()
43+
notifyUpdate()
44+
}
45+
46+
public func checkCancellation() throws {
47+
lock.lock()
48+
let cancelled = _isCancelled
49+
lock.unlock()
50+
if cancelled || Task.isCancelled {
51+
throw PluginImportCancellationError()
52+
}
53+
}
54+
55+
public func cancel() {
56+
lock.lock()
57+
_isCancelled = true
58+
lock.unlock()
59+
}
60+
61+
public var isCancelled: Bool {
62+
lock.lock()
63+
defer { lock.unlock() }
64+
return _isCancelled
65+
}
66+
67+
public var processedStatements: Int {
68+
lock.lock()
69+
defer { lock.unlock() }
70+
return _processedStatements
71+
}
72+
73+
public var estimatedTotalStatements: Int {
74+
lock.lock()
75+
defer { lock.unlock() }
76+
return _estimatedTotalStatements
77+
}
78+
79+
public func finalize() {
80+
notifyUpdate()
81+
}
82+
83+
private func notifyUpdate() {
84+
lock.lock()
85+
let processed = _processedStatements
86+
let total = _estimatedTotalStatements
87+
let status = _statusMessage
88+
lock.unlock()
89+
onUpdate?(processed, total, status)
90+
}
91+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//
2+
// PluginImportSource.swift
3+
// TableProPluginKit
4+
//
5+
6+
import Foundation
7+
8+
public protocol PluginImportSource: AnyObject, Sendable {
9+
func statements() async throws -> AsyncThrowingStream<(statement: String, lineNumber: Int), Error>
10+
func fileURL() -> URL
11+
func fileSizeBytes() -> Int64
12+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//
2+
// PluginImportTypes.swift
3+
// TableProPluginKit
4+
//
5+
6+
import Foundation
7+
8+
public struct PluginImportResult: Sendable {
9+
public let executedStatements: Int
10+
public let executionTime: TimeInterval
11+
public let failedStatement: String?
12+
public let failedLine: Int?
13+
14+
public init(
15+
executedStatements: Int,
16+
executionTime: TimeInterval,
17+
failedStatement: String? = nil,
18+
failedLine: Int? = nil
19+
) {
20+
self.executedStatements = executedStatements
21+
self.executionTime = executionTime
22+
self.failedStatement = failedStatement
23+
self.failedLine = failedLine
24+
}
25+
}
26+
27+
public enum PluginImportError: LocalizedError {
28+
case statementFailed(statement: String, line: Int, underlyingError: any Error)
29+
case rollbackFailed(underlyingError: any Error)
30+
case cancelled
31+
case importFailed(String)
32+
33+
public var errorDescription: String? {
34+
switch self {
35+
case .statementFailed(_, let line, let error):
36+
return "Import failed at line \(line): \(error.localizedDescription)"
37+
case .rollbackFailed(let error):
38+
return "Transaction rollback failed: \(error.localizedDescription)"
39+
case .cancelled:
40+
return "Import cancelled"
41+
case .importFailed(let message):
42+
return "Import failed: \(message)"
43+
}
44+
}
45+
}
46+
47+
public struct PluginImportCancellationError: Error, LocalizedError {
48+
public init() {}
49+
public var errorDescription: String? { "Import cancelled" }
50+
}

0 commit comments

Comments
 (0)