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.