Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Import plugin system: SQL import extracted into a `.tableplugin` bundle, matching the export plugin architecture
- `ImportFormatPlugin` protocol in TableProPluginKit for building custom import format plugins
- SQLImportPlugin as the first import format plugin (SQL files and .gz compressed SQL)

## [0.16.1] - 2026-03-09

### Fixed
Expand Down
8 changes: 8 additions & 0 deletions Plugins/SQLImportPlugin/Info.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>TableProPluginKitVersion</key>
<integer>1</integer>
</dict>
</plist>
12 changes: 12 additions & 0 deletions Plugins/SQLImportPlugin/SQLImportOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// SQLImportOptions.swift
// SQLImportPlugin
//

import Foundation

@Observable
final class SQLImportOptions {
var wrapInTransaction: Bool = true
var disableForeignKeyChecks: Bool = true
}
26 changes: 26 additions & 0 deletions Plugins/SQLImportPlugin/SQLImportOptionsView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// SQLImportOptionsView.swift
// SQLImportPlugin
//

import SwiftUI

struct SQLImportOptionsView: View {
let plugin: SQLImportPlugin

var body: some View {
VStack(alignment: .leading, spacing: 12) {
Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin.options).wrapInTransaction)
.font(.system(size: 13))
.help(
"Execute all statements in a single transaction. If any statement fails, all changes are rolled back."
)

Toggle("Disable foreign key checks", isOn: Bindable(plugin.options).disableForeignKeyChecks)
.font(.system(size: 13))
.help(
"Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies."
)
}
}
}
114 changes: 114 additions & 0 deletions Plugins/SQLImportPlugin/SQLImportPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//
// SQLImportPlugin.swift
// SQLImportPlugin
//

import Foundation
import SwiftUI
import TableProPluginKit

@Observable
final class SQLImportPlugin: ImportFormatPlugin {
static let pluginName = "SQL Import"
static let pluginVersion = "1.0.0"
static let pluginDescription = "Import data from SQL files"
static let formatId = "sql"
static let formatDisplayName = "SQL"
static let acceptedFileExtensions = ["sql", "gz"]
static let iconName = "doc.text"

var options = SQLImportOptions()

required init() {}

func optionsView() -> AnyView? {
AnyView(SQLImportOptionsView(plugin: self))
}

func performImport(
source: any PluginImportSource,
sink: any PluginImportDataSink,
progress: PluginImportProgress
) async throws -> PluginImportResult {
let startTime = Date()
var executedCount = 0

// Estimate total from file size (~500 bytes per statement)
let fileSizeBytes = source.fileSizeBytes()
let estimatedTotal = max(1, Int(fileSizeBytes / 500))
progress.setEstimatedTotal(estimatedTotal)

do {
// Disable FK checks if enabled
if options.disableForeignKeyChecks {
try await sink.disableForeignKeyChecks()
}

// Begin transaction if enabled
if options.wrapInTransaction {
try await sink.beginTransaction()
}

// Stream and execute statements
let stream = try await source.statements()

for try await (statement, lineNumber) in stream {
try progress.checkCancellation()

do {
try await sink.execute(statement: statement)
executedCount += 1
progress.incrementStatement()
} catch {
throw PluginImportError.statementFailed(
statement: statement,
line: lineNumber,
underlyingError: error
)
}
}

// Commit transaction
if options.wrapInTransaction {
try await sink.commitTransaction()
}

// Re-enable FK checks
if options.disableForeignKeyChecks {
try await sink.enableForeignKeyChecks()
}
} catch {
let importError = error

// Rollback on error
if options.wrapInTransaction {
do {
try await sink.rollbackTransaction()
} catch {
throw PluginImportError.rollbackFailed(underlyingError: importError)
}
}

// Re-enable FK checks (best-effort)
if options.disableForeignKeyChecks {
try? await sink.enableForeignKeyChecks()
}

// Re-throw cancellation as-is, wrap others
if importError is PluginImportCancellationError {
throw importError
}
if importError is PluginImportError {
throw importError
}
throw PluginImportError.importFailed(importError.localizedDescription)
}

progress.finalize()

return PluginImportResult(
executedStatements: executedCount,
executionTime: Date().timeIntervalSince(startTime)
)
}
}
31 changes: 31 additions & 0 deletions Plugins/TableProPluginKit/ImportFormatPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// ImportFormatPlugin.swift
// TableProPluginKit
//

import Foundation
import SwiftUI

public protocol ImportFormatPlugin: TableProPlugin {
static var formatId: String { get }
static var formatDisplayName: String { get }
static var acceptedFileExtensions: [String] { get }
static var iconName: String { get }
static var supportedDatabaseTypeIds: [String] { get }
static var excludedDatabaseTypeIds: [String] { get }

func optionsView() -> AnyView?

func performImport(
source: any PluginImportSource,
sink: any PluginImportDataSink,
progress: PluginImportProgress
) async throws -> PluginImportResult
}

public extension ImportFormatPlugin {
static var capabilities: [PluginCapability] { [.importFormat] }
static var supportedDatabaseTypeIds: [String] { [] }
static var excludedDatabaseTypeIds: [String] { [] }
func optionsView() -> AnyView? { nil }
}
21 changes: 21 additions & 0 deletions Plugins/TableProPluginKit/PluginImportDataSink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
//
// PluginImportDataSink.swift
// TableProPluginKit
//

import Foundation

public protocol PluginImportDataSink: AnyObject, Sendable {
var databaseTypeId: String { get }
func execute(statement: String) async throws
func beginTransaction() async throws
func commitTransaction() async throws
func rollbackTransaction() async throws
func disableForeignKeyChecks() async throws
func enableForeignKeyChecks() async throws
}

public extension PluginImportDataSink {
func disableForeignKeyChecks() async throws {}
func enableForeignKeyChecks() async throws {}
}
91 changes: 91 additions & 0 deletions Plugins/TableProPluginKit/PluginImportProgress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
//
// PluginImportProgress.swift
// TableProPluginKit
//

import Foundation

public final class PluginImportProgress: @unchecked Sendable {
private let lock = NSLock()
private var _processedStatements: Int = 0
private var _estimatedTotalStatements: Int = 0
private var _statusMessage: String = ""
private var _isCancelled: Bool = false

private let updateInterval: Int = 500
private var internalCount: Int = 0

public var onUpdate: (@Sendable (Int, Int, String) -> Void)?

public init() {}

public func setEstimatedTotal(_ count: Int) {
lock.lock()
_estimatedTotalStatements = count
lock.unlock()
}

public func incrementStatement() {
lock.lock()
internalCount += 1
_processedStatements = internalCount
let shouldNotify = internalCount % updateInterval == 0
lock.unlock()
if shouldNotify {
notifyUpdate()
}
}

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 PluginImportCancellationError()
}
}

public func cancel() {
lock.lock()
_isCancelled = true
lock.unlock()
}

public var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return _isCancelled
}

public var processedStatements: Int {
lock.lock()
defer { lock.unlock() }
return _processedStatements
}

public var estimatedTotalStatements: Int {
lock.lock()
defer { lock.unlock() }
return _estimatedTotalStatements
}

public func finalize() {
notifyUpdate()
}

private func notifyUpdate() {
lock.lock()
let processed = _processedStatements
let total = _estimatedTotalStatements
let status = _statusMessage
lock.unlock()
onUpdate?(processed, total, status)
}
}
12 changes: 12 additions & 0 deletions Plugins/TableProPluginKit/PluginImportSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//
// PluginImportSource.swift
// TableProPluginKit
//

import Foundation

public protocol PluginImportSource: AnyObject, Sendable {
func statements() async throws -> AsyncThrowingStream<(statement: String, lineNumber: Int), Error>
func fileURL() -> URL
func fileSizeBytes() -> Int64
}
50 changes: 50 additions & 0 deletions Plugins/TableProPluginKit/PluginImportTypes.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
//
// PluginImportTypes.swift
// TableProPluginKit
//

import Foundation

public struct PluginImportResult: Sendable {
public let executedStatements: Int
public let executionTime: TimeInterval
public let failedStatement: String?
public let failedLine: Int?

public init(
executedStatements: Int,
executionTime: TimeInterval,
failedStatement: String? = nil,
failedLine: Int? = nil
) {
self.executedStatements = executedStatements
self.executionTime = executionTime
self.failedStatement = failedStatement
self.failedLine = failedLine
}
}

public enum PluginImportError: LocalizedError {
case statementFailed(statement: String, line: Int, underlyingError: any Error)
case rollbackFailed(underlyingError: any Error)
case cancelled
case importFailed(String)

public var errorDescription: String? {
switch self {
case .statementFailed(_, let line, let error):
return "Import failed at line \(line): \(error.localizedDescription)"
case .rollbackFailed(let error):
return "Transaction rollback failed: \(error.localizedDescription)"
case .cancelled:
return "Import cancelled"
case .importFailed(let message):
return "Import failed: \(message)"
}
}
}

public struct PluginImportCancellationError: Error, LocalizedError {
public init() {}
public var errorDescription: String? { "Import cancelled" }
}
Loading