From 9bfebe0e15c50e58defd3089ecac8e95516c4b71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 14:39:12 +0700 Subject: [PATCH 01/10] feat(plugins): add row-based import sink and target-table contract --- .../ImportFormatPlugin.swift | 2 + .../PluginImportDataSink.swift | 6 +++ .../SQLStatementGenerator.swift | 18 +++++++ .../Core/Plugins/ImportDataSinkAdapter.swift | 52 ++++++++++++++++++- .../Core/Plugins/PlainFileImportSource.swift | 36 +++++++++++++ .../Core/Services/Export/ImportService.swift | 31 +++++++---- 6 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/Plugins/PlainFileImportSource.swift diff --git a/Plugins/TableProPluginKit/ImportFormatPlugin.swift b/Plugins/TableProPluginKit/ImportFormatPlugin.swift index eb955a894..16a752452 100644 --- a/Plugins/TableProPluginKit/ImportFormatPlugin.swift +++ b/Plugins/TableProPluginKit/ImportFormatPlugin.swift @@ -13,6 +13,7 @@ public protocol ImportFormatPlugin: TableProPlugin { static var iconName: String { get } static var supportedDatabaseTypeIds: [String] { get } static var excludedDatabaseTypeIds: [String] { get } + static var requiresTargetTable: Bool { get } func performImport( source: any PluginImportSource, @@ -25,4 +26,5 @@ public extension ImportFormatPlugin { static var capabilities: [PluginCapability] { [.importFormat] } static var supportedDatabaseTypeIds: [String] { [] } static var excludedDatabaseTypeIds: [String] { [] } + static var requiresTargetTable: Bool { false } } diff --git a/Plugins/TableProPluginKit/PluginImportDataSink.swift b/Plugins/TableProPluginKit/PluginImportDataSink.swift index ea70ec46e..abf9ea12f 100644 --- a/Plugins/TableProPluginKit/PluginImportDataSink.swift +++ b/Plugins/TableProPluginKit/PluginImportDataSink.swift @@ -7,7 +7,9 @@ import Foundation public protocol PluginImportDataSink: AnyObject, Sendable { var databaseTypeId: String { get } + var targetTable: String? { get } func execute(statement: String) async throws + func insertRow(_ values: [String: PluginCellValue]) async throws func beginTransaction() async throws func commitTransaction() async throws func rollbackTransaction() async throws @@ -16,6 +18,10 @@ public protocol PluginImportDataSink: AnyObject, Sendable { } public extension PluginImportDataSink { + var targetTable: String? { nil } + func insertRow(_ values: [String: PluginCellValue]) async throws { + throw PluginImportError.importFailed("Row-based import is not supported by this connection") + } func disableForeignKeyChecks() async throws {} func enableForeignKeyChecks() async throws {} } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 8c7964424..7a9a82f45 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -178,6 +178,24 @@ struct SQLStatementGenerator { return ParameterizedStatement(sql: sql, parameters: bindParameters) } + func insertStatement(columns insertColumns: [String], values: [PluginCellValue]) + -> ParameterizedStatement? + { + guard !insertColumns.isEmpty, insertColumns.count == values.count else { return nil } + + var bindParameters: [Any?] = [] + let columnList = insertColumns.map(quoteIdentifierFn).joined(separator: ", ") + let placeholders = values.map { value -> String in + bindParameters.append(value.asAny) + return placeholder(at: bindParameters.count - 1) + }.joined(separator: ", ") + + let sql = + "INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnList)) VALUES (\(placeholders))" + + return ParameterizedStatement(sql: sql, parameters: bindParameters) + } + private func generateInsertSQLFromCellChanges(for change: RowChange) -> ParameterizedStatement? { guard !change.cellChanges.isEmpty else { return nil } diff --git a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index f4ccd3c6f..7f8474fc7 100644 --- a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -9,19 +9,69 @@ import TableProPluginKit final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { let databaseTypeId: String + let targetTable: String? + private let driver: DatabaseDriver + private let databaseType: DatabaseType + private let columnMapping: [String: String] + private let rowGenerator: SQLStatementGenerator? private static let logger = Logger(subsystem: "com.TablePro", category: "ImportDataSinkAdapter") - init(driver: DatabaseDriver, databaseType: DatabaseType) { + init( + driver: DatabaseDriver, + databaseType: DatabaseType, + targetTable: String? = nil, + columnMapping: [String: String] = [:] + ) { self.driver = driver + self.databaseType = databaseType self.databaseTypeId = databaseType.rawValue + self.targetTable = targetTable + self.columnMapping = Dictionary( + columnMapping.map { ($0.key.lowercased(), $0.value) }, + uniquingKeysWith: { _, last in last } + ) + if let targetTable { + self.rowGenerator = try? SQLStatementGenerator( + tableName: targetTable, + columns: [], + primaryKeyColumns: [], + databaseType: databaseType + ) + } else { + self.rowGenerator = nil + } } func execute(statement: String) async throws { _ = try await driver.execute(query: statement) } + func insertRow(_ values: [String: PluginCellValue]) async throws { + guard let targetTable else { + throw PluginImportError.importFailed("No target table configured for row import") + } + guard let rowGenerator else { + throw PluginImportError.importFailed("Could not resolve SQL dialect for \(targetTable)") + } + + var columns: [String] = [] + var bindValues: [PluginCellValue] = [] + for (field, value) in values { + guard let column = columnMapping[field.lowercased()] else { continue } + columns.append(column) + bindValues.append(value) + } + + guard !columns.isEmpty else { return } + guard let statement = rowGenerator.insertStatement(columns: columns, values: bindValues) else { + return + } + + _ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters) + } + func beginTransaction() async throws { try await driver.beginTransaction() } diff --git a/TablePro/Core/Plugins/PlainFileImportSource.swift b/TablePro/Core/Plugins/PlainFileImportSource.swift new file mode 100644 index 000000000..80b6408db --- /dev/null +++ b/TablePro/Core/Plugins/PlainFileImportSource.swift @@ -0,0 +1,36 @@ +// +// PlainFileImportSource.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +final class PlainFileImportSource: PluginImportSource, @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "PlainFileImportSource") + + private let url: URL + + init(url: URL) { + self.url = url + } + + 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 \(url.path(percentEncoded: false)): \(error.localizedDescription)") + return 0 + } + } + + func statements() async throws -> AsyncThrowingStream<(statement: String, lineNumber: Int), Error> { + throw PluginImportError.importFailed("This import format does not produce SQL statements") + } +} diff --git a/TablePro/Core/Services/Export/ImportService.swift b/TablePro/Core/Services/Export/ImportService.swift index eb688735a..1271c083f 100644 --- a/TablePro/Core/Services/Export/ImportService.swift +++ b/TablePro/Core/Services/Export/ImportService.swift @@ -52,7 +52,9 @@ final class ImportService { encoding: String.Encoding, decompressedURL: URL? = nil, ownsDecompressedFile: Bool = false, - knownStatementCount: Int? = nil + knownStatementCount: Int? = nil, + targetTable: String? = nil, + columnMapping: [String: String] = [:] ) async throws -> PluginImportResult { guard let plugin = PluginManager.shared.importPlugin(forFormat: formatId) else { throw PluginImportError.importFailed("Import format '\(formatId)' not found") @@ -69,15 +71,26 @@ final class ImportService { currentProgress = nil } - let sink = ImportDataSinkAdapter(driver: driver, databaseType: connection.type) - let dialect = SqlDialect.from(databaseTypeId: connection.type.rawValue) - let source = SqlFileImportSource( - url: url, - encoding: encoding, - dialect: dialect, - decompressedURL: decompressedURL, - ownsDecompressedFile: ownsDecompressedFile + let sink = ImportDataSinkAdapter( + driver: driver, + databaseType: connection.type, + targetTable: targetTable, + columnMapping: columnMapping ) + + let source: any PluginImportSource + if type(of: plugin).requiresTargetTable { + source = PlainFileImportSource(url: decompressedURL ?? url) + } else { + let dialect = SqlDialect.from(databaseTypeId: connection.type.rawValue) + source = SqlFileImportSource( + url: url, + encoding: encoding, + dialect: dialect, + decompressedURL: decompressedURL, + ownsDecompressedFile: ownsDecompressedFile + ) + } defer { source.cleanup() } // Create progress tracker From ccc8d5da0b2019770efd7eedd8788b20b49fb861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 14:54:47 +0700 Subject: [PATCH 02/10] feat(plugins): add JSON import plugin for array, NDJSON, and table-keyed files --- Plugins/JSONImportPlugin/Info.plist | 12 + .../JSONImportPlugin/JSONImportOptions.swift | 13 + .../JSONImportOptionsView.swift | 34 ++ .../JSONImportPlugin/JSONImportPlugin.swift | 221 +++++++++++ .../PluginImportDataSink.swift | 4 + TablePro.xcodeproj/project.pbxproj | 362 ++++++++++++------ .../SQLStatementGenerator.swift | 4 + .../Core/Plugins/ImportDataSinkAdapter.swift | 7 + .../SQLStatementGeneratorImportTests.swift | 73 ++++ .../Plugins/JSONImportPluginTests.swift | 130 +++++++ 10 files changed, 745 insertions(+), 115 deletions(-) create mode 100644 Plugins/JSONImportPlugin/Info.plist create mode 100644 Plugins/JSONImportPlugin/JSONImportOptions.swift create mode 100644 Plugins/JSONImportPlugin/JSONImportOptionsView.swift create mode 100644 Plugins/JSONImportPlugin/JSONImportPlugin.swift create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorImportTests.swift create mode 100644 TableProTests/Plugins/JSONImportPluginTests.swift diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist new file mode 100644 index 000000000..8ceb6def3 --- /dev/null +++ b/Plugins/JSONImportPlugin/Info.plist @@ -0,0 +1,12 @@ + + + + + TableProPluginKitVersion + 17 + TableProProvidesImportFormatIds + + json + + + diff --git a/Plugins/JSONImportPlugin/JSONImportOptions.swift b/Plugins/JSONImportPlugin/JSONImportOptions.swift new file mode 100644 index 000000000..81d2ec992 --- /dev/null +++ b/Plugins/JSONImportPlugin/JSONImportOptions.swift @@ -0,0 +1,13 @@ +// +// JSONImportOptions.swift +// JSONImportPlugin +// + +import Foundation +import TableProPluginKit + +struct JSONImportOptions: Equatable, Codable { + var errorHandling: ImportErrorHandling = .stopAndRollback + var wrapInTransaction: Bool = true + var deleteExistingRows: Bool = false +} diff --git a/Plugins/JSONImportPlugin/JSONImportOptionsView.swift b/Plugins/JSONImportPlugin/JSONImportOptionsView.swift new file mode 100644 index 000000000..dadc30135 --- /dev/null +++ b/Plugins/JSONImportPlugin/JSONImportOptionsView.swift @@ -0,0 +1,34 @@ +// +// JSONImportOptionsView.swift +// JSONImportPlugin +// + +import SwiftUI +import TableProPluginKit + +struct JSONImportOptionsView: View { + let plugin: JSONImportPlugin + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Picker("On error:", selection: Bindable(plugin).settings.errorHandling) { + Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback) + Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit) + Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue) + } + .pickerStyle(.menu) + .font(.system(size: 13)) + + Toggle("Wrap in transaction (BEGIN/COMMIT)", isOn: Bindable(plugin).settings.wrapInTransaction) + .font(.system(size: 13)) + .disabled(plugin.settings.errorHandling == .skipAndContinue) + .help(plugin.settings.errorHandling == .skipAndContinue + ? String(localized: "Not available in skip-and-continue mode") + : String(localized: "Insert all rows in a single transaction. If any row fails, all changes are rolled back.")) + + Toggle("Delete existing rows before import", isOn: Bindable(plugin).settings.deleteExistingRows) + .font(.system(size: 13)) + .help("Remove every row from the target table before inserting the imported rows.") + } + } +} diff --git a/Plugins/JSONImportPlugin/JSONImportPlugin.swift b/Plugins/JSONImportPlugin/JSONImportPlugin.swift new file mode 100644 index 000000000..92c765fd4 --- /dev/null +++ b/Plugins/JSONImportPlugin/JSONImportPlugin.swift @@ -0,0 +1,221 @@ +// +// JSONImportPlugin.swift +// JSONImportPlugin +// + +import Foundation +import os +import SwiftUI +import TableProPluginKit + +@Observable +final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin { + private static let logger = Logger(subsystem: "com.TablePro", category: "JSONImportPlugin") + + static let pluginName = "JSON Import" + static let pluginVersion = "1.0.0" + static let pluginDescription = "Import data from JSON files" + static let formatId = "json" + static let formatDisplayName = "JSON" + static let acceptedFileExtensions = ["json", "jsonl", "ndjson"] + static let iconName = "curlybraces" + static let requiresTargetTable = true + + typealias Settings = JSONImportOptions + static let settingsStorageId = "json-import" + + var settings = JSONImportOptions() { + didSet { saveSettings() } + } + + required init() { loadSettings() } + + func settingsView() -> AnyView? { + AnyView(JSONImportOptionsView(plugin: self)) + } + + func performImport( + source: any PluginImportSource, + sink: any PluginImportDataSink, + progress: PluginImportProgress + ) async throws -> PluginImportResult { + let startTime = Date() + let url = source.fileURL() + let useTransaction = settings.wrapInTransaction && settings.errorHandling != .skipAndContinue + + progress.setEstimatedTotal(max(1, Int(source.fileSizeBytes() / 256))) + + var inserted = 0 + var skipped = 0 + var errors: [PluginImportResult.ImportStatementError] = [] + let maxErrors = 1_000 + + do { + if settings.deleteExistingRows { + try await sink.deleteAllRowsFromTargetTable() + } + if useTransaction { + try await sink.beginTransaction() + } + + if Self.isLineDelimited(url) { + var lineNumber = 0 + for try await line in url.lines { + try progress.checkCancellation() + lineNumber += 1 + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + let row = try Self.parseRow(fromLine: trimmed) + try await insert(row, into: sink, at: lineNumber, progress: progress, + inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors) + } + } else { + let rows = try Self.parseRows(at: url, targetTable: sink.targetTable) + for (index, row) in rows.enumerated() { + try progress.checkCancellation() + try await insert(row, into: sink, at: index + 1, progress: progress, + inserted: &inserted, skipped: &skipped, errors: &errors, maxErrors: maxErrors) + } + } + + if useTransaction { + try await sink.commitTransaction() + } + } catch { + if useTransaction { + do { + try await sink.rollbackTransaction() + } catch { + Self.logger.warning("Rollback after failed import also failed: \(error.localizedDescription)") + } + } + if error is PluginImportCancellationError { throw error } + if error is PluginImportError { throw error } + throw PluginImportError.importFailed(error.localizedDescription) + } + + progress.finalize() + return PluginImportResult( + executedStatements: inserted, + executionTime: Date().timeIntervalSince(startTime), + skippedStatements: skipped, + errors: errors + ) + } + + private func insert( + _ row: [String: PluginCellValue], + into sink: any PluginImportDataSink, + at line: Int, + progress: PluginImportProgress, + inserted: inout Int, + skipped: inout Int, + errors: inout [PluginImportResult.ImportStatementError], + maxErrors: Int + ) async throws { + do { + try await sink.insertRow(row) + inserted += 1 + progress.incrementStatement() + } catch { + switch settings.errorHandling { + case .stopAndRollback, .stopAndCommit: + throw PluginImportError.statementFailed(statement: "row \(line)", line: line, underlyingError: error) + case .skipAndContinue: + skipped += 1 + if errors.count < maxErrors { + errors.append(.init(statement: "row \(line)", line: line, errorMessage: error.localizedDescription)) + } + progress.incrementStatement() + } + } + } + + // MARK: - Parsing + + static func isLineDelimited(_ url: URL) -> Bool { + ["jsonl", "ndjson"].contains(url.pathExtension.lowercased()) + } + + static func parseRow(fromLine line: String) throws -> [String: PluginCellValue] { + let object = try JSONSerialization.jsonObject(with: Data(line.utf8)) + guard let dict = object as? [String: Any] else { + throw PluginImportError.importFailed("Each line must be a JSON object") + } + return convertRow(dict) + } + + static func parseRows(at url: URL, targetTable: String?) throws -> [[String: PluginCellValue]] { + let data = try Data(contentsOf: url) + let object = try JSONSerialization.jsonObject(with: data) + return try extractRows(from: object, targetTable: targetTable).map(convertRow) + } + + static func extractRows(from object: Any, targetTable: String?) throws -> [[String: Any]] { + if let array = object as? [Any] { + return array.compactMap { $0 as? [String: Any] } + } + + guard let dict = object as? [String: Any] else { + throw PluginImportError.importFailed("Expected a JSON array of objects or a table-keyed object") + } + + let tables = dict.compactMapValues { value -> [Any]? in + guard let array = value as? [Any] else { return nil } + return array.allSatisfy { $0 is [String: Any] } ? array : nil + } + let isTableWrapper = !tables.isEmpty && tables.count == dict.count + + guard isTableWrapper else { + return [dict] + } + + if let targetTable, let match = matchTable(in: tables, to: targetTable) { + return match.compactMap { $0 as? [String: Any] } + } + if tables.count == 1, let only = tables.values.first { + return only.compactMap { $0 as? [String: Any] } + } + throw PluginImportError.importFailed("The file contains multiple tables and none matches the target table") + } + + private static func matchTable(in tables: [String: [Any]], to target: String) -> [Any]? { + if let exact = tables.first(where: { $0.key.caseInsensitiveCompare(target) == .orderedSame }) { + return exact.value + } + let suffix = tables.first { key, _ in + key.split(separator: ".").last.map { $0.caseInsensitiveCompare(target) == .orderedSame } ?? false + } + return suffix?.value + } + + static func convertRow(_ row: [String: Any]) -> [String: PluginCellValue] { + row.mapValues(cellValue(from:)) + } + + static func cellValue(from json: Any) -> PluginCellValue { + switch json { + case is NSNull: + return .null + case let number as NSNumber: + if CFGetTypeID(number) == CFBooleanGetTypeID() { + return .text(number.boolValue ? "true" : "false") + } + return .text(number.stringValue) + case let string as String: + return .text(string) + default: + return .text(serialize(json)) + } + } + + private static func serialize(_ object: Any) -> String { + guard JSONSerialization.isValidJSONObject(object), + let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), + let string = String(data: data, encoding: .utf8) + else { + return String(describing: object) + } + return string + } +} diff --git a/Plugins/TableProPluginKit/PluginImportDataSink.swift b/Plugins/TableProPluginKit/PluginImportDataSink.swift index abf9ea12f..c004b7315 100644 --- a/Plugins/TableProPluginKit/PluginImportDataSink.swift +++ b/Plugins/TableProPluginKit/PluginImportDataSink.swift @@ -10,6 +10,7 @@ public protocol PluginImportDataSink: AnyObject, Sendable { var targetTable: String? { get } func execute(statement: String) async throws func insertRow(_ values: [String: PluginCellValue]) async throws + func deleteAllRowsFromTargetTable() async throws func beginTransaction() async throws func commitTransaction() async throws func rollbackTransaction() async throws @@ -22,6 +23,9 @@ public extension PluginImportDataSink { func insertRow(_ values: [String: PluginCellValue]) async throws { throw PluginImportError.importFailed("Row-based import is not supported by this connection") } + func deleteAllRowsFromTargetTable() async throws { + throw PluginImportError.importFailed("Clearing the target table is not supported by this connection") + } func disableForeignKeyChecks() async throws {} func enableForeignKeyChecks() async throws {} } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 99a8f0c87..144b5100f 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -38,6 +38,8 @@ 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */ = {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 (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 5A86F001A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86F001D00000000 /* JSONImport.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5A86F001100000000 /* JSONImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ABBED742FB55E1400A78382 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ABBED802FB55E1400A78382 /* CSVInspectorPlugin.tableplugin in Copy Plug-Ins (12 items) */ = {isa = PBXBuildFile; fileRef = 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -188,21 +190,21 @@ remoteGlobalIDString = 5A86F000000000000; remoteInfo = SQLImport; }; - 5ABBED7E2FB55E1400A78382 /* PBXContainerItemProxy */ = { + 5A86F001B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; proxyType = 1; - remoteGlobalIDString = 5ABBED712FB55E1400A78382; - remoteInfo = CSVInspectorPlugin; + remoteGlobalIDString = 5A86F001000000000; + remoteInfo = JSONImport; }; - 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = { + 5ABBED7E2FB55E1400A78382 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; proxyType = 1; - remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; - remoteInfo = TablePro; + remoteGlobalIDString = 5ABBED712FB55E1400A78382; + remoteInfo = CSVInspectorPlugin; }; - 5AF00A112FB9000000000001 /* PBXContainerItemProxy */ = { + 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; proxyType = 1; @@ -223,6 +225,13 @@ remoteGlobalIDString = 5ADDB00600000000000000B0; remoteInfo = DynamoDBDriverPlugin; }; + 5AF00A112FB9000000000001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A1091C62EF17EDC0055EA7C; + remoteInfo = TablePro; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -263,6 +272,7 @@ 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins (12 items) */, 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins (12 items) */, + 5A86F001D00000000 /* JSONImport.tableplugin in Copy Plug-Ins (12 items) */, 5ABBED802FB55E1400A78382 /* CSVInspectorPlugin.tableplugin in Copy Plug-Ins (12 items) */, ); name = "Copy Plug-Ins (12 items)"; @@ -301,10 +311,10 @@ 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; }; + 5A86F001100000000 /* JSONImport.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = JSONImport.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5A87A000100000000 /* CassandraDriver.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CassandraDriver.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABBED792FB55E1400A78382 /* CSVInspectorPlugin.tableplugin */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CSVInspectorPlugin.tableplugin; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ABQR00200000000000000A1 /* BigQueryAuth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryAuth.swift; sourceTree = ""; }; 5ABQR00200000000000000A2 /* BigQueryConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryConnection.swift; sourceTree = ""; }; 5ABQR00200000000000000A3 /* BigQueryPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BigQueryPlugin.swift; sourceTree = ""; }; @@ -331,6 +341,7 @@ 5AEA8B3E2F6808CA0040461A /* EtcdPluginDriver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdPluginDriver.swift; sourceTree = ""; }; 5AEA8B3F2F6808CA0040461A /* EtcdQueryBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdQueryBuilder.swift; sourceTree = ""; }; 5AEA8B402F6808CA0040461A /* EtcdStatementGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EtcdStatementGenerator.swift; sourceTree = ""; }; + 5AF00A102FB9000000000001 /* TableProUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TableProUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5ASECRETS000000000000001 /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = SOURCE_ROOT; }; /* End PBXFileReference section */ @@ -480,6 +491,13 @@ ); target = 5A86F000000000000 /* SQLImport */; }; + 5A86F001900000000 /* Exceptions for "Plugins/JSONImportPlugin" folder in "JSONImport" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 5A86F001000000000 /* JSONImport */; + }; 5A87A000900000000 /* Exceptions for "Plugins/CassandraDriverPlugin" folder in "CassandraDriver" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -664,6 +682,14 @@ path = Plugins/SQLImportPlugin; sourceTree = ""; }; + 5A86F001500000000 /* Plugins/JSONImportPlugin */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 5A86F001900000000 /* Exceptions for "Plugins/JSONImportPlugin" folder in "JSONImport" target */, + ); + path = Plugins/JSONImportPlugin; + sourceTree = ""; + }; 5A87A000500000000 /* Plugins/CassandraDriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -685,11 +711,6 @@ path = TableProTests; sourceTree = ""; }; - 5AF00A122FB9000000000001 /* TableProUITests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = TableProUITests; - sourceTree = ""; - }; 5AE4F4812F6BC0640097AC5B /* Plugins/CloudflareD1DriverPlugin */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -698,6 +719,11 @@ path = Plugins/CloudflareD1DriverPlugin; sourceTree = ""; }; + 5AF00A122FB9000000000001 /* TableProUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TableProUITests; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -721,13 +747,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5AF00A132FB9000000000001 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5A3BE6F52F97DA8100611C1F /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -865,6 +884,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86F001300000000 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 5A86F001A00000000 /* TableProPluginKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A87A000300000000 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -920,6 +947,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A132FB9000000000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -958,6 +992,7 @@ 5A86D000500000000 /* Plugins/XLSXExportPlugin */, 5A86E000500000000 /* Plugins/MQLExportPlugin */, 5A86F000500000000 /* Plugins/SQLImportPlugin */, + 5A86F001500000000 /* Plugins/JSONImportPlugin */, 5ABCC5A82F43856700EAF3FC /* TableProTests */, 5AF00A122FB9000000000001 /* TableProUITests */, 5A32BC012F9D5F1300BAEB5F /* mcp-server */, @@ -988,6 +1023,7 @@ 5A86D000100000000 /* XLSXExport.tableplugin */, 5A86E000100000000 /* MQLExport.tableplugin */, 5A86F000100000000 /* SQLImport.tableplugin */, + 5A86F001100000000 /* JSONImport.tableplugin */, 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */, 5AF00A102FB9000000000001 /* TableProUITests.xctest */, 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */, @@ -1094,6 +1130,7 @@ 5A86D000C00000000 /* PBXTargetDependency */, 5A86E000C00000000 /* PBXTargetDependency */, 5A86F000C00000000 /* PBXTargetDependency */, + 5A86F001C00000000 /* PBXTargetDependency */, 5ABBED7F2FB55E1400A78382 /* PBXTargetDependency */, 5ADDB00000000000000000C1 /* PBXTargetDependency */, 5ABQR00000000000000000C1 /* PBXTargetDependency */, @@ -1485,6 +1522,26 @@ productReference = 5A86F000100000000 /* SQLImport.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5A86F001000000000 /* JSONImport */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5A86F001800000000 /* Build configuration list for PBXNativeTarget "JSONImport" */; + buildPhases = ( + 5A86F001200000000 /* Sources */, + 5A86F001300000000 /* Frameworks */, + 5A86F001400000000 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 5A86F001500000000 /* Plugins/JSONImportPlugin */, + ); + name = JSONImport; + productName = JSONImport; + productReference = 5A86F001100000000 /* JSONImport.tableplugin */; + productType = "com.apple.product-type.bundle"; + }; 5A87A000000000000 /* CassandraDriver */ = { isa = PBXNativeTarget; buildConfigurationList = 5A87A000800000000 /* Build configuration list for PBXNativeTarget "CassandraDriver" */; @@ -1546,27 +1603,6 @@ productReference = 5ABCC5A72F43856700EAF3FC /* TableProTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; - 5AF00A142FB9000000000001 /* TableProUITests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */; - buildPhases = ( - 5AF00A152FB9000000000001 /* Sources */, - 5AF00A132FB9000000000001 /* Frameworks */, - 5AF00A162FB9000000000001 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 5AF00A172FB9000000000001 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - 5AF00A122FB9000000000001 /* TableProUITests */, - ); - name = TableProUITests; - productName = TableProUITests; - productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; - productType = "com.apple.product-type.bundle.ui-testing"; - }; 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */ = { isa = PBXNativeTarget; buildConfigurationList = 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */; @@ -1646,6 +1682,27 @@ productReference = 5AEA8B2A2F6808270040461A /* EtcdDriverPlugin.tableplugin */; productType = "com.apple.product-type.bundle"; }; + 5AF00A142FB9000000000001 /* TableProUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */; + buildPhases = ( + 5AF00A152FB9000000000001 /* Sources */, + 5AF00A132FB9000000000001 /* Frameworks */, + 5AF00A162FB9000000000001 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 5AF00A172FB9000000000001 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 5AF00A122FB9000000000001 /* TableProUITests */, + ); + name = TableProUITests; + productName = TableProUITests; + productReference = 5AF00A102FB9000000000001 /* TableProUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -1714,10 +1771,6 @@ CreatedOnToolsVersion = 26.2; TestTargetID = 5A1091C62EF17EDC0055EA7C; }; - 5AF00A142FB9000000000001 = { - CreatedOnToolsVersion = 26.5; - TestTargetID = 5A1091C62EF17EDC0055EA7C; - }; 5AE4F4732F6BC0640097AC5B = { CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; @@ -1726,6 +1779,10 @@ CreatedOnToolsVersion = 26.3; LastSwiftMigration = 2630; }; + 5AF00A142FB9000000000001 = { + CreatedOnToolsVersion = 26.5; + TestTargetID = 5A1091C62EF17EDC0055EA7C; + }; }; }; buildConfigurationList = 5A1091C22EF17EDC0055EA7C /* Build configuration list for PBXProject "TablePro" */; @@ -1771,6 +1828,7 @@ 5A86D000000000000 /* XLSXExport */, 5A86E000000000000 /* MQLExport */, 5A86F000000000000 /* SQLImport */, + 5A86F001000000000 /* JSONImport */, 5ABCC5A62F43856700EAF3FC /* TableProTests */, 5AF00A142FB9000000000001 /* TableProUITests */, 5AEA8B292F6808270040461A /* EtcdDriverPlugin */, @@ -1792,13 +1850,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - 5AF00A162FB9000000000001 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5A3BE6F62F97DA8100611C1F /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1918,6 +1969,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86F001400000000 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A87A000400000000 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1967,24 +2025,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 5A1091C32EF17EDC0055EA7C /* Sources */ = { - isa = PBXSourcesBuildPhase; + 5AF00A162FB9000000000001 /* Resources */ = { + isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 5A32BBFC2F9D5F1300BAEB5F /* Sources */ = { +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 5A1091C32EF17EDC0055EA7C /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 5AF00A152FB9000000000001 /* Sources */ = { + 5A32BBFC2F9D5F1300BAEB5F /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -2110,6 +2168,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5A86F001200000000 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5A87A000200000000 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -2181,6 +2246,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 5AF00A152FB9000000000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -2264,6 +2336,11 @@ target = 5A86F000000000000 /* SQLImport */; targetProxy = 5A86F000B00000000 /* PBXContainerItemProxy */; }; + 5A86F001C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A86F001000000000 /* JSONImport */; + targetProxy = 5A86F001B00000000 /* PBXContainerItemProxy */; + }; 5ABBED7F2FB55E1400A78382 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5ABBED712FB55E1400A78382 /* CSVInspectorPlugin */; @@ -2274,11 +2351,6 @@ target = 5A1091C62EF17EDC0055EA7C /* TablePro */; targetProxy = 5ABCC5AB2F43856700EAF3FC /* PBXContainerItemProxy */; }; - 5AF00A172FB9000000000001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A1091C62EF17EDC0055EA7C /* TablePro */; - targetProxy = 5AF00A112FB9000000000001 /* PBXContainerItemProxy */; - }; 5ABQR00000000000000000C1 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5ABQR00600000000000000B0 /* BigQueryDriverPlugin */; @@ -2289,6 +2361,11 @@ target = 5ADDB00600000000000000B0 /* DynamoDBDriverPlugin */; targetProxy = 5ADDB00000000000000000C0 /* PBXContainerItemProxy */; }; + 5AF00A172FB9000000000001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A1091C62EF17EDC0055EA7C /* TablePro */; + targetProxy = 5AF00A112FB9000000000001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -3596,6 +3673,52 @@ }; name = Release; }; + 5A86F001600000000 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/JSONImportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONImportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.JSONImportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Debug; + }; + 5A86F001700000000 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Plugins/JSONImportPlugin/Info.plist; + INFOPLIST_KEY_NSPrincipalClass = "$(PRODUCT_MODULE_NAME).JSONImportPlugin"; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.JSONImportPlugin; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = macosx; + SWIFT_VERSION = 5.9; + WRAPPER_EXTENSION = tableplugin; + }; + name = Release; + }; 5A87A000600000000 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -3780,48 +3903,6 @@ }; name = Release; }; - 5AF00A182FB9000000000001 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.9; - TEST_TARGET_NAME = TablePro; - }; - name = Debug; - }; - 5AF00A1A2FB9000000000001 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = ""; - GENERATE_INFOPLIST_FILE = YES; - MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = macosx; - STRING_CATALOG_GENERATE_SYMBOLS = NO; - SWIFT_APPROACHABLE_CONCURRENCY = YES; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; - SWIFT_VERSION = 5.9; - TEST_TARGET_NAME = TablePro; - }; - name = Release; - }; 5ABQR00700000000000000B1 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4015,6 +4096,48 @@ }; name = Release; }; + 5AF00A182FB9000000000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Debug; + }; + 5AF00A1A2FB9000000000001 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + GENERATE_INFOPLIST_FILE = YES; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.TablePro.TableProUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.9; + TEST_TARGET_NAME = TablePro; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -4198,6 +4321,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5A86F001800000000 /* Build configuration list for PBXNativeTarget "JSONImport" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5A86F001600000000 /* Debug */, + 5A86F001700000000 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5A87A000800000000 /* Build configuration list for PBXNativeTarget "CassandraDriver" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4225,15 +4357,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 5AF00A182FB9000000000001 /* Debug */, - 5AF00A1A2FB9000000000001 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; 5ABQR00800000000000000B0 /* Build configuration list for PBXNativeTarget "BigQueryDriverPlugin" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4270,6 +4393,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5AF00A192FB9000000000001 /* Build configuration list for PBXNativeTarget "TableProUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 5AF00A182FB9000000000001 /* Debug */, + 5AF00A1A2FB9000000000001 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index 7a9a82f45..681b63c56 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -196,6 +196,10 @@ struct SQLStatementGenerator { return ParameterizedStatement(sql: sql, parameters: bindParameters) } + func deleteAllRowsStatement() -> String { + "DELETE FROM \(quoteIdentifierFn(tableName))" + } + private func generateInsertSQLFromCellChanges(for change: RowChange) -> ParameterizedStatement? { guard !change.cellChanges.isEmpty else { return nil } diff --git a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift index 7f8474fc7..784113755 100644 --- a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -72,6 +72,13 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable { _ = try await driver.executeParameterized(query: statement.sql, parameters: statement.parameters) } + func deleteAllRowsFromTargetTable() async throws { + guard targetTable != nil, let rowGenerator else { + throw PluginImportError.importFailed("No target table configured for row import") + } + _ = try await driver.execute(query: rowGenerator.deleteAllRowsStatement()) + } + func beginTransaction() async throws { try await driver.beginTransaction() } diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorImportTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorImportTests.swift new file mode 100644 index 000000000..a6934b9e8 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorImportTests.swift @@ -0,0 +1,73 @@ +// +// SQLStatementGeneratorImportTests.swift +// TableProTests +// +// Tests for the row-import INSERT/DELETE helpers used by data importers. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Statement Generator - row import") +struct SQLStatementGeneratorImportTests { + private func makeGenerator( + table: String = "users", + databaseType: DatabaseType = .mysql + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: table, + columns: [], + primaryKeyColumns: [], + databaseType: databaseType + ) + } + + @Test("insertStatement parameterizes every value (MySQL)") + func testInsertParameterizesValues() throws { + let generator = try makeGenerator() + let stmt = try #require(generator.insertStatement(columns: ["id", "name"], values: ["1", .text("John")])) + #expect(stmt.sql == "INSERT INTO `users` (`id`, `name`) VALUES (?, ?)") + #expect(stmt.parameters.count == 2) + #expect(stmt.parameters[0] as? String == "1") + #expect(stmt.parameters[1] as? String == "John") + } + + @Test("insertStatement binds SQL-looking data instead of interpolating it") + func testInsertDoesNotInterpolate() throws { + let generator = try makeGenerator() + let injection = "'); DROP TABLE users;--" + let stmt = try #require(generator.insertStatement(columns: ["name"], values: [.text(injection)])) + #expect(stmt.sql == "INSERT INTO `users` (`name`) VALUES (?)") + #expect(stmt.parameters[0] as? String == injection) + } + + @Test("insertStatement uses positional placeholders for PostgreSQL") + func testInsertPostgres() throws { + let generator = try makeGenerator(databaseType: .postgresql) + let stmt = try #require(generator.insertStatement(columns: ["id", "name"], values: ["1", .text("a")])) + #expect(stmt.sql == "INSERT INTO \"users\" (\"id\", \"name\") VALUES ($1, $2)") + } + + @Test("insertStatement passes null through as a nil bind") + func testInsertNull() throws { + let generator = try makeGenerator() + let stmt = try #require(generator.insertStatement(columns: ["deleted_at"], values: [.null])) + #expect(stmt.parameters.count == 1) + #expect(stmt.parameters[0] == nil) + } + + @Test("insertStatement returns nil for empty or mismatched input") + func testInsertGuards() throws { + let generator = try makeGenerator() + #expect(generator.insertStatement(columns: [], values: []) == nil) + #expect(generator.insertStatement(columns: ["a"], values: ["1", "2"]) == nil) + } + + @Test("deleteAllRowsStatement quotes the table identifier per dialect") + func testDeleteAllRows() throws { + #expect(try makeGenerator(databaseType: .mysql).deleteAllRowsStatement() == "DELETE FROM `users`") + #expect(try makeGenerator(databaseType: .postgresql).deleteAllRowsStatement() == "DELETE FROM \"users\"") + } +} diff --git a/TableProTests/Plugins/JSONImportPluginTests.swift b/TableProTests/Plugins/JSONImportPluginTests.swift new file mode 100644 index 000000000..18cf403f7 --- /dev/null +++ b/TableProTests/Plugins/JSONImportPluginTests.swift @@ -0,0 +1,130 @@ +// +// JSONImportPluginTests.swift +// TableProTests +// + +import Foundation +@testable import JSONImport +import TableProPluginKit +import Testing + +@Suite("JSON Import Plugin") +struct JSONImportPluginTests { + private func object(_ json: String) throws -> [String: Any] { + let parsed = try JSONSerialization.jsonObject(with: Data(json.utf8)) + return try #require(parsed as? [String: Any]) + } + + private func anyValue(_ json: String) throws -> Any { + try JSONSerialization.jsonObject(with: Data(json.utf8)) + } + + private func field(_ json: String, _ key: String) throws -> Any { + try #require(object(json)[key]) + } + + // MARK: - Value conversion + + @Test("Null converts to a SQL null cell") + func testNullValue() { + #expect(JSONImportPlugin.cellValue(from: NSNull()) == .null) + } + + @Test("Booleans convert to true/false text, not 1/0") + func testBooleanValue() throws { + #expect(JSONImportPlugin.cellValue(from: try field(#"{"yes": true}"#, "yes")) == .text("true")) + #expect(JSONImportPlugin.cellValue(from: try field(#"{"no": false}"#, "no")) == .text("false")) + } + + @Test("Numbers convert to their text form") + func testNumberValues() throws { + #expect(JSONImportPlugin.cellValue(from: try field(#"{"i": 42}"#, "i")) == .text("42")) + #expect(JSONImportPlugin.cellValue(from: try field(#"{"d": 3.5}"#, "d")) == .text("3.5")) + #expect(JSONImportPlugin.cellValue(from: try field(#"{"big": 9007199254740993}"#, "big")) == .text("9007199254740993")) + } + + @Test("Strings pass through unchanged") + func testStringValue() { + #expect(JSONImportPlugin.cellValue(from: "hello") == .text("hello")) + } + + @Test("Nested objects and arrays serialize to JSON text") + func testNestedValue() throws { + #expect(JSONImportPlugin.cellValue(from: try field(#"{"tags": ["a", "b"]}"#, "tags")) == .text("[\"a\",\"b\"]")) + #expect(JSONImportPlugin.cellValue(from: try field(#"{"meta": {"k": 1}}"#, "meta")) == .text("{\"k\":1}")) + } + + // MARK: - Row extraction + + @Test("Bare array of objects yields rows") + func testBareArray() throws { + let rows = try JSONImportPlugin.extractRows(from: try anyValue("[{\"id\":1},{\"id\":2}]"), targetTable: nil) + #expect(rows.count == 2) + } + + @Test("Single-key table wrapper yields that table's rows") + func testSingleKeyWrapper() throws { + let rows = try JSONImportPlugin.extractRows(from: try anyValue(#"{"users":[{"id":1}]}"#), targetTable: nil) + #expect(rows.count == 1) + } + + @Test("Multi-table wrapper selects the array matching the target table") + func testMultiTableWrapperMatchesTarget() throws { + let json = #"{"users":[{"id":1}],"orders":[{"id":1},{"id":2}]}"# + let rows = try JSONImportPlugin.extractRows(from: try anyValue(json), targetTable: "orders") + #expect(rows.count == 2) + } + + @Test("Schema-qualified wrapper key matches the unqualified target table") + func testQualifiedKeyMatch() throws { + let rows = try JSONImportPlugin.extractRows(from: try anyValue(#"{"public.users":[{"id":1}]}"#), targetTable: "users") + #expect(rows.count == 1) + } + + @Test("Multi-table wrapper with no match throws") + func testMultiTableNoMatchThrows() { + #expect(throws: PluginImportError.self) { + _ = try JSONImportPlugin.extractRows( + from: try anyValue(#"{"users":[{"id":1}],"orders":[{"id":1}]}"#), + targetTable: "products" + ) + } + } + + @Test("A lone JSON object is treated as a single row") + func testSingleObjectRow() throws { + let rows = try JSONImportPlugin.extractRows(from: try anyValue(#"{"id":1,"tags":["a"]}"#), targetTable: nil) + #expect(rows.count == 1) + #expect(rows[0]["id"] != nil) + } + + // MARK: - NDJSON line parsing + + @Test("A JSON object line parses to a row") + func testNdjsonLine() throws { + let row = try JSONImportPlugin.parseRow(fromLine: #"{"id":1,"name":"x"}"#) + #expect(row["id"] == .text("1")) + #expect(row["name"] == .text("x")) + } + + @Test("A non-object line throws") + func testNdjsonNonObjectThrows() { + #expect(throws: PluginImportError.self) { + _ = try JSONImportPlugin.parseRow(fromLine: "[1, 2, 3]") + } + } + + // MARK: - Round trip with the export shape + + @Test("Rows shaped like JSONExportPlugin output convert losslessly") + func testExportShapeRoundTrip() throws { + let row = JSONImportPlugin.convertRow( + try object(#"{"id":1,"name":"Alice","deleted_at":null,"score":3.14,"active":true}"#) + ) + #expect(row["id"] == .text("1")) + #expect(row["name"] == .text("Alice")) + #expect(row["deleted_at"] == .null) + #expect(row["score"] == .text("3.14")) + #expect(row["active"] == .text("true")) + } +} From 6036491cad3a51377595591b4ef6acc163f43cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 15:21:48 +0700 Subject: [PATCH 03/10] feat(import): wire JSON import into the dialog with target-table selection --- .../Core/Plugins/PlainFileImportSource.swift | 5 +- TablePro/Views/Import/ImportDialog.swift | 114 +++++++++++++++++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Plugins/PlainFileImportSource.swift b/TablePro/Core/Plugins/PlainFileImportSource.swift index 80b6408db..142f332f0 100644 --- a/TablePro/Core/Plugins/PlainFileImportSource.swift +++ b/TablePro/Core/Plugins/PlainFileImportSource.swift @@ -21,11 +21,12 @@ final class PlainFileImportSource: PluginImportSource, @unchecked Sendable { } func fileSizeBytes() -> Int64 { + let path = url.path(percentEncoded: false) do { - let attrs = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false)) + let attrs = try FileManager.default.attributesOfItem(atPath: path) return attrs[.size] as? Int64 ?? 0 } catch { - Self.logger.warning("Failed to get file size for \(url.path(percentEncoded: false)): \(error.localizedDescription)") + Self.logger.warning("Failed to get file size for \(path): \(error.localizedDescription)") return 0 } } diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 72d6aaeab..263780c66 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -39,6 +39,14 @@ struct ImportDialog: View { @State private var countStatementsTask: Task? @State private var importTask: Task? + // MARK: - Row import (target table + mapping) + + @State private var availableTables: [TableInfo] = [] + @State private var selectedTargetTable: String? + @State private var columnMapping: [String: String] = [:] + @State private var loadTablesTask: Task? + @State private var loadColumnsTask: Task? + // MARK: - Import Service @State private var importService: ImportService? @@ -57,6 +65,11 @@ struct ImportDialog: View { Divider() } + if requiresTargetTable { + targetTableSection + Divider() + } + filePreviewView optionsView @@ -76,6 +89,23 @@ struct ImportDialog: View { selectedFormatId = type(of: first).formatId } } + if requiresTargetTable { + startLoadingTables() + } + } + .onChange(of: selectedFormatId) { _, _ in + selectedTargetTable = nil + columnMapping = [:] + availableTables = [] + if requiresTargetTable { + startLoadingTables() + } + } + .onChange(of: selectedTargetTable) { _, newValue in + columnMapping = [:] + loadColumnsTask?.cancel() + guard let table = newValue else { return } + loadColumnsTask = Task { await loadMapping(for: table) } } .onExitCommand { if !(importService?.state.isImporting ?? false) { @@ -91,6 +121,8 @@ struct ImportDialog: View { loadFileTask?.cancel() countStatementsTask?.cancel() importTask?.cancel() + loadTablesTask?.cancel() + loadColumnsTask?.cancel() cleanupTempFiles() } .sheet(isPresented: $showProgressDialog) { @@ -143,6 +175,10 @@ struct ImportDialog: View { PluginManager.shared.importPlugin(forFormat: selectedFormatId) } + private var requiresTargetTable: Bool { + currentPlugin.map { type(of: $0).requiresTargetTable } ?? false + } + // MARK: - View Components private var fileInfoView: some View { @@ -212,6 +248,33 @@ struct ImportDialog: View { } } + private var targetTableSection: some View { + HStack(spacing: 8) { + Text("Import into:") + .font(.body) + .frame(width: 80, alignment: .leading) + + Picker("", selection: $selectedTargetTable) { + Text("Select a table…").tag(String?.none) + ForEach(availableTables, id: \.id) { table in + Text(table.name).tag(String?.some(table.name)) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 240) + + if selectedTargetTable != nil && columnMapping.isEmpty { + ProgressView().controlSize(.small) + } + + Spacer() + + Text("Fields are matched to columns by name") + .font(.caption) + .foregroundStyle(.secondary) + } + } + private var filePreviewView: some View { VStack(alignment: .leading, spacing: 6) { Text("Preview") @@ -283,7 +346,13 @@ struct ImportDialog: View { performImport() } .buttonStyle(.borderedProminent) - .disabled(fileURL == nil || (importService?.state.isImporting ?? false) || availableFormats.isEmpty || hasPreviewError) + .disabled( + fileURL == nil + || (importService?.state.isImporting ?? false) + || availableFormats.isEmpty + || hasPreviewError + || (requiresTargetTable && selectedTargetTable == nil) + ) .keyboardShortcut(.defaultAction) } .padding(16) @@ -327,6 +396,12 @@ struct ImportDialog: View { fileURL = url + if let match = availableFormats.first(where: { + type(of: $0).acceptedFileExtensions.contains(url.pathExtension.lowercased()) + }) { + selectedFormatId = type(of: match).formatId + } + do { let attrs = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false)) fileSize = attrs[.size] as? Int64 ?? 0 @@ -373,6 +448,7 @@ struct ImportDialog: View { } countStatementsTask?.cancel() + guard !requiresTargetTable else { return } countStatementsTask = Task { await countStatements(url: urlToRead) } @@ -399,6 +475,38 @@ struct ImportDialog: View { isCountingStatements = false } + private func startLoadingTables() { + loadTablesTask?.cancel() + loadTablesTask = Task { await loadTables() } + } + + @MainActor + private func loadTables() async { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + do { + let tables = try await driver.fetchTables() + availableTables = tables.filter { $0.type == .table } + } catch { + Self.logger.warning("Failed to load tables for import: \(error.localizedDescription, privacy: .public)") + } + } + + @MainActor + private func loadMapping(for table: String) async { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + do { + let columns = try await driver.fetchColumns(table: table) + var mapping: [String: String] = [:] + for column in columns { + mapping[column.name] = column.name + } + columnMapping = mapping + } catch { + Self.logger.warning("Failed to load columns for \(table, privacy: .public): \(error.localizedDescription, privacy: .public)") + columnMapping = [:] + } + } + private func performImport() { guard let url = fileURL else { return } @@ -419,7 +527,9 @@ struct ImportDialog: View { encoding: selectedEncoding.encoding, decompressedURL: decompressedURL, ownsDecompressedFile: ownsDecompressedFile, - knownStatementCount: statementCount > 0 ? statementCount : nil + knownStatementCount: statementCount > 0 ? statementCount : nil, + targetTable: selectedTargetTable, + columnMapping: columnMapping ) await MainActor.run { From 9c4e7a28310bbf36ddd7c304b5c1a580597397b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 15:55:17 +0700 Subject: [PATCH 04/10] feat(plugins): add JSON source-field introspection and type inference --- .../JSONImportPlugin/JSONImportPlugin.swift | 88 +++++++++++++++++++ .../ImportFormatPlugin.swift | 4 + .../TableProPluginKit/PluginImportTypes.swift | 22 +++++ .../Plugins/JSONImportPluginTests.swift | 48 ++++++++++ 4 files changed, 162 insertions(+) diff --git a/Plugins/JSONImportPlugin/JSONImportPlugin.swift b/Plugins/JSONImportPlugin/JSONImportPlugin.swift index 92c765fd4..9bea84ff0 100644 --- a/Plugins/JSONImportPlugin/JSONImportPlugin.swift +++ b/Plugins/JSONImportPlugin/JSONImportPlugin.swift @@ -218,4 +218,92 @@ final class JSONImportPlugin: ImportFormatPlugin, SettablePlugin { } return string } + + // MARK: - Source introspection + + func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] { + let rows = try Self.sampleRawRows(at: url, targetTable: targetTable, limit: 200) + return Self.detectFields(in: rows) + } + + static func sampleRawRows(at url: URL, targetTable: String?, limit: Int) throws -> [[String: Any]] { + if isLineDelimited(url) { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + let text = String(bytes: handle.readData(ofLength: 256 * 1_024), encoding: .utf8) ?? "" + var rows: [[String: Any]] = [] + for line in text.split(separator: "\n") where rows.count < limit { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + if let object = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) as? [String: Any] { + rows.append(object) + } + } + return rows + } + let object = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) + return Array(try extractRows(from: object, targetTable: targetTable).prefix(limit)) + } + + static func detectFields(in rows: [[String: Any]]) -> [PluginImportField] { + var names: [String] = [] + var seen = Set() + var valuesByField: [String: [Any]] = [:] + for row in rows { + for (key, value) in row { + if seen.insert(key).inserted { names.append(key) } + valuesByField[key, default: []].append(value) + } + } + return names.sorted().map { name in + let nonNull = (valuesByField[name] ?? []).filter { !($0 is NSNull) } + return PluginImportField( + name: name, + sampleValue: nonNull.first.map(sampleString), + inferredType: inferType(from: nonNull) + ) + } + } + + static func inferType(from values: [Any]) -> PluginImportFieldType { + guard !values.isEmpty else { return .text } + var allNested = true + var allBoolean = true + var allInteger = true + var allNumber = true + for value in values { + if value is [Any] || value is [String: Any] { + allBoolean = false + allInteger = false + allNumber = false + } else { + allNested = false + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + allInteger = false + allNumber = false + } else { + allBoolean = false + if CFNumberIsFloatType(number) { allInteger = false } + } + } else { + allBoolean = false + allInteger = false + allNumber = false + } + } + } + if allNested { return .json } + if allBoolean { return .boolean } + if allInteger { return .integer } + if allNumber { return .real } + return .text + } + + private static func sampleString(_ value: Any) -> String { + switch cellValue(from: value) { + case .text(let string): return String(string.prefix(80)) + case .bytes, .null: return "" + } + } } diff --git a/Plugins/TableProPluginKit/ImportFormatPlugin.swift b/Plugins/TableProPluginKit/ImportFormatPlugin.swift index 16a752452..a26a31897 100644 --- a/Plugins/TableProPluginKit/ImportFormatPlugin.swift +++ b/Plugins/TableProPluginKit/ImportFormatPlugin.swift @@ -20,6 +20,8 @@ public protocol ImportFormatPlugin: TableProPlugin { sink: any PluginImportDataSink, progress: PluginImportProgress ) async throws -> PluginImportResult + + func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] } public extension ImportFormatPlugin { @@ -27,4 +29,6 @@ public extension ImportFormatPlugin { static var supportedDatabaseTypeIds: [String] { [] } static var excludedDatabaseTypeIds: [String] { [] } static var requiresTargetTable: Bool { false } + + func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] { [] } } diff --git a/Plugins/TableProPluginKit/PluginImportTypes.swift b/Plugins/TableProPluginKit/PluginImportTypes.swift index 409a38d8d..b12035a33 100644 --- a/Plugins/TableProPluginKit/PluginImportTypes.swift +++ b/Plugins/TableProPluginKit/PluginImportTypes.swift @@ -11,6 +11,28 @@ public enum ImportErrorHandling: String, Codable, CaseIterable, Sendable { case skipAndContinue } +public enum PluginImportFieldType: String, Sendable { + case text + case integer + case real + case boolean + case json +} + +public struct PluginImportField: Sendable, Identifiable { + public let name: String + public let sampleValue: String? + public let inferredType: PluginImportFieldType + + public var id: String { name } + + public init(name: String, sampleValue: String?, inferredType: PluginImportFieldType) { + self.name = name + self.sampleValue = sampleValue + self.inferredType = inferredType + } +} + public struct PluginImportResult: Sendable { public let executedStatements: Int public let skippedStatements: Int diff --git a/TableProTests/Plugins/JSONImportPluginTests.swift b/TableProTests/Plugins/JSONImportPluginTests.swift index 18cf403f7..93fea57fb 100644 --- a/TableProTests/Plugins/JSONImportPluginTests.swift +++ b/TableProTests/Plugins/JSONImportPluginTests.swift @@ -127,4 +127,52 @@ struct JSONImportPluginTests { #expect(row["score"] == .text("3.14")) #expect(row["active"] == .text("true")) } + + // MARK: - Type inference + + private func array(_ json: String) throws -> [Any] { + try #require(try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [Any]) + } + + @Test("Inference: all integers") + func testInferInteger() throws { + #expect(JSONImportPlugin.inferType(from: try array("[1, 2, 3]")) == .integer) + } + + @Test("Inference: any decimal makes the field real") + func testInferReal() throws { + #expect(JSONImportPlugin.inferType(from: try array("[1, 2.5, 3]")) == .real) + } + + @Test("Inference: all booleans") + func testInferBoolean() throws { + #expect(JSONImportPlugin.inferType(from: try array("[true, false]")) == .boolean) + } + + @Test("Inference: all-nested values are json") + func testInferJSON() throws { + #expect(JSONImportPlugin.inferType(from: try array(#"[{"a":1}, [1,2]]"#)) == .json) + } + + @Test("Inference: mixed types fall back to text") + func testInferText() throws { + #expect(JSONImportPlugin.inferType(from: try array(#"["a", 1]"#)) == .text) + } + + @Test("Inference: empty values are text") + func testInferEmpty() { + #expect(JSONImportPlugin.inferType(from: []) == .text) + } + + @Test("detectFields reports sorted fields with inferred types and a sample") + func testDetectFields() throws { + let raw = #"[{"id":1,"name":"a","active":true},{"id":2,"name":"b","active":false}]"# + let rows = try #require(try JSONSerialization.jsonObject(with: Data(raw.utf8)) as? [[String: Any]]) + let fields = JSONImportPlugin.detectFields(in: rows) + #expect(fields.map(\.name) == ["active", "id", "name"]) + #expect(fields.first { $0.name == "id" }?.inferredType == .integer) + #expect(fields.first { $0.name == "active" }?.inferredType == .boolean) + #expect(fields.first { $0.name == "name" }?.inferredType == .text) + #expect(fields.first { $0.name == "id" }?.sampleValue == "1") + } } From 3086dfaf6f93f80729037e4f4fdb1e7e64b9e5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 16:11:38 +0700 Subject: [PATCH 05/10] feat(import): add dedicated JSON import sheet with field-to-column mapping --- TablePro/Views/Import/ImportDialog.swift | 114 +------ TablePro/Views/Import/JSONImportSheet.swift | 295 ++++++++++++++++++ ...ainContentCoordinator+SidebarActions.swift | 6 +- .../Views/Main/MainContentCoordinator.swift | 2 + TablePro/Views/Main/MainContentView.swift | 16 + 5 files changed, 320 insertions(+), 113 deletions(-) create mode 100644 TablePro/Views/Import/JSONImportSheet.swift diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 263780c66..72d6aaeab 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -39,14 +39,6 @@ struct ImportDialog: View { @State private var countStatementsTask: Task? @State private var importTask: Task? - // MARK: - Row import (target table + mapping) - - @State private var availableTables: [TableInfo] = [] - @State private var selectedTargetTable: String? - @State private var columnMapping: [String: String] = [:] - @State private var loadTablesTask: Task? - @State private var loadColumnsTask: Task? - // MARK: - Import Service @State private var importService: ImportService? @@ -65,11 +57,6 @@ struct ImportDialog: View { Divider() } - if requiresTargetTable { - targetTableSection - Divider() - } - filePreviewView optionsView @@ -89,23 +76,6 @@ struct ImportDialog: View { selectedFormatId = type(of: first).formatId } } - if requiresTargetTable { - startLoadingTables() - } - } - .onChange(of: selectedFormatId) { _, _ in - selectedTargetTable = nil - columnMapping = [:] - availableTables = [] - if requiresTargetTable { - startLoadingTables() - } - } - .onChange(of: selectedTargetTable) { _, newValue in - columnMapping = [:] - loadColumnsTask?.cancel() - guard let table = newValue else { return } - loadColumnsTask = Task { await loadMapping(for: table) } } .onExitCommand { if !(importService?.state.isImporting ?? false) { @@ -121,8 +91,6 @@ struct ImportDialog: View { loadFileTask?.cancel() countStatementsTask?.cancel() importTask?.cancel() - loadTablesTask?.cancel() - loadColumnsTask?.cancel() cleanupTempFiles() } .sheet(isPresented: $showProgressDialog) { @@ -175,10 +143,6 @@ struct ImportDialog: View { PluginManager.shared.importPlugin(forFormat: selectedFormatId) } - private var requiresTargetTable: Bool { - currentPlugin.map { type(of: $0).requiresTargetTable } ?? false - } - // MARK: - View Components private var fileInfoView: some View { @@ -248,33 +212,6 @@ struct ImportDialog: View { } } - private var targetTableSection: some View { - HStack(spacing: 8) { - Text("Import into:") - .font(.body) - .frame(width: 80, alignment: .leading) - - Picker("", selection: $selectedTargetTable) { - Text("Select a table…").tag(String?.none) - ForEach(availableTables, id: \.id) { table in - Text(table.name).tag(String?.some(table.name)) - } - } - .pickerStyle(.menu) - .frame(maxWidth: 240) - - if selectedTargetTable != nil && columnMapping.isEmpty { - ProgressView().controlSize(.small) - } - - Spacer() - - Text("Fields are matched to columns by name") - .font(.caption) - .foregroundStyle(.secondary) - } - } - private var filePreviewView: some View { VStack(alignment: .leading, spacing: 6) { Text("Preview") @@ -346,13 +283,7 @@ struct ImportDialog: View { performImport() } .buttonStyle(.borderedProminent) - .disabled( - fileURL == nil - || (importService?.state.isImporting ?? false) - || availableFormats.isEmpty - || hasPreviewError - || (requiresTargetTable && selectedTargetTable == nil) - ) + .disabled(fileURL == nil || (importService?.state.isImporting ?? false) || availableFormats.isEmpty || hasPreviewError) .keyboardShortcut(.defaultAction) } .padding(16) @@ -396,12 +327,6 @@ struct ImportDialog: View { fileURL = url - if let match = availableFormats.first(where: { - type(of: $0).acceptedFileExtensions.contains(url.pathExtension.lowercased()) - }) { - selectedFormatId = type(of: match).formatId - } - do { let attrs = try FileManager.default.attributesOfItem(atPath: url.path(percentEncoded: false)) fileSize = attrs[.size] as? Int64 ?? 0 @@ -448,7 +373,6 @@ struct ImportDialog: View { } countStatementsTask?.cancel() - guard !requiresTargetTable else { return } countStatementsTask = Task { await countStatements(url: urlToRead) } @@ -475,38 +399,6 @@ struct ImportDialog: View { isCountingStatements = false } - private func startLoadingTables() { - loadTablesTask?.cancel() - loadTablesTask = Task { await loadTables() } - } - - @MainActor - private func loadTables() async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - do { - let tables = try await driver.fetchTables() - availableTables = tables.filter { $0.type == .table } - } catch { - Self.logger.warning("Failed to load tables for import: \(error.localizedDescription, privacy: .public)") - } - } - - @MainActor - private func loadMapping(for table: String) async { - guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } - do { - let columns = try await driver.fetchColumns(table: table) - var mapping: [String: String] = [:] - for column in columns { - mapping[column.name] = column.name - } - columnMapping = mapping - } catch { - Self.logger.warning("Failed to load columns for \(table, privacy: .public): \(error.localizedDescription, privacy: .public)") - columnMapping = [:] - } - } - private func performImport() { guard let url = fileURL else { return } @@ -527,9 +419,7 @@ struct ImportDialog: View { encoding: selectedEncoding.encoding, decompressedURL: decompressedURL, ownsDecompressedFile: ownsDecompressedFile, - knownStatementCount: statementCount > 0 ? statementCount : nil, - targetTable: selectedTargetTable, - columnMapping: columnMapping + knownStatementCount: statementCount > 0 ? statementCount : nil ) await MainActor.run { diff --git a/TablePro/Views/Import/JSONImportSheet.swift b/TablePro/Views/Import/JSONImportSheet.swift new file mode 100644 index 000000000..1284fccb1 --- /dev/null +++ b/TablePro/Views/Import/JSONImportSheet.swift @@ -0,0 +1,295 @@ +// +// JSONImportSheet.swift +// TablePro +// +// Dedicated import sheet for row-based formats (JSON / NDJSON): +// pick a destination table and map each source field to a column. +// + +import os +import SwiftUI +import TableProPluginKit + +struct JSONImportSheet: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "JSONImportSheet") + + @Binding var isPresented: Bool + let connection: DatabaseConnection + let fileURL: URL + + private struct FieldMapping: Identifiable { + let field: PluginImportField + var include: Bool + var targetColumn: String? + var id: String { field.name } + } + + @State private var availableTables: [TableInfo] = [] + @State private var selectedTargetTable: String? + @State private var targetColumns: [String] = [] + @State private var mappings: [FieldMapping] = [] + @State private var isLoadingContext = false + @State private var loadError: String? + + @State private var importService: ImportService? + @State private var importResult: PluginImportResult? + @State private var importError: (any Error)? + @State private var showProgressDialog = false + @State private var showSuccessDialog = false + @State private var showErrorDialog = false + @State private var importTask: Task? + + var body: some View { + VStack(spacing: 0) { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + fileInfoSection + Divider() + destinationSection + if selectedTargetTable != nil { + Divider() + mappingSection + } + if let settable = currentPlugin as? any SettablePluginDiscoverable, + let optionsView = settable.settingsView() { + Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Options") + .font(.callout.weight(.semibold)) + optionsView + } + } + } + .padding(16) + } + .frame(width: 640, height: 560) + + Divider() + footerView + } + .background(Color(nsColor: .windowBackgroundColor)) + .task { await loadTables() } + .onChange(of: selectedTargetTable) { _, newValue in + mappings = [] + targetColumns = [] + guard let table = newValue else { return } + Task { await loadTargetContext(table: table) } + } + .onDisappear { importTask?.cancel() } + .sheet(isPresented: $showProgressDialog) { + if let service = importService { + ImportProgressView(service: service) { service.cancelImport() } + .interactiveDismissDisabled() + } + } + .sheet(isPresented: $showSuccessDialog, onDismiss: { + isPresented = false + AppCommands.shared.refreshData.send(connection.id) + }) { + ImportSuccessView(result: importResult) { showSuccessDialog = false } + } + .sheet(isPresented: $showErrorDialog) { + ImportErrorView(error: importError) { showErrorDialog = false } + } + } + + // MARK: - Plugin + + private var currentPlugin: (any ImportFormatPlugin)? { + let ext = fileURL.pathExtension.lowercased() + return PluginManager.shared.allImportPlugins().first { + type(of: $0).requiresTargetTable && type(of: $0).acceptedFileExtensions.contains(ext) + } + } + + private var formatId: String { + currentPlugin.map { type(of: $0).formatId } ?? "json" + } + + private var canImport: Bool { + selectedTargetTable != nil + && mappings.contains { $0.include && $0.targetColumn != nil } + && !(importService?.state.isImporting ?? false) + } + + // MARK: - Sections + + private var fileInfoSection: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "curlybraces") + .font(.title) + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 4) { + Text(fileURL.lastPathComponent) + .font(.body.weight(.semibold)) + Text("Import JSON rows into a table") + .font(.callout) + .foregroundStyle(.secondary) + } + Spacer() + } + } + + private var destinationSection: some View { + HStack(spacing: 8) { + Text("Import into:") + .font(.body) + .frame(width: 90, alignment: .leading) + Picker("", selection: $selectedTargetTable) { + Text("Select a table…").tag(String?.none) + ForEach(availableTables, id: \.id) { table in + Text(table.name).tag(String?.some(table.name)) + } + } + .pickerStyle(.menu) + .frame(maxWidth: 260) + if isLoadingContext { + ProgressView().controlSize(.small) + } + Spacer() + } + } + + private var mappingSection: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Field Mapping") + .font(.callout.weight(.semibold)) + Spacer() + if let loadError { + Text(loadError).font(.caption).foregroundStyle(.red) + } + } + HStack(spacing: 8) { + Text("Import").frame(width: 50, alignment: .leading) + Text("JSON field").frame(width: 170, alignment: .leading) + Text("Column").frame(maxWidth: .infinity, alignment: .leading) + } + .font(.caption) + .foregroundStyle(.secondary) + + ForEach($mappings) { $mapping in + mappingRow($mapping) + } + } + } + + private func mappingRow(_ mapping: Binding) -> some View { + HStack(spacing: 8) { + Toggle("", isOn: mapping.include) + .labelsHidden() + .frame(width: 50, alignment: .leading) + + VStack(alignment: .leading, spacing: 1) { + Text(mapping.wrappedValue.field.name) + .font(.body) + .lineLimit(1) + if let sample = mapping.wrappedValue.field.sampleValue, !sample.isEmpty { + Text(sample) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(width: 170, alignment: .leading) + + Picker("", selection: mapping.targetColumn) { + Text("Skip").tag(String?.none) + ForEach(targetColumns, id: \.self) { column in + Text(column).tag(String?.some(column)) + } + } + .labelsHidden() + .disabled(!mapping.wrappedValue.include) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private var footerView: some View { + HStack { + Button("Cancel") { isPresented = false } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Import") { performImport() } + .buttonStyle(.borderedProminent) + .disabled(!canImport) + .keyboardShortcut(.defaultAction) + } + .padding(16) + } + + // MARK: - Loading + + @MainActor + private func loadTables() async { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { return } + do { + availableTables = try await driver.fetchTables().filter { $0.type == .table } + } catch { + Self.logger.warning("Failed to load tables: \(error.localizedDescription, privacy: .public)") + } + } + + @MainActor + private func loadTargetContext(table: String) async { + guard let driver = DatabaseManager.shared.driver(for: connection.id), + let plugin = currentPlugin else { return } + isLoadingContext = true + loadError = nil + defer { isLoadingContext = false } + do { + let columns = try await driver.fetchColumns(table: table).map(\.name) + let fields = try plugin.detectSourceFields(at: fileURL, targetTable: table) + targetColumns = columns + mappings = fields.map { field in + let match = columns.first { $0.caseInsensitiveCompare(field.name) == .orderedSame } + return FieldMapping(field: field, include: match != nil, targetColumn: match) + } + } catch { + loadError = error.localizedDescription + Self.logger.warning("Failed to read import fields: \(error.localizedDescription, privacy: .public)") + } + } + + // MARK: - Import + + private func performImport() { + guard let targetTable = selectedTargetTable else { return } + + var mapping: [String: String] = [:] + for entry in mappings where entry.include { + if let column = entry.targetColumn { + mapping[entry.field.name] = column + } + } + + let service = ImportService(connection: connection) + importService = service + showProgressDialog = true + + importTask = Task { + do { + let result = try await service.importFile( + from: fileURL, + formatId: formatId, + encoding: .utf8, + targetTable: targetTable, + columnMapping: mapping + ) + await MainActor.run { + showProgressDialog = false + importResult = result + showSuccessDialog = true + } + } catch is PluginImportCancellationError { + await MainActor.run { showProgressDialog = false } + } catch { + await MainActor.run { + showProgressDialog = false + importError = error + showErrorDialog = true + } + } + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index fe78240d4..0ee7a0292 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -169,7 +169,11 @@ extension MainContentCoordinator { panel.beginSheetModal(for: window) { [weak self] response in guard response == .OK, let url = panel.url else { return } self?.importFileURL = url - self?.activeSheet = .importDialog + let ext = url.pathExtension.lowercased() + let isRowBased = PluginManager.shared.allImportPlugins().contains { + type(of: $0).requiresTargetTable && type(of: $0).acceptedFileExtensions.contains(ext) + } + self?.activeSheet = isRowBased ? .jsonImport : .importDialog } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 5b3ac7a7d..b1bbd0170 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -46,6 +46,7 @@ enum ActiveSheet: Identifiable { case sqlPreview case exportDialog case importDialog + case jsonImport case exportQueryResults case backupDatabase case restoreDatabase(fileURL: URL) @@ -58,6 +59,7 @@ enum ActiveSheet: Identifiable { case .sqlPreview: "sqlPreview" case .exportDialog: "exportDialog" case .importDialog: "importDialog" + case .jsonImport: "jsonImport" case .exportQueryResults: "exportQueryResults" case .backupDatabase: "backupDatabase" case .restoreDatabase(let fileURL): "restoreDatabase-\(fileURL.path)" diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index c246283f0..b7d9b22dd 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -227,6 +227,22 @@ struct MainContentView: View { connection: connection, initialFileURL: coordinator.importFileURL ) + case .jsonImport: + let jsonDismiss = Binding( + get: { coordinator.activeSheet != nil }, + set: { if !$0 { + coordinator.activeSheet = nil + coordinator.importFileURL = nil + } + } + ) + if let url = coordinator.importFileURL { + JSONImportSheet( + isPresented: jsonDismiss, + connection: connection, + fileURL: url + ) + } case .backupDatabase: BackupDatabaseFlow( isPresented: dismissBinding, From a4d3e502bbc219cb228fb5d9fbb8b2de82b11f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 16:20:51 +0700 Subject: [PATCH 06/10] feat(import): add create-new-table mode to the JSON import sheet --- .../Core/Plugins/JSONImportTypeMapper.swift | 71 ++++ TablePro/Views/Import/JSONImportSheet.swift | 355 ++++++++++++++---- .../Plugins/JSONImportTypeMapperTests.swift | 42 +++ 3 files changed, 398 insertions(+), 70 deletions(-) create mode 100644 TablePro/Core/Plugins/JSONImportTypeMapper.swift create mode 100644 TableProTests/Core/Plugins/JSONImportTypeMapperTests.swift diff --git a/TablePro/Core/Plugins/JSONImportTypeMapper.swift b/TablePro/Core/Plugins/JSONImportTypeMapper.swift new file mode 100644 index 000000000..8a221535b --- /dev/null +++ b/TablePro/Core/Plugins/JSONImportTypeMapper.swift @@ -0,0 +1,71 @@ +// +// JSONImportTypeMapper.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +enum JSONImportTypeMapper { + static func sqlType(for type: PluginImportFieldType, databaseType: DatabaseType) -> String { + switch databaseType { + case .postgresql, .redshift, .cockroachdb: + return postgresType(type) + case .mysql, .mariadb: + return mysqlType(type) + case .sqlite: + return sqliteType(type) + case .mssql: + return mssqlType(type) + default: + return genericType(type) + } + } + + private static func postgresType(_ type: PluginImportFieldType) -> String { + switch type { + case .integer: return "BIGINT" + case .real: return "DOUBLE PRECISION" + case .boolean: return "BOOLEAN" + case .json: return "JSONB" + case .text: return "TEXT" + } + } + + private static func mysqlType(_ type: PluginImportFieldType) -> String { + switch type { + case .integer: return "BIGINT" + case .real: return "DOUBLE" + case .boolean: return "TINYINT(1)" + case .json: return "JSON" + case .text: return "TEXT" + } + } + + private static func sqliteType(_ type: PluginImportFieldType) -> String { + switch type { + case .integer: return "INTEGER" + case .real: return "REAL" + case .boolean: return "INTEGER" + case .json, .text: return "TEXT" + } + } + + private static func mssqlType(_ type: PluginImportFieldType) -> String { + switch type { + case .integer: return "BIGINT" + case .real: return "FLOAT" + case .boolean: return "BIT" + case .json, .text: return "NVARCHAR(MAX)" + } + } + + private static func genericType(_ type: PluginImportFieldType) -> String { + switch type { + case .integer: return "INTEGER" + case .real: return "DOUBLE PRECISION" + case .boolean: return "BOOLEAN" + case .json, .text: return "TEXT" + } + } +} diff --git a/TablePro/Views/Import/JSONImportSheet.swift b/TablePro/Views/Import/JSONImportSheet.swift index 1284fccb1..3c386037b 100644 --- a/TablePro/Views/Import/JSONImportSheet.swift +++ b/TablePro/Views/Import/JSONImportSheet.swift @@ -3,7 +3,8 @@ // TablePro // // Dedicated import sheet for row-based formats (JSON / NDJSON): -// pick a destination table and map each source field to a column. +// map each source field to a column in an existing table, or create a new +// table with columns inferred from the data. // import os @@ -17,6 +18,11 @@ struct JSONImportSheet: View { let connection: DatabaseConnection let fileURL: URL + private enum Destination: Hashable { + case existingTable + case newTable + } + private struct FieldMapping: Identifiable { let field: PluginImportField var include: Bool @@ -24,10 +30,25 @@ struct JSONImportSheet: View { var id: String { field.name } } + private struct NewColumn: Identifiable { + let field: PluginImportField + var include: Bool + var name: String + var type: String + var isPrimaryKey: Bool + var isNullable: Bool + var defaultValue: String + var id: String { field.name } + } + + @State private var destination: Destination = .existingTable @State private var availableTables: [TableInfo] = [] @State private var selectedTargetTable: String? @State private var targetColumns: [String] = [] @State private var mappings: [FieldMapping] = [] + @State private var newTableName: String = "" + @State private var newColumns: [NewColumn] = [] + @State private var newColumnsLoaded = false @State private var isLoadingContext = false @State private var loadError: String? @@ -45,35 +66,29 @@ struct JSONImportSheet: View { VStack(alignment: .leading, spacing: 16) { fileInfoSection Divider() - destinationSection - if selectedTargetTable != nil { - Divider() - mappingSection - } - if let settable = currentPlugin as? any SettablePluginDiscoverable, - let optionsView = settable.settingsView() { - Divider() - VStack(alignment: .leading, spacing: 8) { - Text("Options") - .font(.callout.weight(.semibold)) - optionsView - } - } + destinationPicker + destinationDetail + Divider() + centralSection + optionsSection } .padding(16) } - .frame(width: 640, height: 560) + .frame(width: 700, height: 560) Divider() footerView } .background(Color(nsColor: .windowBackgroundColor)) - .task { await loadTables() } + .task { + await loadTables() + await loadNewColumns() + } .onChange(of: selectedTargetTable) { _, newValue in mappings = [] targetColumns = [] - guard let table = newValue else { return } - Task { await loadTargetContext(table: table) } + guard destination == .existingTable, let table = newValue else { return } + Task { await loadExistingContext(table: table) } } .onDisappear { importTask?.cancel() } .sheet(isPresented: $showProgressDialog) { @@ -107,9 +122,14 @@ struct JSONImportSheet: View { } private var canImport: Bool { - selectedTargetTable != nil - && mappings.contains { $0.include && $0.targetColumn != nil } - && !(importService?.state.isImporting ?? false) + guard !(importService?.state.isImporting ?? false) else { return false } + switch destination { + case .existingTable: + return selectedTargetTable != nil && mappings.contains { $0.include && $0.targetColumn != nil } + case .newTable: + return !newTableName.trimmingCharacters(in: .whitespaces).isEmpty + && newColumns.contains { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } + } } // MARK: - Sections @@ -127,81 +147,167 @@ struct JSONImportSheet: View { .foregroundStyle(.secondary) } Spacer() + if isLoadingContext { + ProgressView().controlSize(.small) + } } } - private var destinationSection: some View { - HStack(spacing: 8) { - Text("Import into:") - .font(.body) - .frame(width: 90, alignment: .leading) - Picker("", selection: $selectedTargetTable) { - Text("Select a table…").tag(String?.none) - ForEach(availableTables, id: \.id) { table in - Text(table.name).tag(String?.some(table.name)) + private var destinationPicker: some View { + Picker("Destination", selection: $destination) { + Text("Existing table").tag(Destination.existingTable) + Text("New table").tag(Destination.newTable) + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 300) + } + + @ViewBuilder + private var destinationDetail: some View { + switch destination { + case .existingTable: + HStack(spacing: 8) { + Text("Import into:") + .frame(width: 90, alignment: .leading) + Picker("", selection: $selectedTargetTable) { + Text("Select a table…").tag(String?.none) + ForEach(availableTables, id: \.id) { table in + Text(table.name).tag(String?.some(table.name)) + } } + .pickerStyle(.menu) + .frame(maxWidth: 260) + Spacer() } - .pickerStyle(.menu) - .frame(maxWidth: 260) - if isLoadingContext { - ProgressView().controlSize(.small) + case .newTable: + HStack(spacing: 8) { + Text("New table:") + .frame(width: 90, alignment: .leading) + TextField("table_name", text: $newTableName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 260) + Spacer() } - Spacer() } } + @ViewBuilder + private var centralSection: some View { + switch destination { + case .existingTable: + if selectedTargetTable != nil { + mappingSection + } else { + Text("Select a destination table to map fields.") + .font(.callout) + .foregroundStyle(.secondary) + } + case .newTable: + newColumnsSection + } + } + + // MARK: - Existing-table mapping + private var mappingSection: some View { + VStack(alignment: .leading, spacing: 8) { + mappingHeader + ForEach($mappings) { $mapping in + HStack(spacing: 8) { + Toggle("", isOn: $mapping.include) + .labelsHidden() + .frame(width: 50, alignment: .leading) + fieldLabel(mapping.field) + .frame(width: 200, alignment: .leading) + Picker("", selection: $mapping.targetColumn) { + Text("Skip").tag(String?.none) + ForEach(targetColumns, id: \.self) { column in + Text(column).tag(String?.some(column)) + } + } + .labelsHidden() + .disabled(!mapping.include) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var mappingHeader: some View { + HStack { + Text("Field Mapping").font(.callout.weight(.semibold)) + Spacer() + if let loadError { + Text(loadError).font(.caption).foregroundStyle(.red) + } + } + } + + // MARK: - New-table columns + + private var newColumnsSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { - Text("Field Mapping") - .font(.callout.weight(.semibold)) + Text("Columns").font(.callout.weight(.semibold)) Spacer() if let loadError { Text(loadError).font(.caption).foregroundStyle(.red) } } HStack(spacing: 8) { - Text("Import").frame(width: 50, alignment: .leading) - Text("JSON field").frame(width: 170, alignment: .leading) - Text("Column").frame(maxWidth: .infinity, alignment: .leading) + Text("Create").frame(width: 50, alignment: .leading) + Text("Column").frame(width: 150, alignment: .leading) + Text("Type").frame(width: 130, alignment: .leading) + Text("PK").frame(width: 34, alignment: .leading) + Text("Null").frame(width: 40, alignment: .leading) + Text("Default").frame(maxWidth: .infinity, alignment: .leading) } .font(.caption) .foregroundStyle(.secondary) - ForEach($mappings) { $mapping in - mappingRow($mapping) + ForEach($newColumns) { $column in + HStack(spacing: 8) { + Toggle("", isOn: $column.include).labelsHidden().frame(width: 50, alignment: .leading) + TextField("name", text: $column.name) + .textFieldStyle(.roundedBorder) + .frame(width: 150) + .disabled(!column.include) + TextField("type", text: $column.type) + .textFieldStyle(.roundedBorder) + .frame(width: 130) + .disabled(!column.include) + Toggle("", isOn: $column.isPrimaryKey).labelsHidden().frame(width: 34, alignment: .leading) + .disabled(!column.include) + Toggle("", isOn: $column.isNullable).labelsHidden().frame(width: 40, alignment: .leading) + .disabled(!column.include) + TextField("", text: $column.defaultValue) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: .infinity) + .disabled(!column.include) + } } } } - private func mappingRow(_ mapping: Binding) -> some View { - HStack(spacing: 8) { - Toggle("", isOn: mapping.include) - .labelsHidden() - .frame(width: 50, alignment: .leading) - - VStack(alignment: .leading, spacing: 1) { - Text(mapping.wrappedValue.field.name) - .font(.body) - .lineLimit(1) - if let sample = mapping.wrappedValue.field.sampleValue, !sample.isEmpty { - Text(sample) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + private func fieldLabel(_ field: PluginImportField) -> some View { + VStack(alignment: .leading, spacing: 1) { + Text(field.name).font(.body).lineLimit(1) + if let sample = field.sampleValue, !sample.isEmpty { + Text(sample).font(.caption).foregroundStyle(.secondary).lineLimit(1) } - .frame(width: 170, alignment: .leading) + } + } - Picker("", selection: mapping.targetColumn) { - Text("Skip").tag(String?.none) - ForEach(targetColumns, id: \.self) { column in - Text(column).tag(String?.some(column)) - } + @ViewBuilder + private var optionsSection: some View { + if let settable = currentPlugin as? any SettablePluginDiscoverable, + let optionsView = settable.settingsView() { + Divider() + VStack(alignment: .leading, spacing: 8) { + Text("Options").font(.callout.weight(.semibold)) + optionsView } - .labelsHidden() - .disabled(!mapping.wrappedValue.include) - .frame(maxWidth: .infinity, alignment: .leading) } } @@ -231,7 +337,32 @@ struct JSONImportSheet: View { } @MainActor - private func loadTargetContext(table: String) async { + private func loadNewColumns() async { + guard !newColumnsLoaded, let plugin = currentPlugin else { return } + isLoadingContext = true + defer { isLoadingContext = false } + do { + let fields = try plugin.detectSourceFields(at: fileURL, targetTable: nil) + newColumns = fields.map { field in + NewColumn( + field: field, + include: true, + name: field.name, + type: JSONImportTypeMapper.sqlType(for: field.inferredType, databaseType: connection.type), + isPrimaryKey: false, + isNullable: true, + defaultValue: "" + ) + } + newColumnsLoaded = true + } catch { + loadError = error.localizedDescription + Self.logger.warning("Failed to read import fields: \(error.localizedDescription, privacy: .public)") + } + } + + @MainActor + private func loadExistingContext(table: String) async { guard let driver = DatabaseManager.shared.driver(for: connection.id), let plugin = currentPlugin else { return } isLoadingContext = true @@ -254,21 +385,84 @@ struct JSONImportSheet: View { // MARK: - Import private func performImport() { - guard let targetTable = selectedTargetTable else { return } + switch destination { + case .existingTable: + guard let table = selectedTargetTable else { return } + runImport(targetTable: table, mapping: existingMapping(), createTableSQL: nil) + case .newTable: + let name = newTableName.trimmingCharacters(in: .whitespaces) + guard !name.isEmpty, let sql = buildCreateTableSQL(tableName: name) else { + importError = NSError( + domain: "JSONImport", code: -1, + userInfo: [NSLocalizedDescriptionKey: String(localized: "Could not build the CREATE TABLE statement")] + ) + showErrorDialog = true + return + } + runImport(targetTable: name, mapping: newTableMapping(), createTableSQL: sql) + } + } + private func existingMapping() -> [String: String] { var mapping: [String: String] = [:] for entry in mappings where entry.include { if let column = entry.targetColumn { mapping[entry.field.name] = column } } + return mapping + } + + private func newTableMapping() -> [String: String] { + var mapping: [String: String] = [:] + for column in newColumns where column.include && !column.name.trimmingCharacters(in: .whitespaces).isEmpty { + mapping[column.field.name] = column.name + } + return mapping + } + + private func buildCreateTableSQL(tableName: String) -> String? { + let included = newColumns.filter { + $0.include + && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty + && !$0.type.trimmingCharacters(in: .whitespaces).isEmpty + } + guard !included.isEmpty else { return nil } + + let definition = PluginCreateTableDefinition( + tableName: tableName, + columns: included.map { column in + PluginColumnDefinition( + name: column.name, + dataType: column.type, + isNullable: column.isNullable, + defaultValue: column.defaultValue.isEmpty ? nil : column.defaultValue, + isPrimaryKey: column.isPrimaryKey, + autoIncrement: false, + comment: nil, + unsigned: false, + onUpdate: nil, + charset: nil, + collation: nil + ) + }, + primaryKeyColumns: included.filter(\.isPrimaryKey).map(\.name) + ) + + let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver + return pluginDriver?.generateCreateTableSQL(definition: definition) + } + private func runImport(targetTable: String, mapping: [String: String], createTableSQL: String?) { let service = ImportService(connection: connection) importService = service showProgressDialog = true importTask = Task { do { + if let createTableSQL { + try await createTable(sql: createTableSQL) + } let result = try await service.importFile( from: fileURL, formatId: formatId, @@ -292,4 +486,25 @@ struct JSONImportSheet: View { } } } + + private func createTable(sql: String) async throws { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw DatabaseError.notConnected + } + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connection.id, + databaseType: connection.type, + sql: sql, + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Create Table") + ) + ) + guard case .authorized = decision else { + throw PluginImportError.importFailed(decision.deniedReason ?? String(localized: "Operation not permitted")) + } + _ = try await driver.execute(query: sql) + } } diff --git a/TableProTests/Core/Plugins/JSONImportTypeMapperTests.swift b/TableProTests/Core/Plugins/JSONImportTypeMapperTests.swift new file mode 100644 index 000000000..e03d4383a --- /dev/null +++ b/TableProTests/Core/Plugins/JSONImportTypeMapperTests.swift @@ -0,0 +1,42 @@ +// +// JSONImportTypeMapperTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("JSON Import Type Mapper") +struct JSONImportTypeMapperTests { + @Test("PostgreSQL maps inferred types to native SQL types") + func testPostgres() { + #expect(JSONImportTypeMapper.sqlType(for: .integer, databaseType: .postgresql) == "BIGINT") + #expect(JSONImportTypeMapper.sqlType(for: .real, databaseType: .postgresql) == "DOUBLE PRECISION") + #expect(JSONImportTypeMapper.sqlType(for: .boolean, databaseType: .postgresql) == "BOOLEAN") + #expect(JSONImportTypeMapper.sqlType(for: .json, databaseType: .postgresql) == "JSONB") + #expect(JSONImportTypeMapper.sqlType(for: .text, databaseType: .postgresql) == "TEXT") + } + + @Test("MySQL maps inferred types to native SQL types") + func testMySQL() { + #expect(JSONImportTypeMapper.sqlType(for: .integer, databaseType: .mysql) == "BIGINT") + #expect(JSONImportTypeMapper.sqlType(for: .boolean, databaseType: .mysql) == "TINYINT(1)") + #expect(JSONImportTypeMapper.sqlType(for: .json, databaseType: .mysql) == "JSON") + } + + @Test("SQLite uses its storage classes") + func testSQLite() { + #expect(JSONImportTypeMapper.sqlType(for: .integer, databaseType: .sqlite) == "INTEGER") + #expect(JSONImportTypeMapper.sqlType(for: .real, databaseType: .sqlite) == "REAL") + #expect(JSONImportTypeMapper.sqlType(for: .json, databaseType: .sqlite) == "TEXT") + } + + @Test("Unhandled database types fall back to generic SQL types") + func testFallback() { + #expect(JSONImportTypeMapper.sqlType(for: .text, databaseType: .clickhouse) == "TEXT") + #expect(JSONImportTypeMapper.sqlType(for: .integer, databaseType: .clickhouse) == "INTEGER") + #expect(JSONImportTypeMapper.sqlType(for: .boolean, databaseType: .clickhouse) == "BOOLEAN") + } +} From 60543f8f4e0b9f180c40345a0487a9fd7d452973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 16:22:16 +0700 Subject: [PATCH 07/10] docs(import): document JSON file import --- CHANGELOG.md | 1 + docs/features/import-export.mdx | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e54b256a9..e72e5f962 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Import a JSON file into a table. A dedicated sheet accepts an array of objects, newline-delimited JSON, or TablePro's own JSON export, and lets you map each field to a column in an existing table or in a new table with inferred, editable columns. - Save the current query as a favorite from a star button in the SQL editor toolbar. - Field names and types in the row Details panel can now be selected and copied. diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index 5d063a546..e10bc622e 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -198,7 +198,7 @@ Paste tabular data directly into the data grid. Press `Cmd+V` after selecting a ## Import Data -Import `.sql` and `.sql.gz` files. Statements execute directly against your database: backups, migrations, seed data. +Import `.sql` and `.sql.gz` files (statements execute directly against your database: backups, migrations, seed data), or `.json` / `.jsonl` files into a table (see [Import JSON Data](#import-json-data)). **MongoDB**: SQL import is not available. Use `mongoimport` or the MQL shell. @@ -257,6 +257,17 @@ Three modes for handling errors during import: In **Skip and Continue** mode, failed statements are collected (up to 1,000) with line numbers and error messages. After import completes, a summary dialog shows how many succeeded vs failed, with a scrollable error list and a "Copy Errors to Clipboard" button. +### Import JSON Data + +Pick a `.json`, `.jsonl`, or `.ndjson` file in **File** > **Import** and TablePro opens a dedicated JSON import sheet. It accepts an array of objects `[{...}, {...}]`, newline-delimited JSON (one object per line, streamed for large files), and TablePro's own JSON export shape `{ "table": [ {...} ] }`, so an export round-trips back in. + +Choose a destination: + +- **Existing table**: pick the table, then map each JSON field to a column. Fields are auto-matched by name; toggle any field off to skip it, or remap it to a different column. Columns with no matching field keep their default or NULL. +- **New table**: name the table and review the columns TablePro infers from the data. Each column's name, type, primary key, nullable flag, and default are editable before the table is created. + +Rows insert through parameterized statements, so JSON values are never concatenated into SQL. Nested objects and arrays are stored as JSON text. The on-error, transaction, and "delete existing rows" options work the same as SQL import. + ## Progress and Errors During import, a progress bar shows statements processed and overall completion. From d51e28940a2310d4bfba8ac5c7c49da0481618e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 16:48:51 +0700 Subject: [PATCH 08/10] refactor(import): rebuild the JSON import sheet with native Form, Grid, and a type pop-up --- TablePro/Views/Import/JSONImportSheet.swift | 386 +++++++++++--------- 1 file changed, 214 insertions(+), 172 deletions(-) diff --git a/TablePro/Views/Import/JSONImportSheet.swift b/TablePro/Views/Import/JSONImportSheet.swift index 3c386037b..f88868bd2 100644 --- a/TablePro/Views/Import/JSONImportSheet.swift +++ b/TablePro/Views/Import/JSONImportSheet.swift @@ -7,6 +7,7 @@ // table with columns inferred from the data. // +import Combine import os import SwiftUI import TableProPluginKit @@ -62,24 +63,28 @@ struct JSONImportSheet: View { var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - fileInfoSection - Divider() - destinationPicker - destinationDetail - Divider() - centralSection - optionsSection - } - .padding(16) - } - .frame(width: 700, height: 560) + headerView + .padding() + Divider() + + destinationForm + .padding(.horizontal) + .padding(.vertical, 10) + Divider() + + contentArea + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + optionsForm + .padding(.horizontal) + .padding(.vertical, 10) Divider() + footerView + .padding() } - .background(Color(nsColor: .windowBackgroundColor)) + .frame(width: 720, height: 600) .task { await loadTables() await loadNewColumns() @@ -108,42 +113,18 @@ struct JSONImportSheet: View { } } - // MARK: - Plugin - - private var currentPlugin: (any ImportFormatPlugin)? { - let ext = fileURL.pathExtension.lowercased() - return PluginManager.shared.allImportPlugins().first { - type(of: $0).requiresTargetTable && type(of: $0).acceptedFileExtensions.contains(ext) - } - } - - private var formatId: String { - currentPlugin.map { type(of: $0).formatId } ?? "json" - } - - private var canImport: Bool { - guard !(importService?.state.isImporting ?? false) else { return false } - switch destination { - case .existingTable: - return selectedTargetTable != nil && mappings.contains { $0.include && $0.targetColumn != nil } - case .newTable: - return !newTableName.trimmingCharacters(in: .whitespaces).isEmpty - && newColumns.contains { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } - } - } - - // MARK: - Sections + // MARK: - Header / forms - private var fileInfoSection: some View { + private var headerView: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: "curlybraces") .font(.title) .foregroundStyle(.blue) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 2) { Text(fileURL.lastPathComponent) - .font(.body.weight(.semibold)) + .font(.headline) Text("Import JSON rows into a table") - .font(.callout) + .font(.subheadline) .foregroundStyle(.secondary) } Spacer() @@ -153,175 +134,236 @@ struct JSONImportSheet: View { } } - private var destinationPicker: some View { - Picker("Destination", selection: $destination) { - Text("Existing table").tag(Destination.existingTable) - Text("New table").tag(Destination.newTable) - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 300) - } + private var destinationForm: some View { + Grid(alignment: .leading, horizontalSpacing: 8, verticalSpacing: 10) { + GridRow { + Text("Destination:") + .gridColumnAlignment(.trailing) + Picker("", selection: $destination) { + Text("Existing table").tag(Destination.existingTable) + Text("New table").tag(Destination.newTable) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + } - @ViewBuilder - private var destinationDetail: some View { - switch destination { - case .existingTable: - HStack(spacing: 8) { - Text("Import into:") - .frame(width: 90, alignment: .leading) - Picker("", selection: $selectedTargetTable) { - Text("Select a table…").tag(String?.none) - ForEach(availableTables, id: \.id) { table in - Text(table.name).tag(String?.some(table.name)) + if destination == .existingTable { + GridRow { + Text("Import into:") + Picker("", selection: $selectedTargetTable) { + Text("Select a table…").tag(String?.none) + ForEach(availableTables, id: \.id) { table in + Text(table.name).tag(String?.some(table.name)) + } } + .labelsHidden() + .frame(maxWidth: 280, alignment: .leading) + } + } else { + GridRow { + Text("New table:") + TextField("", text: $newTableName, prompt: Text("table_name")) + .frame(maxWidth: 280) } - .pickerStyle(.menu) - .frame(maxWidth: 260) - Spacer() } - case .newTable: - HStack(spacing: 8) { - Text("New table:") - .frame(width: 90, alignment: .leading) - TextField("table_name", text: $newTableName) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 260) - Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var optionsForm: some View { + Group { + if let settable = currentPlugin as? any SettablePluginDiscoverable, + let optionsView = settable.settingsView() { + VStack(alignment: .leading, spacing: 8) { + Text("Options").font(.callout.weight(.semibold)) + optionsView + } + .frame(maxWidth: .infinity, alignment: .leading) } } } + private var footerView: some View { + HStack { + Button("Cancel") { isPresented = false } + .keyboardShortcut(.cancelAction) + Spacer() + Button("Import") { performImport() } + .buttonStyle(.borderedProminent) + .disabled(!canImport) + .keyboardShortcut(.defaultAction) + } + } + + // MARK: - Content tables + @ViewBuilder - private var centralSection: some View { + private var contentArea: some View { switch destination { case .existingTable: - if selectedTargetTable != nil { - mappingSection + if selectedTargetTable == nil { + placeholder("Choose a destination table to map fields.") + } else if mappings.isEmpty { + placeholder(loadError ?? "No fields found in the file.") } else { - Text("Select a destination table to map fields.") - .font(.callout) - .foregroundStyle(.secondary) + mappingTable } case .newTable: - newColumnsSection + if newColumns.isEmpty { + placeholder(loadError ?? "No columns found in the file.") + } else { + newColumnsTable + } } } - // MARK: - Existing-table mapping + private func placeholder(_ message: String) -> some View { + VStack { + Spacer() + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } - private var mappingSection: some View { - VStack(alignment: .leading, spacing: 8) { - mappingHeader - ForEach($mappings) { $mapping in - HStack(spacing: 8) { - Toggle("", isOn: $mapping.include) - .labelsHidden() - .frame(width: 50, alignment: .leading) - fieldLabel(mapping.field) - .frame(width: 200, alignment: .leading) - Picker("", selection: $mapping.targetColumn) { - Text("Skip").tag(String?.none) - ForEach(targetColumns, id: \.self) { column in - Text(column).tag(String?.some(column)) + private var mappingTable: some View { + ScrollView { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Text("Import") + Text("JSON field") + Text("Column") + } + .font(.caption) + .foregroundStyle(.secondary) + Divider().gridCellColumns(3) + + ForEach(mappings) { row in + GridRow { + Toggle("", isOn: mappingBinding(row).include).labelsHidden() + VStack(alignment: .leading, spacing: 1) { + Text(row.field.name).lineLimit(1) + if let sample = row.field.sampleValue, !sample.isEmpty { + Text(sample).font(.caption).foregroundStyle(.secondary).lineLimit(1) + } } + Picker("", selection: mappingBinding(row).targetColumn) { + Text("Skip").tag(String?.none) + ForEach(targetColumns, id: \.self) { column in + Text(column).tag(String?.some(column)) + } + } + .labelsHidden() + .frame(maxWidth: 240, alignment: .leading) + .disabled(!row.include) } - .labelsHidden() - .disabled(!mapping.include) - .frame(maxWidth: .infinity, alignment: .leading) } } + .padding(.horizontal) + .padding(.vertical, 8) } } - private var mappingHeader: some View { - HStack { - Text("Field Mapping").font(.callout.weight(.semibold)) - Spacer() - if let loadError { - Text(loadError).font(.caption).foregroundStyle(.red) + private var newColumnsTable: some View { + ScrollView { + Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 6) { + GridRow { + Text("Create") + Text("Column") + Text("Type") + Text("Key") + Text("Null") + Text("Default") + } + .font(.caption) + .foregroundStyle(.secondary) + Divider().gridCellColumns(6) + + ForEach(newColumns) { row in + GridRow { + Toggle("", isOn: columnBinding(row).include).labelsHidden() + TextField("name", text: columnBinding(row).name) + .textFieldStyle(.roundedBorder) + .frame(width: 150) + .disabled(!row.include) + Picker("", selection: columnBinding(row).type) { + ForEach(typeOptions(including: row.type), id: \.self) { type in + Text(type).tag(type) + } + } + .labelsHidden() + .frame(width: 150) + .disabled(!row.include) + Toggle("", isOn: columnBinding(row).isPrimaryKey).labelsHidden().disabled(!row.include) + Toggle("", isOn: columnBinding(row).isNullable).labelsHidden().disabled(!row.include) + TextField("", text: columnBinding(row).defaultValue) + .textFieldStyle(.roundedBorder) + .frame(minWidth: 120) + .disabled(!row.include) + } + } } + .padding(.horizontal) + .padding(.vertical, 8) } } - // MARK: - New-table columns + // MARK: - Bindings - private var newColumnsSection: some View { - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Columns").font(.callout.weight(.semibold)) - Spacer() - if let loadError { - Text(loadError).font(.caption).foregroundStyle(.red) - } - } - HStack(spacing: 8) { - Text("Create").frame(width: 50, alignment: .leading) - Text("Column").frame(width: 150, alignment: .leading) - Text("Type").frame(width: 130, alignment: .leading) - Text("PK").frame(width: 34, alignment: .leading) - Text("Null").frame(width: 40, alignment: .leading) - Text("Default").frame(maxWidth: .infinity, alignment: .leading) - } - .font(.caption) - .foregroundStyle(.secondary) - - ForEach($newColumns) { $column in - HStack(spacing: 8) { - Toggle("", isOn: $column.include).labelsHidden().frame(width: 50, alignment: .leading) - TextField("name", text: $column.name) - .textFieldStyle(.roundedBorder) - .frame(width: 150) - .disabled(!column.include) - TextField("type", text: $column.type) - .textFieldStyle(.roundedBorder) - .frame(width: 130) - .disabled(!column.include) - Toggle("", isOn: $column.isPrimaryKey).labelsHidden().frame(width: 34, alignment: .leading) - .disabled(!column.include) - Toggle("", isOn: $column.isNullable).labelsHidden().frame(width: 40, alignment: .leading) - .disabled(!column.include) - TextField("", text: $column.defaultValue) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: .infinity) - .disabled(!column.include) - } - } + private func mappingBinding(_ row: FieldMapping) -> Binding { + guard let index = mappings.firstIndex(where: { $0.id == row.id }) else { + return .constant(row) } + return $mappings[index] } - private func fieldLabel(_ field: PluginImportField) -> some View { - VStack(alignment: .leading, spacing: 1) { - Text(field.name).font(.body).lineLimit(1) - if let sample = field.sampleValue, !sample.isEmpty { - Text(sample).font(.caption).foregroundStyle(.secondary).lineLimit(1) - } + private func columnBinding(_ row: NewColumn) -> Binding { + guard let index = newColumns.firstIndex(where: { $0.id == row.id }) else { + return .constant(row) } + return $newColumns[index] } - @ViewBuilder - private var optionsSection: some View { - if let settable = currentPlugin as? any SettablePluginDiscoverable, - let optionsView = settable.settingsView() { - Divider() - VStack(alignment: .leading, spacing: 8) { - Text("Options").font(.callout.weight(.semibold)) - optionsView - } + private var dialectTypes: [String] { + PluginManager.shared.columnTypesByCategory(for: connection.type) + .values + .flatMap { $0 } + .sorted() + } + + private func typeOptions(including current: String) -> [String] { + var types = dialectTypes + if !types.contains(where: { $0.caseInsensitiveCompare(current) == .orderedSame }) { + types.insert(current, at: 0) } + return types } - private var footerView: some View { - HStack { - Button("Cancel") { isPresented = false } - .keyboardShortcut(.cancelAction) - Spacer() - Button("Import") { performImport() } - .buttonStyle(.borderedProminent) - .disabled(!canImport) - .keyboardShortcut(.defaultAction) + // MARK: - Plugin + + private var currentPlugin: (any ImportFormatPlugin)? { + let ext = fileURL.pathExtension.lowercased() + return PluginManager.shared.allImportPlugins().first { + type(of: $0).requiresTargetTable && type(of: $0).acceptedFileExtensions.contains(ext) + } + } + + private var formatId: String { + currentPlugin.map { type(of: $0).formatId } ?? "json" + } + + private var canImport: Bool { + guard !(importService?.state.isImporting ?? false) else { return false } + switch destination { + case .existingTable: + return selectedTargetTable != nil && mappings.contains { $0.include && $0.targetColumn != nil } + case .newTable: + return !newTableName.trimmingCharacters(in: .whitespaces).isEmpty + && newColumns.contains { $0.include && !$0.name.trimmingCharacters(in: .whitespaces).isEmpty } } - .padding(16) } // MARK: - Loading From 08bd7213267c5dcbe61b138b1b464b03e4d68040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 1 Jun 2026 17:05:53 +0700 Subject: [PATCH 09/10] feat(import): add select-all toggles and mapping validation to the JSON import sheet --- TablePro/Views/Import/JSONImportSheet.swift | 70 ++++++++++++++++----- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/TablePro/Views/Import/JSONImportSheet.swift b/TablePro/Views/Import/JSONImportSheet.swift index f88868bd2..d52879488 100644 --- a/TablePro/Views/Import/JSONImportSheet.swift +++ b/TablePro/Views/Import/JSONImportSheet.swift @@ -188,6 +188,12 @@ struct JSONImportSheet: View { HStack { Button("Cancel") { isPresented = false } .keyboardShortcut(.cancelAction) + if let message = validationMessage { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } Spacer() Button("Import") { performImport() } .buttonStyle(.borderedProminent) @@ -233,12 +239,12 @@ struct JSONImportSheet: View { ScrollView { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { - Text("Import") - Text("JSON field") - Text("Column") + Toggle("", isOn: allMappingsIncluded) + .labelsHidden() + .help(String(localized: "Import all fields")) + Text("JSON field").font(.caption).foregroundStyle(.secondary) + Text("Column").font(.caption).foregroundStyle(.secondary) } - .font(.caption) - .foregroundStyle(.secondary) Divider().gridCellColumns(3) ForEach(mappings) { row in @@ -271,15 +277,15 @@ struct JSONImportSheet: View { ScrollView { Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 6) { GridRow { - Text("Create") - Text("Column") - Text("Type") - Text("Key") - Text("Null") - Text("Default") + Toggle("", isOn: allColumnsIncluded) + .labelsHidden() + .help(String(localized: "Create all columns")) + Text("Column").font(.caption).foregroundStyle(.secondary) + Text("Type").font(.caption).foregroundStyle(.secondary) + Text("Key").font(.caption).foregroundStyle(.secondary) + Text("Null").font(.caption).foregroundStyle(.secondary) + Text("Default").font(.caption).foregroundStyle(.secondary) } - .font(.caption) - .foregroundStyle(.secondary) Divider().gridCellColumns(6) ForEach(newColumns) { row in @@ -327,6 +333,42 @@ struct JSONImportSheet: View { return $newColumns[index] } + private var allMappingsIncluded: Binding { + Binding( + get: { !mappings.isEmpty && mappings.allSatisfy(\.include) }, + set: { value in for index in mappings.indices { mappings[index].include = value } } + ) + } + + private var allColumnsIncluded: Binding { + Binding( + get: { !newColumns.isEmpty && newColumns.allSatisfy(\.include) }, + set: { value in for index in newColumns.indices { newColumns[index].include = value } } + ) + } + + private var validationMessage: String? { + switch destination { + case .existingTable: + let columns = mappings.filter { $0.include }.compactMap { $0.targetColumn?.lowercased() } + if Set(columns).count != columns.count { + return String(localized: "Each column can be mapped from only one field.") + } + return nil + case .newTable: + let names = newColumns + .filter { $0.include } + .map { $0.name.trimmingCharacters(in: .whitespaces).lowercased() } + if names.contains(where: \.isEmpty) { + return String(localized: "Every included column needs a name.") + } + if Set(names).count != names.count { + return String(localized: "Column names must be unique.") + } + return nil + } + } + private var dialectTypes: [String] { PluginManager.shared.columnTypesByCategory(for: connection.type) .values @@ -356,7 +398,7 @@ struct JSONImportSheet: View { } private var canImport: Bool { - guard !(importService?.state.isImporting ?? false) else { return false } + guard !(importService?.state.isImporting ?? false), validationMessage == nil else { return false } switch destination { case .existingTable: return selectedTargetTable != nil && mappings.contains { $0.include && $0.targetColumn != nil } From 18de96f8ee6500598e2c347c24e5fa2254f6d881 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 1 Jun 2026 22:16:30 +0700 Subject: [PATCH 10/10] fix(plugins): sync TableProCore PluginKit import contracts with the desktop framework (#1536) --- .../ImportFormatPlugin.swift | 6 +++++ .../PluginImportDataSink.swift | 10 +++++++++ .../TableProPluginKit/PluginImportTypes.swift | 22 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift index eb955a894..a26a31897 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/ImportFormatPlugin.swift @@ -13,16 +13,22 @@ public protocol ImportFormatPlugin: TableProPlugin { static var iconName: String { get } static var supportedDatabaseTypeIds: [String] { get } static var excludedDatabaseTypeIds: [String] { get } + static var requiresTargetTable: Bool { get } func performImport( source: any PluginImportSource, sink: any PluginImportDataSink, progress: PluginImportProgress ) async throws -> PluginImportResult + + func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] } public extension ImportFormatPlugin { static var capabilities: [PluginCapability] { [.importFormat] } static var supportedDatabaseTypeIds: [String] { [] } static var excludedDatabaseTypeIds: [String] { [] } + static var requiresTargetTable: Bool { false } + + func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] { [] } } diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginImportDataSink.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginImportDataSink.swift index ea70ec46e..c004b7315 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/PluginImportDataSink.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginImportDataSink.swift @@ -7,7 +7,10 @@ import Foundation public protocol PluginImportDataSink: AnyObject, Sendable { var databaseTypeId: String { get } + var targetTable: String? { get } func execute(statement: String) async throws + func insertRow(_ values: [String: PluginCellValue]) async throws + func deleteAllRowsFromTargetTable() async throws func beginTransaction() async throws func commitTransaction() async throws func rollbackTransaction() async throws @@ -16,6 +19,13 @@ public protocol PluginImportDataSink: AnyObject, Sendable { } public extension PluginImportDataSink { + var targetTable: String? { nil } + func insertRow(_ values: [String: PluginCellValue]) async throws { + throw PluginImportError.importFailed("Row-based import is not supported by this connection") + } + func deleteAllRowsFromTargetTable() async throws { + throw PluginImportError.importFailed("Clearing the target table is not supported by this connection") + } func disableForeignKeyChecks() async throws {} func enableForeignKeyChecks() async throws {} } diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginImportTypes.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginImportTypes.swift index 409a38d8d..b12035a33 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/PluginImportTypes.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginImportTypes.swift @@ -11,6 +11,28 @@ public enum ImportErrorHandling: String, Codable, CaseIterable, Sendable { case skipAndContinue } +public enum PluginImportFieldType: String, Sendable { + case text + case integer + case real + case boolean + case json +} + +public struct PluginImportField: Sendable, Identifiable { + public let name: String + public let sampleValue: String? + public let inferredType: PluginImportFieldType + + public var id: String { name } + + public init(name: String, sampleValue: String?, inferredType: PluginImportFieldType) { + self.name = name + self.sampleValue = sampleValue + self.inferredType = inferredType + } +} + public struct PluginImportResult: Sendable { public let executedStatements: Int public let skippedStatements: Int