Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions apple/Plugins/CSVImportPlugin/CSVImportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ final class CSVImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi

var fieldDetectionSignature: String { settings.detectionSignature }

var rowImportConfiguration: RowImportRunner.Configuration? { runConfiguration }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rowImportConfiguration and runConfiguration are pasted identically into the CSV, JSON and XLSX plugins, where rowImportConfiguration just returns runConfiguration. A new setting forgotten in one copy silently desynchronises batch policy from a file's own import. Share one definition.


private var runConfiguration: RowImportRunner.Configuration {
RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
)
}

required init() { loadSettings() }

@MainActor
Expand Down Expand Up @@ -82,11 +92,7 @@ final class CSVImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlugi
let lineOffset = hasHeader ? 2 : 1
var cursor = 0
let outcome = try await RowImportRunner.run(
configuration: RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
),
configuration: runConfiguration,
sink: sink,
progress: progress
) {
Expand Down
54 changes: 54 additions & 0 deletions apple/Plugins/FormatPluginTests/RowImportConfigurationTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
//
// RowImportConfigurationTests.swift
// TableProTests
//

import Foundation
import TableProPluginKit
import Testing

/// A run of several files has one transaction, so it reads each format's error handling and transaction options
/// before it starts, and they have to be the ones the format's own import runs with.
struct RowImportConfigurationTests {
@Test("CSV reports the options its import runs with")
func csvReportsItsOptions() throws {
let plugin = CSVImportPlugin()
let saved = plugin.settings
defer { plugin.settings = saved }
plugin.settings.errorHandling = .stopAndCommit
plugin.settings.wrapInTransaction = false
plugin.settings.deleteExistingRows = true

let configuration = try #require(plugin.rowImportConfiguration)

#expect(configuration.errorHandling == .stopAndCommit)
#expect(configuration.wrapInTransaction == false)
#expect(configuration.deleteExistingRows == true)
}

@Test("JSON reports the options its import runs with")
func jsonReportsItsOptions() throws {
let plugin = JSONImportPlugin()
let saved = plugin.settings
defer { plugin.settings = saved }
plugin.settings.errorHandling = .skipAndContinue

let configuration = try #require(plugin.rowImportConfiguration)

#expect(configuration.errorHandling == .skipAndContinue)
#expect(configuration.wrapInTransaction == saved.wrapInTransaction)
}

@Test("XLSX reports the options its import runs with")
func xlsxReportsItsOptions() throws {
let plugin = XLSXImportPlugin()
let saved = plugin.settings
defer { plugin.settings = saved }
plugin.settings.deleteExistingRows = true

let configuration = try #require(plugin.rowImportConfiguration)

#expect(configuration.deleteExistingRows == true)
#expect(configuration.errorHandling == saved.errorHandling)
}
}
16 changes: 11 additions & 5 deletions apple/Plugins/JSONImportPlugin/JSONImportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ final class JSONImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlug

required init() { loadSettings() }

var rowImportConfiguration: RowImportRunner.Configuration? { runConfiguration }

private var runConfiguration: RowImportRunner.Configuration {
RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
)
}

@MainActor
func settingsView() -> AnyView? {
AnyView(JSONImportOptionsView(plugin: self))
Expand All @@ -51,11 +61,7 @@ final class JSONImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlug
) async throws -> PluginImportResult {
let startTime = Date()
let url = source.fileURL()
let configuration = RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
)
let configuration = runConfiguration

let outcome: RowImportRunner.Outcome
var unreadableLines: [PluginImportResult.ImportStatementError] = []
Expand Down
6 changes: 6 additions & 0 deletions apple/Plugins/TableProPluginKit/ImportFormatPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ public protocol ImportFormatPlugin: TableProPlugin, Sendable {

var fieldDetectionSignature: String { get }

/// The error handling and transaction options the next import runs with. A host that runs several files in one
/// transaction reads them first, because the files have to agree. Nil for a format that runs statements.
var rowImportConfiguration: RowImportRunner.Configuration? { get }

func performImport(
source: any PluginImportSource,
sink: any PluginImportDataSink,
Expand All @@ -36,5 +40,7 @@ public extension ImportFormatPlugin {

var fieldDetectionSignature: String { "" }

var rowImportConfiguration: RowImportRunner.Configuration? { nil }

func detectSourceFields(at url: URL, targetTable: String?) throws -> [PluginImportField] { [] }
}
16 changes: 11 additions & 5 deletions apple/Plugins/XLSXImportPlugin/XLSXImportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ final class XLSXImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlug

var fieldDetectionSignature: String { settings.detectionSignature }

var rowImportConfiguration: RowImportRunner.Configuration? { runConfiguration }

private var runConfiguration: RowImportRunner.Configuration {
RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
)
}

@MainActor
func settingsView() -> AnyView? {
AnyView(XLSXImportOptionsView(plugin: self))
Expand Down Expand Up @@ -78,11 +88,7 @@ final class XLSXImportPlugin: ObservableObject, ImportFormatPlugin, SettablePlug
progress.setEstimatedTotal(max(1, sheet.rows.count))
var cursor = 0
let outcome = try await RowImportRunner.run(
configuration: RowImportRunner.Configuration(
errorHandling: settings.errorHandling,
wrapInTransaction: settings.wrapInTransaction,
deleteExistingRows: settings.deleteExistingRows
),
configuration: runConfiguration,
sink: sink,
progress: progress
) {
Expand Down
7 changes: 7 additions & 0 deletions apple/TablePro/Core/Plugins/ImportDataSinkAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
/// the user's own: a `BEGIN` there would join it, and the import's `COMMIT` would commit work the
/// user never asked to commit, so the import then runs inside it and leaves ending it to them.
private let ownsTransaction = OSAllocatedUnfairLock(initialState: false)
/// One file of several imported in one run. The run opened the transaction and ends it, so this sink sends no
/// `BEGIN`, `COMMIT` or `ROLLBACK`: reading the session would not stop it, since `.unknown` permits a `BEGIN`
/// and MySQL commits the open transaction on one.
private let joinsTransaction: Bool
private let transactionRecord = OSAllocatedUnfairLock(initialState: TransactionRecord())

private struct TransactionRecord {
Expand Down Expand Up @@ -78,9 +82,11 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
targetTable: String? = nil,
columnMapping: [String: String] = [:],
sourceFields: Set<String> = [],
joinsTransaction: Bool = false,
isCancelled: @escaping @Sendable () -> Bool = { false }
) {
self.isCancelled = isCancelled
self.joinsTransaction = joinsTransaction
self.driver = driver
self.databaseType = databaseType
self.grammar = databaseType.lexicalGrammar
Expand Down Expand Up @@ -310,6 +316,7 @@ final class ImportDataSinkAdapter: PluginImportDataSink, @unchecked Sendable {
}

func beginTransaction() async throws {
guard !joinsTransaction else { return }
let owner = WriteTransactionOwner.resolve(
supportsTransactions: driver.supportsTransactions,
sessionState: await driver.heldSessionTransactionState()
Expand Down
62 changes: 62 additions & 0 deletions apple/TablePro/Core/Services/Export/ImportBatchFiles.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// ImportBatchFiles.swift
// TablePro
//

import Foundation

/// The files a choice of several files or folders imports as rows, each with the format its extension names.
///
/// A folder gives the visible files directly inside it, not those of its subfolders, in Finder's order. A file no
/// row format reads, a SQL dump among them, is left out by name rather than dropped silently: a dump runs its own
/// statements and is imported on its own.
internal enum ImportBatchFiles {
internal struct File: Equatable, Sendable {
internal let url: URL
internal let formatId: String
}

internal struct Resolution: Equatable, Sendable {
internal var files: [File] = []
internal var leftOut: [URL] = []
}

internal static func resolve(
_ urls: [URL],
among options: [ImportFormatOption],
readsRows: (String) -> Bool
) -> Resolution {
var resolution = Resolution()
var seen = Set<String>()
for url in urls {
let candidates = isDirectory(url) ? contents(of: url) : [url]
for candidate in candidates {
let key = candidate.standardizedFileURL.path(percentEncoded: false)
guard seen.insert(key).inserted else { continue }
if case .format(let formatId) = ImportFileFormatResolver.match(candidate, among: options),
readsRows(formatId) {
resolution.files.append(File(url: candidate, formatId: formatId))
} else {
resolution.leftOut.append(candidate)
}
}
}
return resolution
}

internal static func isDirectory(_ url: URL) -> Bool {
(try? url.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true
}

private static func contents(of folder: URL) -> [URL] {
let keys: [URLResourceKey] = [.isRegularFileKey]
guard let entries = try? FileManager.default.contentsOfDirectory(
at: folder,
includingPropertiesForKeys: keys,
options: [.skipsHiddenFiles]
) else { return [] }
return entries
.filter { (try? $0.resourceValues(forKeys: Set(keys)))?.isRegularFile == true }
.sorted { $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent) == .orderedAscending }
}
}
Loading
Loading