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)