diff --git a/CHANGELOG.md b/CHANGELOG.md
index fdad6fc8..f7977bcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist
new file mode 100644
index 00000000..12b650a8
--- /dev/null
+++ b/Plugins/SQLImportPlugin/Info.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ TableProPluginKitVersion
+ 1
+
+
diff --git a/Plugins/SQLImportPlugin/SQLImportOptions.swift b/Plugins/SQLImportPlugin/SQLImportOptions.swift
new file mode 100644
index 00000000..290418fd
--- /dev/null
+++ b/Plugins/SQLImportPlugin/SQLImportOptions.swift
@@ -0,0 +1,12 @@
+//
+// SQLImportOptions.swift
+// SQLImportPlugin
+//
+
+import Foundation
+
+@Observable
+final class SQLImportOptions {
+ var wrapInTransaction: Bool = true
+ var disableForeignKeyChecks: Bool = true
+}
diff --git a/Plugins/SQLImportPlugin/SQLImportOptionsView.swift b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift
new file mode 100644
index 00000000..931badb0
--- /dev/null
+++ b/Plugins/SQLImportPlugin/SQLImportOptionsView.swift
@@ -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."
+ )
+ }
+ }
+}
diff --git a/Plugins/SQLImportPlugin/SQLImportPlugin.swift b/Plugins/SQLImportPlugin/SQLImportPlugin.swift
new file mode 100644
index 00000000..04b07d98
--- /dev/null
+++ b/Plugins/SQLImportPlugin/SQLImportPlugin.swift
@@ -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)
+ )
+ }
+}
diff --git a/Plugins/TableProPluginKit/ImportFormatPlugin.swift b/Plugins/TableProPluginKit/ImportFormatPlugin.swift
new file mode 100644
index 00000000..58a1eaf9
--- /dev/null
+++ b/Plugins/TableProPluginKit/ImportFormatPlugin.swift
@@ -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 }
+}
diff --git a/Plugins/TableProPluginKit/PluginImportDataSink.swift b/Plugins/TableProPluginKit/PluginImportDataSink.swift
new file mode 100644
index 00000000..ea70ec46
--- /dev/null
+++ b/Plugins/TableProPluginKit/PluginImportDataSink.swift
@@ -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 {}
+}
diff --git a/Plugins/TableProPluginKit/PluginImportProgress.swift b/Plugins/TableProPluginKit/PluginImportProgress.swift
new file mode 100644
index 00000000..b1b1c94e
--- /dev/null
+++ b/Plugins/TableProPluginKit/PluginImportProgress.swift
@@ -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)
+ }
+}
diff --git a/Plugins/TableProPluginKit/PluginImportSource.swift b/Plugins/TableProPluginKit/PluginImportSource.swift
new file mode 100644
index 00000000..b2360985
--- /dev/null
+++ b/Plugins/TableProPluginKit/PluginImportSource.swift
@@ -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
+}
diff --git a/Plugins/TableProPluginKit/PluginImportTypes.swift b/Plugins/TableProPluginKit/PluginImportTypes.swift
new file mode 100644
index 00000000..d9cacf80
--- /dev/null
+++ b/Plugins/TableProPluginKit/PluginImportTypes.swift
@@ -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" }
+}
diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj
index cf4cf6bd..64c0a943 100644
--- a/TablePro.xcodeproj/project.pbxproj
+++ b/TablePro.xcodeproj/project.pbxproj
@@ -34,6 +34,8 @@
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, ); }; };
+ 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; };
+ 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; };
5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; };
5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; };
5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; };
@@ -141,6 +143,13 @@
remoteGlobalIDString = 5A86E000000000000;
remoteInfo = MQLExport;
};
+ 5A86F000B00000000 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 5A86F000000000000;
+ remoteInfo = SQLImport;
+ };
5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */;
@@ -170,6 +179,7 @@
5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */,
5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */,
5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */,
+ 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */,
);
name = "Copy Plug-Ins";
runOnlyForDeploymentPostprocessing = 0;
@@ -203,6 +213,7 @@
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; };
+ 5A86F000100000000 /* SQLImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SQLImport.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 */
@@ -306,6 +317,13 @@
);
target = 5A86E000000000000 /* MQLExport */;
};
+ 5A86F000900000000 /* Exceptions for "Plugins/SQLImportPlugin" folder in "SQLImport" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 5A86F000000000000 /* SQLImport */;
+ };
5AF312BE2F36FF7500E86682 /* Exceptions for "TablePro" folder in "TablePro" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
@@ -436,6 +454,14 @@
path = Plugins/MQLExportPlugin;
sourceTree = "";
};
+ 5A86F000500000000 /* Plugins/SQLImportPlugin */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 5A86F000900000000 /* Exceptions for "Plugins/SQLImportPlugin" folder in "SQLImport" target */,
+ );
+ path = Plugins/SQLImportPlugin;
+ sourceTree = "";
+ };
5ABCC5A82F43856700EAF3FC /* TableProTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = TableProTests;
@@ -568,6 +594,14 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A86F000300000000 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A42F43856700EAF3FC /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -604,6 +638,7 @@
5A86C000500000000 /* Plugins/SQLExportPlugin */,
5A86D000500000000 /* Plugins/XLSXExportPlugin */,
5A86E000500000000 /* Plugins/MQLExportPlugin */,
+ 5A86F000500000000 /* Plugins/SQLImportPlugin */,
5ABCC5A82F43856700EAF3FC /* TableProTests */,
5A1091C82EF17EDC0055EA7C /* Products */,
5A05FBC72F3EDF7500819CD7 /* Recovered References */,
@@ -628,6 +663,7 @@
5A86C000100000000 /* SQLExport.tableplugin */,
5A86D000100000000 /* XLSXExport.tableplugin */,
5A86E000100000000 /* MQLExport.tableplugin */,
+ 5A86F000100000000 /* SQLImport.tableplugin */,
5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */,
);
name = Products;
@@ -673,6 +709,7 @@
5A86C000C00000000 /* PBXTargetDependency */,
5A86D000C00000000 /* PBXTargetDependency */,
5A86E000C00000000 /* PBXTargetDependency */,
+ 5A86F000C00000000 /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5A1091C92EF17EDC0055EA7C /* TablePro */,
@@ -973,6 +1010,26 @@
productReference = 5A86E000100000000 /* MQLExport.tableplugin */;
productType = "com.apple.product-type.bundle";
};
+ 5A86F000000000000 /* SQLImport */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 5A86F000800000000 /* Build configuration list for PBXNativeTarget "SQLImport" */;
+ buildPhases = (
+ 5A86F000200000000 /* Sources */,
+ 5A86F000300000000 /* Frameworks */,
+ 5A86F000400000000 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 5A86F000500000000 /* Plugins/SQLImportPlugin */,
+ );
+ name = SQLImport;
+ productName = SQLImport;
+ productReference = 5A86F000100000000 /* SQLImport.tableplugin */;
+ productType = "com.apple.product-type.bundle";
+ };
5ABCC5A62F43856700EAF3FC /* TableProTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */;
@@ -1093,6 +1150,7 @@
5A86C000000000000 /* SQLExport */,
5A86D000000000000 /* XLSXExport */,
5A86E000000000000 /* MQLExport */,
+ 5A86F000000000000 /* SQLImport */,
5ABCC5A62F43856700EAF3FC /* TableProTests */,
);
};
@@ -1204,6 +1262,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A86F000400000000 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A52F43856700EAF3FC /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -1319,6 +1384,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 5A86F000200000000 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
5ABCC5A32F43856700EAF3FC /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -1399,6 +1471,11 @@
target = 5A86E000000000000 /* MQLExport */;
targetProxy = 5A86E000B00000000 /* PBXContainerItemProxy */;
};
+ 5A86F000C00000000 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 5A86F000000000000 /* SQLImport */;
+ targetProxy = 5A86F000B00000000 /* PBXContainerItemProxy */;
+ };
5ABCC5AC2F43856700EAF3FC /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5A1091C62EF17EDC0055EA7C /* TablePro */;
@@ -2402,6 +2479,29 @@
};
name = Debug;
};
+ 5A86F000600000000 /* 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/SQLImportPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
+ 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 = {
@@ -2425,6 +2525,29 @@
};
name = Release;
};
+ 5A86F000700000000 /* 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/SQLImportPlugin/Info.plist;
+ INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).SQLImportPlugin";
+ LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.SQLImportPlugin;
+ 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 = {
@@ -2616,6 +2739,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 5A86F000800000000 /* Build configuration list for PBXNativeTarget "SQLImport" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 5A86F000600000000 /* Debug */,
+ 5A86F000700000000 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
5ABCC5AD2F43856700EAF3FC /* Build configuration list for PBXNativeTarget "TableProTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift
new file mode 100644
index 00000000..96bb6e37
--- /dev/null
+++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift
@@ -0,0 +1,78 @@
+//
+// ImportDataSinkAdapter.swift
+// TablePro
+//
+
+import Foundation
+import os
+import TableProPluginKit
+
+final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
+ let databaseTypeId: String
+ private let driver: DatabaseDriver
+ private let dbType: DatabaseType
+
+ private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDataSinkAdapter")
+
+ init(driver: DatabaseDriver, databaseType: DatabaseType) {
+ self.driver = driver
+ self.dbType = databaseType
+ self.databaseTypeId = databaseType.rawValue
+ }
+
+ func execute(statement: String) async throws {
+ _ = try await driver.execute(query: statement)
+ }
+
+ func beginTransaction() async throws {
+ try await driver.beginTransaction()
+ }
+
+ func commitTransaction() async throws {
+ try await driver.commitTransaction()
+ }
+
+ func rollbackTransaction() async throws {
+ try await driver.rollbackTransaction()
+ }
+
+ func disableForeignKeyChecks() async throws {
+ for stmt in fkDisableStatements() {
+ _ = try await driver.execute(query: stmt)
+ }
+ }
+
+ func enableForeignKeyChecks() async throws {
+ for stmt in fkEnableStatements() {
+ _ = try await driver.execute(query: stmt)
+ }
+ }
+
+ // MARK: - FK Statements
+
+ private func fkDisableStatements() -> [String] {
+ switch dbType {
+ case .mysql, .mariadb:
+ return ["SET FOREIGN_KEY_CHECKS=0"]
+ case .postgresql, .redshift, .mssql, .oracle:
+ return []
+ case .sqlite:
+ return ["PRAGMA foreign_keys = OFF"]
+ case .mongodb, .redis, .clickhouse:
+ return []
+ }
+ }
+
+ private func fkEnableStatements() -> [String] {
+ switch dbType {
+ case .mysql, .mariadb:
+ return ["SET FOREIGN_KEY_CHECKS=1"]
+ case .postgresql, .redshift, .mssql, .oracle:
+ return []
+ case .sqlite:
+ return ["PRAGMA foreign_keys = ON"]
+ case .mongodb, .redis, .clickhouse:
+ return []
+ }
+ }
+}
diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift
index 34677106..9a919f8c 100644
--- a/TablePro/Core/Plugins/PluginManager.swift
+++ b/TablePro/Core/Plugins/PluginManager.swift
@@ -23,6 +23,8 @@ final class PluginManager {
private(set) var exportPlugins: [String: any ExportFormatPlugin] = [:]
+ private(set) var importPlugins: [String: any ImportFormatPlugin] = [:]
+
private var builtInPluginsDir: URL? { Bundle.main.builtInPlugInsURL }
private var userPluginsDir: URL {
@@ -78,7 +80,7 @@ final class PluginManager {
}
validateDependencies()
- Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s)")
+ Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)")
}
private func discoverPlugins(from directory: URL, source: PluginSource) {
@@ -217,12 +219,22 @@ final class PluginManager {
exportPlugins[formatId] = exportPlugin
Self.logger.debug("Registered export plugin '\(pluginId)' for format '\(formatId)'")
}
+
+ if let importPlugin = instance as? any ImportFormatPlugin {
+ if !declared.contains(.importFormat) {
+ Self.logger.warning("Plugin '\(pluginId)' conforms to ImportFormatPlugin but does not declare .importFormat capability — registering anyway")
+ }
+ let formatId = type(of: importPlugin).formatId
+ importPlugins[formatId] = importPlugin
+ Self.logger.debug("Registered import plugin '\(pluginId)' for format '\(formatId)'")
+ }
}
private func validateCapabilityDeclarations(_ pluginType: any TableProPlugin.Type, pluginId: String) {
let declared = Set(pluginType.capabilities)
let isDriver = pluginType is any DriverPlugin.Type
let isExporter = pluginType is any ExportFormatPlugin.Type
+ let isImporter = pluginType is any ImportFormatPlugin.Type
if declared.contains(.databaseDriver) && !isDriver {
Self.logger.warning("Plugin '\(pluginId)' declares .databaseDriver but does not conform to DriverPlugin")
@@ -230,6 +242,9 @@ final class PluginManager {
if declared.contains(.exportFormat) && !isExporter {
Self.logger.warning("Plugin '\(pluginId)' declares .exportFormat but does not conform to ExportFormatPlugin")
}
+ if declared.contains(.importFormat) && !isImporter {
+ Self.logger.warning("Plugin '\(pluginId)' declares .importFormat but does not conform to ImportFormatPlugin")
+ }
}
private func replaceExistingPlugin(bundleId: String) {
@@ -257,6 +272,14 @@ final class PluginManager {
}
return true
}
+
+ importPlugins = importPlugins.filter { _, value in
+ guard let entry = plugins.first(where: { $0.id == pluginId }) else { return true }
+ if let principalClass = entry.bundle.principalClass as? any ImportFormatPlugin.Type {
+ return principalClass.formatId != type(of: value).formatId
+ }
+ return true
+ }
}
// MARK: - Enable / Disable
diff --git a/TablePro/Core/Plugins/SqlFileImportSource.swift b/TablePro/Core/Plugins/SqlFileImportSource.swift
new file mode 100644
index 00000000..fdf00f03
--- /dev/null
+++ b/TablePro/Core/Plugins/SqlFileImportSource.swift
@@ -0,0 +1,103 @@
+//
+// SqlFileImportSource.swift
+// TablePro
+//
+
+import Foundation
+import os
+import TableProPluginKit
+
+final class SqlFileImportSource: PluginImportSource, @unchecked Sendable {
+ private static let logger = Logger(subsystem: "com.TablePro", category: "SqlFileImportSource")
+
+ private let url: URL
+ private let encoding: String.Encoding
+ private let parser = SQLFileParser()
+
+ private let lock = NSLock()
+ private var decompressedURL: URL?
+
+ init(url: URL, encoding: String.Encoding) {
+ self.url = url
+ self.encoding = encoding
+ }
+
+ func fileURL() -> URL {
+ url
+ }
+
+ func fileSizeBytes() -> Int64 {
+ do {
+ let attrs = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false))
+ return attrs[.size] as? Int64 ?? 0
+ } catch {
+ Self.logger.warning("Failed to get file size for \(self.url.path(percentEncoded: false)): \(error.localizedDescription)")
+ return 0
+ }
+ }
+
+ func statements() async throws -> AsyncThrowingStream<(statement: String, lineNumber: Int), Error> {
+ let fileURL = try await decompressIfNeeded()
+
+ let stream = try await parser.parseFile(url: fileURL, encoding: encoding)
+
+ return AsyncThrowingStream { continuation in
+ Task {
+ do {
+ for try await item in stream {
+ continuation.yield(item)
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ func cleanup() {
+ lock.lock()
+ let tempURL = decompressedURL
+ decompressedURL = nil
+ lock.unlock()
+
+ if let tempURL {
+ do {
+ try FileManager.default.removeItem(at: tempURL)
+ } catch {
+ Self.logger.warning("Failed to clean up temp file: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ deinit {
+ // Best-effort cleanup — decompressedURL is non-isolated, use lock
+ lock.lock()
+ let tempURL = decompressedURL
+ lock.unlock()
+ if let tempURL {
+ try? FileManager.default.removeItem(at: tempURL)
+ }
+ }
+
+ // MARK: - Private
+
+ private func decompressIfNeeded() async throws -> URL {
+ lock.lock()
+ if let existing = decompressedURL {
+ lock.unlock()
+ return existing
+ }
+ lock.unlock()
+
+ let result = try await FileDecompressor.decompressIfNeeded(url) { $0.path() }
+
+ if result != url {
+ lock.lock()
+ decompressedURL = result
+ lock.unlock()
+ }
+
+ return result
+ }
+}
diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift
index 529ce034..1543e10b 100644
--- a/TablePro/Core/Services/Export/ExportService.swift
+++ b/TablePro/Core/Services/Export/ExportService.swift
@@ -265,27 +265,3 @@ final class ExportService {
return total
}
}
-
-// 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
- }
-
- func clearPending() {
- lock.lock()
- isPending = false
- lock.unlock()
- }
-}
diff --git a/TablePro/Core/Services/Export/ImportService.swift b/TablePro/Core/Services/Export/ImportService.swift
index 19c591fd..70a4ec90 100644
--- a/TablePro/Core/Services/Export/ImportService.swift
+++ b/TablePro/Core/Services/Export/ImportService.swift
@@ -2,234 +2,115 @@
// ImportService.swift
// TablePro
//
-// Service responsible for importing SQL files with transaction support
-// and foreign key handling.
+// Plugin-driven import orchestrator. Resolves the import format plugin,
+// creates the adapter/source objects, and wires progress to the UI.
//
import Foundation
import Observation
import os
+import TableProPluginKit
// MARK: - Import State
-/// Consolidated state struct to minimize @Published update overhead.
-/// A single @Published property avoids N separate objectWillChange notifications per statement.
struct ImportState {
var isImporting: Bool = false
var progress: Double = 0.0
- var currentStatement: String = ""
- var currentStatementIndex: Int = 0
- var totalStatements: Int = 0
+ var processedStatements: Int = 0
+ var estimatedTotalStatements: Int = 0
var statusMessage: String = ""
var errorMessage: String?
}
// MARK: - Import Service
-/// Service responsible for importing SQL files
@MainActor @Observable
final class ImportService {
private static let logger = Logger(subsystem: "com.TablePro", category: "ImportService")
- // MARK: - Published State
var state = ImportState()
- // MARK: - Cancellation
-
- // Lock is required despite @MainActor because _isCancelled is read from background Tasks
- private let isCancelledLock = NSLock()
- private var _isCancelled: Bool = false
-
- private var isCancelled: Bool {
- get {
- isCancelledLock.lock()
- defer { isCancelledLock.unlock() }
- return _isCancelled
- }
- set {
- isCancelledLock.lock()
- defer { isCancelledLock.unlock() }
- _isCancelled = newValue
- }
- }
-
- func cancelImport() {
- isCancelled = true
- }
-
- // MARK: - Dependencies
-
private let connection: DatabaseConnection
- private let parser = SQLFileParser()
-
- // MARK: - Initialization
+ private var currentProgress: PluginImportProgress?
init(connection: DatabaseConnection) {
self.connection = connection
}
- // MARK: - Public API
+ // MARK: - Cancellation
- /// Import SQL file
- /// - Parameters:
- /// - url: File URL to import
- /// - config: Import configuration
- /// - Returns: Import result with execution summary
- func importSQL(
- from url: URL,
- config: ImportConfiguration
- ) async throws -> ImportResult {
- state = ImportState(isImporting: true)
- isCancelled = false
+ func cancelImport() {
+ currentProgress?.cancel()
+ }
- defer {
- state.isImporting = false
- }
+ // MARK: - Public API
- // 1. Decompress .gz if needed
- let fileURL = try await decompressIfNeeded(url)
- let needsCleanup = fileURL != url
- defer {
- if needsCleanup {
- do {
- try FileManager.default.removeItem(at: fileURL)
- } catch {
- Self.logger.warning("Failed to clean up temporary file at \(fileURL.path): \(error)")
- }
- }
+ func importFile(
+ from url: URL,
+ formatId: String,
+ encoding: String.Encoding
+ ) async throws -> PluginImportResult {
+ guard let plugin = PluginManager.shared.importPlugins[formatId] else {
+ throw PluginImportError.importFailed("Import format '\(formatId)' not found")
}
- // 2. Estimate statement count from file size (skip counting pass to avoid double-parsing)
- let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path(percentEncoded: false))
- let fileSizeBytes = attrs[.size] as? Int64 ?? 0
-
- // Rough heuristic: ~500 bytes per statement on average.
- // SQL dumps typically contain large INSERT/DDL statements (5k-50k bytes each),
- // so a smaller divisor (e.g. 200) grossly overestimates the count and causes the
- // progress bar to crawl and never visually reach 100%.
- let estimatedStatements = max(1, Int(fileSizeBytes / 500))
- state.totalStatements = estimatedStatements
-
- try checkCancellation()
-
- // 3. Get database driver
guard let driver = DatabaseManager.shared.driver(for: connection.id) else {
throw DatabaseError.notConnected
}
- let startTime = Date()
- var executedCount = 0
- var failedStatement: String?
- var failedLine: Int?
-
- do {
- // 4. Disable FK checks (if enabled) - BEFORE transaction
- if config.disableForeignKeyChecks {
- let fkDisableStmts = fkDisableStatements(for: connection.type)
- for stmt in fkDisableStmts {
- _ = try await driver.execute(query: stmt)
- }
- }
-
- // 5. Begin transaction (if enabled)
- if config.wrapInTransaction {
- try await driver.beginTransaction()
- }
-
- // 6. Parse and execute statements (single pass — no prior counting pass)
- let stream = try await parser.parseFile(url: fileURL, encoding: config.encoding)
-
- for try await (statement, lineNumber) in stream {
- try checkCancellation()
-
- let nsStmt = statement as NSString
- state.currentStatement = nsStmt.length > 50 ? nsStmt.substring(to: 50) + "..." : statement
- state.currentStatementIndex = executedCount + 1
-
- do {
- _ = try await driver.execute(query: statement)
-
- executedCount += 1
- state.progress = min(1.0, Double(executedCount) / Double(state.totalStatements))
- } catch {
- // Statement execution failed
- failedStatement = statement
- failedLine = lineNumber
+ // Reset state
+ state = ImportState(isImporting: true)
+ defer {
+ state.isImporting = false
+ currentProgress = nil
+ }
- throw ImportError.importFailed(
- statement: statement,
- line: lineNumber,
- error: error.localizedDescription
- )
+ // Create adapter and source
+ let sink = ImportDataSinkAdapter(driver: driver, databaseType: connection.type)
+ let source = SqlFileImportSource(url: url, encoding: encoding)
+ defer { source.cleanup() }
+
+ // Create progress tracker
+ let progress = PluginImportProgress()
+ currentProgress = progress
+
+ // Wire progress to UI state via coalescer
+ let pendingUpdate = ProgressUpdateCoalescer()
+ progress.onUpdate = { [weak self] processed, total, status in
+ let shouldDispatch = pendingUpdate.markPending()
+ if shouldDispatch {
+ Task { @MainActor [weak self] in
+ pendingUpdate.clearPending()
+ guard let self else { return }
+ self.state.processedStatements = processed
+ self.state.estimatedTotalStatements = total
+ if total > 0 {
+ self.state.progress = min(1.0, Double(processed) / Double(total))
+ }
+ if !status.isEmpty {
+ self.state.statusMessage = status
+ }
}
}
+ }
- // Update to actual count so UI shows correct final state
- state.totalStatements = executedCount
- state.currentStatementIndex = executedCount
- state.progress = 1.0
-
- // 7. Commit transaction (if enabled)
- if config.wrapInTransaction {
- try await driver.commitTransaction()
- }
-
- // 8. Re-enable FK checks (if enabled) - AFTER transaction
- if config.disableForeignKeyChecks {
- let fkEnableStmts = fkEnableStatements(for: connection.type)
- for stmt in fkEnableStmts {
- _ = try await driver.execute(query: stmt)
- }
- }
+ let result: PluginImportResult
+ do {
+ result = try await plugin.performImport(
+ source: source,
+ sink: sink,
+ progress: progress
+ )
} catch {
- // Rollback on error - this is CRITICAL and must not fail silently
- if config.wrapInTransaction {
- do {
- try await driver.rollbackTransaction()
- } catch let rollbackError {
- throw ImportError.rollbackFailed(rollbackError.localizedDescription)
- }
- }
-
- // Re-enable FK checks on error - important for data integrity
- if config.disableForeignKeyChecks {
- let fkEnableStmts = fkEnableStatements(for: connection.type)
- var fkReenableErrors: [String] = []
- for stmt in fkEnableStmts {
- do {
- _ = try await driver.execute(query: stmt)
- } catch let fkError {
- // FK re-enable failed - warn user but don't override original error
- // Store this as a warning that should be shown alongside the original error
- let message = fkError.localizedDescription
- fkReenableErrors.append(message)
- Self.logger.warning("Failed to re-enable FK checks: \(message)")
- // Note: We don't throw here to preserve the original import error
- // but we should log this for the user to see
- }
- }
+ state.errorMessage = error.localizedDescription
- // If FK re-enable failed, surface this information alongside the original error
- if !fkReenableErrors.isEmpty {
- let fkDetails = fkReenableErrors.joined(separator: "; ")
- let combinedMessage = """
- Import failed: \(error.localizedDescription)
- Additionally, failed to re-enable foreign key checks: \(fkDetails)
- """
- // Expose the combined message so callers / UI can present it to the user
- state.statusMessage = combinedMessage
- state.errorMessage = combinedMessage
- }
- }
-
- // Record a single summary history entry for the failed import
- let failedImportTime = Date().timeIntervalSince(startTime)
+ // Record failed import history
QueryHistoryManager.shared.recordQuery(
- query: "-- Import from \(fileURL.lastPathComponent) (\(executedCount) statements before failure)",
+ query: "-- Import from \(url.lastPathComponent) (\(progress.processedStatements) statements before failure)",
connectionId: connection.id,
databaseName: connection.database,
- executionTime: failedImportTime,
- rowCount: executedCount,
+ executionTime: 0,
+ rowCount: progress.processedStatements,
wasSuccessful: false,
errorMessage: error.localizedDescription
)
@@ -237,69 +118,22 @@ final class ImportService {
throw error
}
- let executionTime = Date().timeIntervalSince(startTime)
+ // Update final state
+ state.processedStatements = result.executedStatements
+ state.estimatedTotalStatements = result.executedStatements
+ state.progress = 1.0
- // Record a single summary history entry for the entire import
+ // Record success history
QueryHistoryManager.shared.recordQuery(
- query: "-- Import from \(fileURL.lastPathComponent) (\(executedCount) statements)",
+ query: "-- Import from \(url.lastPathComponent) (\(result.executedStatements) statements)",
connectionId: connection.id,
databaseName: connection.database,
- executionTime: executionTime,
- rowCount: executedCount,
+ executionTime: result.executionTime,
+ rowCount: result.executedStatements,
wasSuccessful: true,
errorMessage: nil
)
- return ImportResult(
- totalStatements: executedCount,
- executedStatements: executedCount,
- failedStatement: failedStatement,
- failedLine: failedLine,
- executionTime: executionTime
- )
- }
-
- // MARK: - Private Helpers
-
- /// Returns a filesystem path string for the given URL.
- private func fileSystemPath(for url: URL) -> String {
- url.path()
- }
-
- private func decompressIfNeeded(_ url: URL) async throws -> URL {
- try await FileDecompressor.decompressIfNeeded(url, fileSystemPath: fileSystemPath)
- }
-
- private func checkCancellation() throws {
- if isCancelled {
- throw ImportError.cancelled
- }
- }
-
- private func fkDisableStatements(for dbType: DatabaseType) -> [String] {
- switch dbType {
- case .mysql, .mariadb:
- return ["SET FOREIGN_KEY_CHECKS=0"]
- case .postgresql, .redshift, .mssql, .oracle:
- // These databases don't support globally disabling non-deferrable FKs.
- return []
- case .sqlite:
- return ["PRAGMA foreign_keys = OFF"]
- case .mongodb, .redis, .clickhouse:
- return []
- }
- }
-
- private func fkEnableStatements(for dbType: DatabaseType) -> [String] {
- switch dbType {
- case .mysql, .mariadb:
- return ["SET FOREIGN_KEY_CHECKS=1"]
- case .postgresql, .redshift, .mssql, .oracle:
- return []
- case .sqlite:
- return ["PRAGMA foreign_keys = ON"]
- case .mongodb, .redis, .clickhouse:
- return []
- }
+ return result
}
}
diff --git a/TablePro/Core/Services/Export/ProgressUpdateCoalescer.swift b/TablePro/Core/Services/Export/ProgressUpdateCoalescer.swift
new file mode 100644
index 00000000..4d87f089
--- /dev/null
+++ b/TablePro/Core/Services/Export/ProgressUpdateCoalescer.swift
@@ -0,0 +1,28 @@
+//
+// ProgressUpdateCoalescer.swift
+// TablePro
+//
+
+import Foundation
+
+/// Ensures only one `Task { @MainActor }` is in-flight at a time to prevent
+/// flooding the main actor queue during high-throughput exports/imports.
+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
+ }
+
+ func clearPending() {
+ lock.lock()
+ isPending = false
+ lock.unlock()
+ }
+}
diff --git a/TablePro/Core/Utilities/File/FileDecompressor.swift b/TablePro/Core/Utilities/File/FileDecompressor.swift
index 19ea3402..42a51059 100644
--- a/TablePro/Core/Utilities/File/FileDecompressor.swift
+++ b/TablePro/Core/Utilities/File/FileDecompressor.swift
@@ -8,6 +8,20 @@
import Foundation
import os
+enum DecompressionError: LocalizedError {
+ case decompressFailed
+ case fileReadFailed(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .decompressFailed:
+ return String(localized: "Failed to decompress .gz file")
+ case .fileReadFailed(let message):
+ return String(localized: "Failed to read file: \(message)")
+ }
+ }
+}
+
/// Utility for decompressing gzip-compressed files
enum FileDecompressor {
private static let logger = Logger(subsystem: "com.TablePro", category: "FileDecompressor")
@@ -26,7 +40,7 @@ enum FileDecompressor {
// Check if gunzip exists
let gunzipPath = "/usr/bin/gunzip"
guard FileManager.default.fileExists(atPath: gunzipPath) else {
- throw ImportError.fileReadFailed("gunzip not found at \(gunzipPath)")
+ throw DecompressionError.fileReadFailed("gunzip not found at \(gunzipPath)")
}
let tempURL = FileManager.default.temporaryDirectory
@@ -42,7 +56,7 @@ enum FileDecompressor {
let fileManager = FileManager.default
guard fileManager.createFile(atPath: tempURL.path, contents: nil, attributes: nil) else {
- throw ImportError.decompressFailed
+ throw DecompressionError.decompressFailed
}
let outputFile = try FileHandle(forWritingTo: tempURL)
defer {
@@ -65,7 +79,7 @@ enum FileDecompressor {
// Try to read error message
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error"
- throw ImportError.fileReadFailed("Failed to decompress .gz file: \(errorMessage)")
+ throw DecompressionError.fileReadFailed("Failed to decompress .gz file: \(errorMessage)")
}
return tempURL
diff --git a/TablePro/Models/Export/ImportModels.swift b/TablePro/Models/Export/ImportModels.swift
index 945e5fd2..9e622d58 100644
--- a/TablePro/Models/Export/ImportModels.swift
+++ b/TablePro/Models/Export/ImportModels.swift
@@ -2,20 +2,11 @@
// ImportModels.swift
// TablePro
//
-// Data models for SQL import functionality.
+// Encoding options for SQL import.
//
import Foundation
-// MARK: - Import Configuration
-
-/// Configuration for SQL import operation
-struct ImportConfiguration {
- var encoding: String.Encoding = .utf8
- var wrapInTransaction: Bool = true
- var disableForeignKeyChecks: Bool = true
-}
-
// MARK: - Import Encoding Options
/// Available text encodings for import
@@ -40,52 +31,3 @@ enum ImportEncoding: String, CaseIterable, Identifiable {
}
}
}
-
-// MARK: - Import Error
-
-/// Errors that can occur during import operations
-enum ImportError: LocalizedError {
- case fileNotFound
- case fileReadFailed(String)
- case decompressFailed
- case parseStatementFailed(line: Int, reason: String)
- case importFailed(statement: String, line: Int, error: String)
- case cancelled
- case invalidEncoding
- case rollbackFailed(String)
- case foreignKeyCleanupFailed(String)
-
- var errorDescription: String? {
- switch self {
- case .fileNotFound:
- return String(localized: "File not found")
- case .fileReadFailed(let message):
- return String(localized: "Failed to read file: \(message)")
- case .decompressFailed:
- return String(localized: "Failed to decompress .gz file")
- case .parseStatementFailed(let line, let reason):
- return String(localized: "Failed to parse statement at line \(line): \(reason)")
- case .importFailed(_, let line, let error):
- return String(localized: "Import failed at line \(line): \(error)")
- case .cancelled:
- return String(localized: "Import cancelled by user")
- case .invalidEncoding:
- return String(localized: "Invalid file encoding. Try a different encoding option.")
- case .rollbackFailed(let message):
- return String(localized: "CRITICAL: Transaction rollback failed - database may be in inconsistent state: \(message)")
- case .foreignKeyCleanupFailed(let message):
- return String(localized: "WARNING: Failed to re-enable foreign key checks: \(message). Please manually verify FK constraints are enabled.")
- }
- }
-}
-
-// MARK: - Import Result
-
-/// Result of import operation
-struct ImportResult {
- let totalStatements: Int
- let executedStatements: Int
- let failedStatement: String?
- let failedLine: Int?
- let executionTime: TimeInterval
-}
diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift
index d6359ddc..c0b11d10 100644
--- a/TablePro/Views/Import/ImportDialog.swift
+++ b/TablePro/Views/Import/ImportDialog.swift
@@ -2,16 +2,16 @@
// ImportDialog.swift
// TablePro
//
-// Main import dialog for importing SQL files.
+// Plugin-aware import dialog.
//
import AppKit
import Observation
import os
import SwiftUI
+import TableProPluginKit
import UniformTypeIdentifiers
-/// Main import dialog view
struct ImportDialog: View {
private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDialog")
@Binding var isPresented: Bool
@@ -25,19 +25,15 @@ struct ImportDialog: View {
@State private var fileSize: Int64 = 0
@State private var statementCount: Int = 0
@State private var isCountingStatements = false
- @State private var config = ImportConfiguration()
@State private var selectedEncoding: ImportEncoding = .utf8
+ @State private var selectedFormatId: String = "sql"
@State private var showProgressDialog = false
@State private var showSuccessDialog = false
@State private var showErrorDialog = false
- @State private var importResult: ImportResult?
- @State private var importError: ImportError?
+ @State private var importResult: PluginImportResult?
+ @State private var importError: (any Error)?
- // Track temp files for cleanup
@State private var tempPreviewURL: URL?
- @State private var tempCountURL: URL?
-
- // Track active tasks for cancellation
@State private var loadFileTask: Task?
// MARK: - Import Service
@@ -48,50 +44,55 @@ struct ImportDialog: View {
var body: some View {
VStack(spacing: 0) {
- // Content
VStack(spacing: 16) {
- // File info
fileInfoView
Divider()
- // Preview
+ if availableFormats.count > 1 {
+ formatPickerView
+ Divider()
+ }
+
filePreviewView
- // Options
- importOptionsView
+ optionsView
}
.padding(16)
.frame(width: 600, height: 550)
Divider()
- // Footer
footerView
}
.background(Color(nsColor: .windowBackgroundColor))
+ .onAppear {
+ PluginManager.shared.loadPendingPlugins()
+ let available = availableFormats
+ if !available.contains(where: { type(of: $0).formatId == selectedFormatId }) {
+ if let first = available.first {
+ selectedFormatId = type(of: first).formatId
+ }
+ }
+ }
.onExitCommand {
if !importServiceState.isImporting {
isPresented = false
}
}
.task {
- // Load initial file if provided
if let initialURL = initialFileURL, fileURL == nil {
await loadFile(initialURL)
}
}
.onDisappear {
- // Cancel any in-progress file loading when dialog is dismissed
loadFileTask?.cancel()
- // Clean up temp files when dialog is dismissed
cleanupTempFiles()
}
.sheet(isPresented: $showProgressDialog) {
ImportProgressView(
- currentStatement: importServiceState.currentStatement,
- statementIndex: importServiceState.currentStatementIndex,
- totalStatements: importServiceState.totalStatements,
+ processedStatements: importServiceState.processedStatements,
+ estimatedTotalStatements: importServiceState.estimatedTotalStatements,
statusMessage: importServiceState.statusMessage
) {
importServiceState.service?.cancelImport()
@@ -104,7 +105,6 @@ struct ImportDialog: View {
) {
showSuccessDialog = false
isPresented = false
- // Refresh schema
NotificationCenter.default.post(name: .refreshData, object: nil)
}
}
@@ -117,19 +117,34 @@ struct ImportDialog: View {
}
}
- // MARK: - View Components
+ // MARK: - Plugin Helpers
- private var fileSelectionView: some View {
- Button(fileURL == nil ? String(localized: "Select SQL File...") : String(localized: "Change File")) {
- selectFile()
- }
- .buttonStyle(.borderedProminent)
+ private var availableFormats: [any ImportFormatPlugin] {
+ let dbTypeId = connection.type.rawValue
+ return PluginManager.shared.importPlugins.values
+ .filter { plugin in
+ let supported = type(of: plugin).supportedDatabaseTypeIds
+ let excluded = type(of: plugin).excludedDatabaseTypeIds
+ if !supported.isEmpty && !supported.contains(dbTypeId) {
+ return false
+ }
+ if excluded.contains(dbTypeId) {
+ return false
+ }
+ return true
+ }
+ .sorted { type(of: $0).formatDisplayName < type(of: $1).formatDisplayName }
}
+ private var currentPlugin: (any ImportFormatPlugin)? {
+ PluginManager.shared.importPlugins[selectedFormatId]
+ }
+
+ // MARK: - View Components
+
private var fileInfoView: some View {
HStack(alignment: .top, spacing: 12) {
- // File icon
- Image(systemName: "doc.text.fill")
+ Image(systemName: currentPlugin.map { type(of: $0).iconName } ?? "doc.text.fill")
.font(.system(size: 32))
.foregroundStyle(.blue)
@@ -174,6 +189,24 @@ struct ImportDialog: View {
.frame(maxWidth: .infinity, alignment: .leading)
}
+ private var formatPickerView: some View {
+ HStack(spacing: 8) {
+ Text("Format:")
+ .font(.system(size: 13))
+ .frame(width: 80, alignment: .leading)
+
+ Picker("", selection: $selectedFormatId) {
+ ForEach(availableFormats.map { (id: type(of: $0).formatId, name: type(of: $0).formatDisplayName) }, id: \.id) { item in
+ Text(item.name).tag(item.id)
+ }
+ }
+ .pickerStyle(.menu)
+ .frame(width: 120)
+
+ Spacer()
+ }
+ }
+
private var filePreviewView: some View {
VStack(alignment: .leading, spacing: 6) {
Text("Preview")
@@ -181,7 +214,7 @@ struct ImportDialog: View {
.foregroundStyle(.primary)
SQLCodePreview(text: $filePreview)
- .frame(height: 280)
+ .frame(height: availableFormats.count > 1 ? 220 : 280)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -190,14 +223,14 @@ struct ImportDialog: View {
}
}
- private var importOptionsView: some View {
+ private var optionsView: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Options")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.primary)
VStack(alignment: .leading, spacing: 12) {
- // Encoding picker
+ // Encoding picker (always shown, independent of plugin)
HStack(spacing: 8) {
Text("Encoding:")
.font(.system(size: 13))
@@ -210,11 +243,8 @@ struct ImportDialog: View {
}
.pickerStyle(.menu)
.frame(width: 120)
- .onChange(of: selectedEncoding) { _, newEncoding in
- config.encoding = newEncoding.encoding
- // Cancel previous task to avoid race conditions
+ .onChange(of: selectedEncoding) { _, _ in
loadFileTask?.cancel()
- // Reload preview with new encoding
if let url = fileURL {
loadFileTask = Task {
await loadFile(url)
@@ -225,19 +255,10 @@ struct ImportDialog: View {
Spacer()
}
- // Transaction checkbox
- Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: $config.wrapInTransaction)
- .font(.system(size: 13))
- .help(
- "Execute all statements in a single transaction. If any statement fails, all changes are rolled back."
- )
-
- // FK checkbox
- Toggle("Disable foreign key checks", isOn: $config.disableForeignKeyChecks)
- .font(.system(size: 13))
- .help(
- "Temporarily disable foreign key constraints during import. Useful for importing data with circular dependencies."
- )
+ // Plugin-provided options
+ if let pluginView = currentPlugin?.optionsView() {
+ pluginView
+ }
}
}
.frame(maxWidth: .infinity, alignment: .leading)
@@ -255,7 +276,7 @@ struct ImportDialog: View {
performImport()
}
.buttonStyle(.borderedProminent)
- .disabled(fileURL == nil || importServiceState.isImporting)
+ .disabled(fileURL == nil || importServiceState.isImporting || availableFormats.isEmpty)
.keyboardShortcut(.return, modifiers: [])
}
.padding(16)
@@ -266,10 +287,11 @@ struct ImportDialog: View {
private func selectFile() {
let panel = NSOpenPanel()
- let allowedTypes = ["sql", "gz"].compactMap { UTType(filenameExtension: $0) }
+ let extensions = currentPlugin.map { type(of: $0).acceptedFileExtensions } ?? ["sql", "gz"]
+ let allowedTypes = extensions.compactMap { UTType(filenameExtension: $0) }
panel.allowedContentTypes = allowedTypes.isEmpty ? [.data] : allowedTypes
panel.allowsMultipleSelection = false
- panel.message = "Select SQL file to import"
+ panel.message = "Select file to import"
panel.begin { response in
guard response == .OK, let url = panel.url else { return }
@@ -282,10 +304,8 @@ struct ImportDialog: View {
@MainActor
private func loadFile(_ url: URL) async {
- // Clean up previous temp files
cleanupTempFiles()
- // Validate that the URL points to a regular file, not a directory or symlink
var isDirectory: ObjCBool = false
guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory),
!isDirectory.boolValue
@@ -296,7 +316,6 @@ struct ImportDialog: View {
fileURL = url
- // Get file size
do {
let attrs = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false))
fileSize = attrs[.size] as? Int64 ?? 0
@@ -305,11 +324,9 @@ struct ImportDialog: View {
fileSize = 0
}
- // Decompress .gz files before preview
let urlToRead: URL
do {
urlToRead = try await decompressIfNeeded(url)
- // Track temp file if decompression occurred
if urlToRead != url {
tempPreviewURL = urlToRead
}
@@ -318,7 +335,6 @@ struct ImportDialog: View {
return
}
- // Load preview (up to 5MB for preview)
do {
let handle = try FileHandle(forReadingFrom: urlToRead)
defer {
@@ -329,21 +345,18 @@ struct ImportDialog: View {
}
}
- // Load up to 5MB for preview (enough for most SQL files)
- let maxPreviewSize = 5 * 1_024 * 1_024 // 5 MB
+ let maxPreviewSize = 5 * 1_024 * 1_024
let previewData = handle.readData(ofLength: maxPreviewSize)
- if let preview = String(data: previewData, encoding: config.encoding) {
+ if let preview = String(data: previewData, encoding: selectedEncoding.encoding) {
filePreview = preview
} else {
- let encodingDescription = String(describing: config.encoding)
- filePreview = String(localized: "Failed to load preview using encoding: \(encodingDescription). Try selecting a different text encoding from the encoding picker and reload the preview.")
+ filePreview = String(localized: "Failed to load preview using encoding: \(selectedEncoding.rawValue). Try selecting a different text encoding.")
}
} catch {
filePreview = String(localized: "Failed to load preview: \(error.localizedDescription)")
}
- // Count statements asynchronously
Task {
await countStatements(url: urlToRead)
}
@@ -355,14 +368,13 @@ struct ImportDialog: View {
statementCount = 0
do {
- let encoding = config.encoding
+ let encoding = selectedEncoding.encoding
let parser = SQLFileParser()
let count = try await Task.detached {
try await parser.countStatements(url: url, encoding: encoding)
}.value
statementCount = count
} catch {
- // If counting fails, use a sentinel value to distinguish from a real 0
statementCount = -1
}
@@ -379,30 +391,27 @@ struct ImportDialog: View {
Task {
do {
- let result = try await service.importSQL(from: url, config: config)
+ let result = try await service.importFile(
+ from: url,
+ formatId: selectedFormatId,
+ encoding: selectedEncoding.encoding
+ )
await MainActor.run {
showProgressDialog = false
importResult = result
showSuccessDialog = true
}
- } catch let error as ImportError {
- await MainActor.run {
- showProgressDialog = false
- importError = error
- showErrorDialog = true
- }
} catch {
await MainActor.run {
showProgressDialog = false
- importError = ImportError.fileReadFailed(error.localizedDescription)
+ importError = error
showErrorDialog = true
}
}
}
}
- /// Clean up temporary decompressed files
private func cleanupTempFiles() {
if let tempURL = tempPreviewURL {
do {
@@ -414,24 +423,12 @@ struct ImportDialog: View {
}
tempPreviewURL = nil
}
- if let tempURL = tempCountURL {
- do {
- try FileManager.default.removeItem(at: tempURL)
- } catch {
- Self.logger.error(
- "cleanupTempFiles: Failed to remove tempCountURL at \(tempURL.path(percentEncoded: false), privacy: .public): \(error.localizedDescription, privacy: .public)"
- )
- }
- tempCountURL = nil
- }
}
- /// Returns filesystem path for URL.
private func fileSystemPath(for url: URL) -> String {
url.path()
}
- /// Decompress .gz file if needed, returns URL to read
private func decompressIfNeeded(_ url: URL) async throws -> URL {
try await FileDecompressor.decompressIfNeeded(url, fileSystemPath: fileSystemPath)
}
@@ -449,8 +446,7 @@ final class ImportServiceState {
}
var isImporting: Bool { service?.state.isImporting ?? false }
- var currentStatement: String { service?.state.currentStatement ?? "" }
- var currentStatementIndex: Int { service?.state.currentStatementIndex ?? 0 }
- var totalStatements: Int { service?.state.totalStatements ?? 0 }
+ var processedStatements: Int { service?.state.processedStatements ?? 0 }
+ var estimatedTotalStatements: Int { service?.state.estimatedTotalStatements ?? 0 }
var statusMessage: String { service?.state.statusMessage ?? "" }
}
diff --git a/TablePro/Views/Import/ImportErrorView.swift b/TablePro/Views/Import/ImportErrorView.swift
index e1ece640..6fca0652 100644
--- a/TablePro/Views/Import/ImportErrorView.swift
+++ b/TablePro/Views/Import/ImportErrorView.swift
@@ -2,13 +2,14 @@
// ImportErrorView.swift
// TablePro
//
-// Error dialog shown when SQL import fails.
+// Error dialog shown when import fails.
//
import SwiftUI
+import TableProPluginKit
struct ImportErrorView: View {
- let error: ImportError?
+ let error: (any Error)?
let onClose: () -> Void
var body: some View {
@@ -21,7 +22,9 @@ struct ImportErrorView: View {
Text("Import Failed")
.font(.system(size: 15, weight: .semibold))
- if case .importFailed(let statement, let line, let errorMsg) = error {
+ if let pluginError = error as? PluginImportError,
+ case .statementFailed(let statement, let line, let underlyingError) = pluginError
+ {
Text("Failed at line \(line)")
.font(.system(size: 13))
.foregroundStyle(.secondary)
@@ -38,7 +41,7 @@ struct ImportErrorView: View {
Text("Error:")
.font(.system(size: 12, weight: .medium))
.padding(.top, 8)
- Text(errorMsg)
+ Text(underlyingError.localizedDescription)
.font(.system(size: 11))
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
diff --git a/TablePro/Views/Import/ImportProgressView.swift b/TablePro/Views/Import/ImportProgressView.swift
index d2a1b8ec..35a57c32 100644
--- a/TablePro/Views/Import/ImportProgressView.swift
+++ b/TablePro/Views/Import/ImportProgressView.swift
@@ -2,21 +2,20 @@
// ImportProgressView.swift
// TablePro
//
-// Progress dialog shown during SQL import.
+// Progress dialog shown during import.
//
import SwiftUI
struct ImportProgressView: View {
- let currentStatement: String
- let statementIndex: Int
- let totalStatements: Int
+ let processedStatements: Int
+ let estimatedTotalStatements: Int
let statusMessage: String
let onStop: () -> Void
var body: some View {
VStack(spacing: 20) {
- Text("Import SQL")
+ Text("Importing...")
.font(.system(size: 15, weight: .semibold))
VStack(spacing: 8) {
@@ -26,16 +25,10 @@ struct ImportProgressView: View {
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
- Text("Executed \(statementIndex) statements")
+ Text("Executed \(processedStatements) statements")
.font(.system(size: 13))
Spacer()
-
- Text(currentStatement)
- .font(.system(size: 12, design: .monospaced))
- .foregroundStyle(.secondary)
- .lineLimit(1)
- .truncationMode(.tail)
}
}
@@ -59,7 +52,7 @@ struct ImportProgressView: View {
}
private var progressValue: Double {
- guard totalStatements > 0 else { return 0 }
- return min(1.0, Double(statementIndex) / Double(totalStatements))
+ guard estimatedTotalStatements > 0 else { return 0 }
+ return min(1.0, Double(processedStatements) / Double(estimatedTotalStatements))
}
}
diff --git a/TablePro/Views/Import/ImportSuccessView.swift b/TablePro/Views/Import/ImportSuccessView.swift
index 4a2f2efa..0f49c6e6 100644
--- a/TablePro/Views/Import/ImportSuccessView.swift
+++ b/TablePro/Views/Import/ImportSuccessView.swift
@@ -2,13 +2,14 @@
// ImportSuccessView.swift
// TablePro
//
-// Success dialog shown after successful SQL import.
+// Success dialog shown after successful import.
//
import SwiftUI
+import TableProPluginKit
struct ImportSuccessView: View {
- let result: ImportResult?
+ let result: PluginImportResult?
let onClose: () -> Void
var body: some View {
@@ -21,7 +22,7 @@ struct ImportSuccessView: View {
Text("Import Successful")
.font(.system(size: 15, weight: .semibold))
- if let result = result {
+ if let result {
Text("\(result.executedStatements) statements executed")
.font(.system(size: 13))
.foregroundStyle(.secondary)