diff --git a/CHANGELOG.md b/CHANGELOG.md index f84b0518e..697487d63 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. - The window title bar shows the open table's name, with its database and schema below, so you can tell which table you're viewing without checking the sidebar. (#1475) - iOS: open DuckDB database files and in-memory DuckDB databases, matching the Mac app. (#1526) - Save the current query as a favorite from a star button in the SQL editor toolbar. 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 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..9bea84ff0 --- /dev/null +++ b/Plugins/JSONImportPlugin/JSONImportPlugin.swift @@ -0,0 +1,309 @@ +// +// 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 + } + + // 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 eb955a894..a26a31897 100644 --- a/Plugins/TableProPluginKit/ImportFormatPlugin.swift +++ b/Plugins/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/Plugins/TableProPluginKit/PluginImportDataSink.swift b/Plugins/TableProPluginKit/PluginImportDataSink.swift index ea70ec46e..c004b7315 100644 --- a/Plugins/TableProPluginKit/PluginImportDataSink.swift +++ b/Plugins/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/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/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 8c7964424..681b63c56 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -178,6 +178,28 @@ 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) + } + + 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 f4ccd3c6f..784113755 100644 --- a/TablePro/Core/Plugins/ImportDataSinkAdapter.swift +++ b/TablePro/Core/Plugins/ImportDataSinkAdapter.swift @@ -9,19 +9,76 @@ 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 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/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/Core/Plugins/PlainFileImportSource.swift b/TablePro/Core/Plugins/PlainFileImportSource.swift new file mode 100644 index 000000000..142f332f0 --- /dev/null +++ b/TablePro/Core/Plugins/PlainFileImportSource.swift @@ -0,0 +1,37 @@ +// +// 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 { + let path = url.path(percentEncoded: false) + do { + let attrs = try FileManager.default.attributesOfItem(atPath: path) + return attrs[.size] as? Int64 ?? 0 + } catch { + Self.logger.warning("Failed to get file size for \(path): \(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 diff --git a/TablePro/Views/Import/JSONImportSheet.swift b/TablePro/Views/Import/JSONImportSheet.swift new file mode 100644 index 000000000..d52879488 --- /dev/null +++ b/TablePro/Views/Import/JSONImportSheet.swift @@ -0,0 +1,594 @@ +// +// JSONImportSheet.swift +// TablePro +// +// Dedicated import sheet for row-based formats (JSON / NDJSON): +// map each source field to a column in an existing table, or create a new +// table with columns inferred from the data. +// + +import Combine +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 enum Destination: Hashable { + case existingTable + case newTable + } + + private struct FieldMapping: Identifiable { + let field: PluginImportField + var include: Bool + var targetColumn: String? + 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? + + @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) { + 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() + } + .frame(width: 720, height: 600) + .task { + await loadTables() + await loadNewColumns() + } + .onChange(of: selectedTargetTable) { _, newValue in + mappings = [] + targetColumns = [] + guard destination == .existingTable, let table = newValue else { return } + Task { await loadExistingContext(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: - Header / forms + + private var headerView: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "curlybraces") + .font(.title) + .foregroundStyle(.blue) + VStack(alignment: .leading, spacing: 2) { + Text(fileURL.lastPathComponent) + .font(.headline) + Text("Import JSON rows into a table") + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + if isLoadingContext { + ProgressView().controlSize(.small) + } + } + } + + 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() + } + + 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) + } + } + } + .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) + if let message = validationMessage { + Text(message) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + Spacer() + Button("Import") { performImport() } + .buttonStyle(.borderedProminent) + .disabled(!canImport) + .keyboardShortcut(.defaultAction) + } + } + + // MARK: - Content tables + + @ViewBuilder + private var contentArea: some View { + switch destination { + case .existingTable: + if selectedTargetTable == nil { + placeholder("Choose a destination table to map fields.") + } else if mappings.isEmpty { + placeholder(loadError ?? "No fields found in the file.") + } else { + mappingTable + } + case .newTable: + if newColumns.isEmpty { + placeholder(loadError ?? "No columns found in the file.") + } else { + newColumnsTable + } + } + } + + private func placeholder(_ message: String) -> some View { + VStack { + Spacer() + Text(message) + .font(.callout) + .foregroundStyle(.secondary) + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var mappingTable: some View { + ScrollView { + Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { + GridRow { + Toggle("", isOn: allMappingsIncluded) + .labelsHidden() + .help(String(localized: "Import all fields")) + Text("JSON field").font(.caption).foregroundStyle(.secondary) + 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) + } + } + } + .padding(.horizontal) + .padding(.vertical, 8) + } + } + + private var newColumnsTable: some View { + ScrollView { + Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 6) { + GridRow { + 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) + } + 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: - Bindings + + 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 columnBinding(_ row: NewColumn) -> Binding { + guard let index = newColumns.firstIndex(where: { $0.id == row.id }) else { + return .constant(row) + } + 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 + .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 + } + + // 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), validationMessage == nil 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: - 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 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 + 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() { + 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, + 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 + } + } + } + } + + 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/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 bee244815..5972adc80 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -230,6 +230,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, 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/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") + } +} diff --git a/TableProTests/Plugins/JSONImportPluginTests.swift b/TableProTests/Plugins/JSONImportPluginTests.swift new file mode 100644 index 000000000..93fea57fb --- /dev/null +++ b/TableProTests/Plugins/JSONImportPluginTests.swift @@ -0,0 +1,178 @@ +// +// 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")) + } + + // 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") + } +} 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.