diff --git a/CHANGELOG.md b/CHANGELOG.md index ff145aa0b..24c399c56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `EXPLAIN ANALYSE` treated as a plain `EXPLAIN` that does not run its statement. - Safe Mode level lost when importing a connection file from Mac to iOS or from iOS to Mac. - Undo and Redo in a tab with unsaved edits replaying another tab's changes against the wrong rows. +- Table Transfer emptying a destination table, then failing, when two source columns map to one column. - Elasticsearch, Typesense and SurrealDB table exports cutting arrays and objects over 10,000 characters into unreadable JSON. - Server dashboard Slow Queries panel failing on every refresh on SQL Server. - Server dashboard sessions, slow queries, connection count and active query count failing on Redshift. diff --git a/TablePro/Core/Services/Export/TableColumnMatcher.swift b/TablePro/Core/Services/Export/TableColumnMatcher.swift index 642ae9daf..d196c167c 100644 --- a/TablePro/Core/Services/Export/TableColumnMatcher.swift +++ b/TablePro/Core/Services/Export/TableColumnMatcher.swift @@ -25,58 +25,90 @@ enum TableColumnMatcher { let unmatchedDestination: [String] var isEmpty: Bool { mapping.isEmpty } - } - /// Case-insensitive, because engines disagree about identifier folding and a transfer from a - /// case-folding engine to a case-preserving one would otherwise match nothing. - static func match(source: [String], destination: [String]) -> Match { - var destinationByFolded: [String: String] = [:] - for column in destination { - destinationByFolded[column.lowercased()] = column + /// Destination columns more than one source column is mapped to. The INSERT would name + /// each of them twice, which every engine refuses, so the transfer cannot run until the + /// user moves one. + var contestedDestinations: [String] { + TableColumnMatcher.contestedDestinations(in: mapping) } + } + /// Exact spelling first, then case-insensitive, because engines disagree about identifier + /// folding and a transfer from a case-folding engine to a case-preserving one would otherwise + /// match nothing. A destination column goes to one source column at most, so `Name` and `name` + /// on the source never both land on a lone `name`. + static func match(source: [String], destination: [String]) -> Match { var mapping: [String: String] = [:] - var unmatchedSource: [String] = [] - var claimed: Set = [] - for column in source { - guard let target = destinationByFolded[column.lowercased()] else { - unmatchedSource.append(column) - continue - } + for (column, target) in zip(source, pair(source, with: destination)) { + guard let target else { continue } mapping[column] = target - claimed.insert(target) } - return Match( - mapping: mapping, - unmatchedSource: unmatchedSource, - unmatchedDestination: destination.filter { !claimed.contains($0) } - ) + return resolved(mapping, source: source, destination: destination) } - /// Applies the user's overrides over an automatic match. An override to nil excludes the + /// The automatic match with the user's overrides laid over it. An override to nil excludes the /// column, which is how a source column with no destination is deliberately dropped rather - /// than failing the transfer. - static func applying( - overrides: [String: String?], - to match: Match, - destination: [String] + /// than failing the transfer. An override may point at a column another source column + /// already holds; that is kept and reported through `contestedDestinations`, not resolved + /// by quietly unmapping the other one. + static func match( + source: [String], + destination: [String], + overrides: [String: String?] ) -> Match { - var mapping = match.mapping - var unmatchedSource = Set(match.unmatchedSource) + var mapping = match(source: source, destination: destination).mapping for (sourceColumn, target) in overrides { guard let target, destination.contains(target) else { mapping.removeValue(forKey: sourceColumn) - unmatchedSource.insert(sourceColumn) continue } mapping[sourceColumn] = target - unmatchedSource.remove(sourceColumn) } + return resolved(mapping, source: source, destination: destination) + } + + /// Both sides' leftovers in their own table's column order, whichever way the mapping was made. + private static func resolved(_ mapping: [String: String], source: [String], destination: [String]) -> Match { let claimed = Set(mapping.values) return Match( mapping: mapping, - unmatchedSource: unmatchedSource.sorted(), + unmatchedSource: source.filter { mapping[$0] == nil }, unmatchedDestination: destination.filter { !claimed.contains($0) } ) } + + /// Destination columns named by more than one entry of `mapping`, sorted. + static func contestedDestinations(in mapping: [String: String]) -> [String] { + var sourceCount: [String: Int] = [:] + for target in mapping.values { + sourceCount[target, default: 0] += 1 + } + return sourceCount.filter { $0.value > 1 }.keys.sorted() + } + + /// Pairs each name with a candidate of the same spelling, then with a candidate left over that + /// differs only by case, earlier names first. A candidate is paired with one name at most. + private static func pair(_ names: [String], with candidates: [String]) -> [String?] { + var pairs = [String?](repeating: nil, count: names.count) + var claimed = Set() + let spelled = Set(candidates) + for (index, name) in names.enumerated() { + guard spelled.contains(name), !claimed.contains(name) else { continue } + pairs[index] = name + claimed.insert(name) + } + + var unclaimedByFolded: [String: [String]] = [:] + for candidate in candidates where !claimed.contains(candidate) { + unclaimedByFolded[candidate.lowercased(), default: []].append(candidate) + } + for (index, name) in names.enumerated() where pairs[index] == nil { + let folded = name.lowercased() + guard var remaining = unclaimedByFolded[folded], !remaining.isEmpty else { continue } + pairs[index] = remaining.removeFirst() + unclaimedByFolded[folded] = remaining + } + return pairs + } } diff --git a/TablePro/Core/Services/Export/TableTransferService.swift b/TablePro/Core/Services/Export/TableTransferService.swift index 993211a09..0177aeb37 100644 --- a/TablePro/Core/Services/Export/TableTransferService.swift +++ b/TablePro/Core/Services/Export/TableTransferService.swift @@ -14,6 +14,7 @@ enum TableTransferError: LocalizedError { case sameConnectionAndContainer case targetMissing(table: String) case noMatchingColumns(table: String) + case contestedDestination(table: String, columns: [String]) case transferFailed(String) var errorDescription: String? { @@ -30,6 +31,11 @@ enum TableTransferError: LocalizedError { return String( format: String(localized: "No column of %@ matches a column on the destination table."), table) + case .contestedDestination(let table, let columns): + return String( + format: String(localized: "%1$@: more than one source column is mapped to %2$@."), + table, + columns.joined(separator: ", ")) case .transferFailed(let message): return String(format: String(localized: "Transfer failed: %@"), message) } @@ -137,6 +143,7 @@ final class TableTransferService: ObservableObject { ) async throws { let rowObjects = request.objects.filter { $0.kind.carriesRows } guard !rowObjects.isEmpty else { throw TableTransferError.noTablesSelected } + try Self.refuseContestedMappings(request.columnMapping, for: rowObjects) /// The flag is cleared on the way out, never on the way in. A Stop pressed while the sheet /// was still reading both sides' columns arrives before this line, and clearing it here @@ -161,6 +168,7 @@ final class TableTransferService: ObservableObject { databaseType: request.destinationType, targetTable: object.name, columnMapping: mapping, + sourceFields: Set(request.sourceColumns[object.name] ?? []), isCancelled: { [flag = cancellationFlag] in flag.isCancelled } ) try await transferOne(object: object, from: source, into: sink, request: request) @@ -168,6 +176,22 @@ final class TableTransferService: ObservableObject { state.warnings.append(contentsOf: source.cappedTableWarnings) } + /// Checked for every table before the first is written: the INSERT would name the contested + /// column twice, and the server refuses it only after "Delete existing rows first" has already + /// emptied the table, permanently when the table is not wrapped in a transaction. + nonisolated static func refuseContestedMappings( + _ mappings: [String: [String: String]], + for objects: [ExportObjectItem] + ) throws { + for object in objects { + guard let mapping = mappings[object.name] else { continue } + let contested = TableColumnMatcher.contestedDestinations(in: mapping) + guard contested.isEmpty else { + throw TableTransferError.contestedDestination(table: object.name, columns: contested) + } + } + } + /// The sink writes by column name and skips any field the mapping does not name, so an empty /// mapping writes nothing and reports every row as unmapped. A caller that supplies no mapping /// gets one matched by name, and a table whose columns match nothing is refused by name here diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 688f1090b..44b291bc9 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -3507,6 +3507,9 @@ }, "%@ loses its default" : { + }, + "%@ mapped more than once" : { + }, "%@ matches" : { @@ -5967,6 +5970,9 @@ }, "%1$@: %2$@ had no column of that name on the destination." : { + }, + "%1$@: more than one source column is mapped to %2$@." : { + }, "%1$@: only the first %2$lld rows were read, the most this database returns from one query." : { @@ -19884,6 +19890,9 @@ }, "Another profile already has this name." : { + }, + "Another source column is mapped to the same destination column." : { + }, "ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN are removed from the tool's environment, so replies always draw on the subscription." : { "localizations" : { @@ -57271,6 +57280,9 @@ }, "Each database is written to its own file." : { + }, + "Each destination column can be mapped from only one source column." : { + }, "Each value is split at every match. Rows with fewer pieces get empty cells." : { diff --git a/TablePro/Views/Export/TableTransferMappingEditor.swift b/TablePro/Views/Export/TableTransferMappingEditor.swift index cabf89e64..8b4ae7898 100644 --- a/TablePro/Views/Export/TableTransferMappingEditor.swift +++ b/TablePro/Views/Export/TableTransferMappingEditor.swift @@ -11,24 +11,45 @@ import SwiftUI /// the two schemas were renamed apart. Without this the only way to correct that would be to rename /// a column on one side. internal struct TableTransferMappingEditor: View { + internal static var contestedMappingMessage: String { + String(localized: "Each destination column can be mapped from only one source column.") + } + internal let tableName: String internal let sourceColumns: [String] internal let destinationColumns: [String] - @Binding internal var overrides: [String: String?] + internal let onChange: ([String: String?]) -> Void internal let dismiss: () -> Void - private var automatic: TableColumnMatcher.Match { - TableColumnMatcher.match(source: sourceColumns, destination: destinationColumns) + /// Held here rather than read back through the sheet: SwiftUI does not re-evaluate `.popover` + /// content when the presenting view re-renders, so a pick that only wrote the sheet's state + /// left this view drawing the mapping it opened with. + @State private var overrides: [String: String?] + + internal init( + tableName: String, + sourceColumns: [String], + destinationColumns: [String], + overrides: [String: String?], + onChange: @escaping ([String: String?]) -> Void, + dismiss: @escaping () -> Void + ) { + self.tableName = tableName + self.sourceColumns = sourceColumns + self.destinationColumns = destinationColumns + self.onChange = onChange + self.dismiss = dismiss + _overrides = State(initialValue: overrides) } private var resolved: TableColumnMatcher.Match { - overrides.isEmpty - ? automatic - : TableColumnMatcher.applying( - overrides: overrides, to: automatic, destination: destinationColumns) + TableColumnMatcher.match( + source: sourceColumns, destination: destinationColumns, overrides: overrides) } internal var body: some View { + let match = resolved + let contested = Set(match.contestedDestinations) VStack(alignment: .leading, spacing: 10) { Text(tableName) .font(.headline) @@ -42,36 +63,28 @@ internal struct TableTransferMappingEditor: View { ScrollView { VStack(alignment: .leading, spacing: 4) { ForEach(sourceColumns, id: \.self) { column in - HStack(spacing: 6) { - Text(column) - .lineLimit(1) - .truncationMode(.middle) - .frame(width: 130, alignment: .leading) - - Picker(String(format: String(localized: "Destination for %@"), column), - selection: binding(for: column)) { - Text("Skip").tag(String?.none) - ForEach(destinationColumns, id: \.self) { target in - Text(target).tag(String?.some(target)) - } - } - .labelsHidden() - .frame(width: 150) - } + row(for: column, isContested: match.mapping[column].map(contested.contains) ?? false) } } } .frame(height: 200) - if !resolved.unmatchedDestination.isEmpty { - Text(unmatchedDestinationLabel) + if !contested.isEmpty { + Text(Self.contestedMappingMessage) + .font(.caption) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + if !match.unmatchedDestination.isEmpty { + Text(unmatchedDestinationLabel(match.unmatchedDestination)) .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } HStack { - Button("Match by Name") { overrides = [:] } + Button("Match by Name") { update([:]) } Spacer() Button("Done", action: dismiss) .keyboardShortcut(.defaultAction) @@ -81,19 +94,55 @@ internal struct TableTransferMappingEditor: View { .frame(width: 340) } + private func row(for column: String, isContested: Bool) -> some View { + HStack(spacing: 6) { + Text(column) + .lineLimit(1) + .truncationMode(.middle) + .frame(width: 130, alignment: .leading) + + Picker(String(format: String(localized: "Destination for %@"), column), + selection: binding(for: column)) { + Text("Skip").tag(String?.none) + ForEach(destinationColumns, id: \.self) { target in + Text(target).tag(String?.some(target)) + } + } + .labelsHidden() + .frame(width: 150) + + if isContested { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .help(String(localized: "Another source column is mapped to the same destination column.")) + .accessibilityLabel( + Text("Another source column is mapped to the same destination column.")) + } + } + } + /// A destination column nothing writes to takes its own default or null, which only fails when /// it is `NOT NULL` without one, so it is stated rather than blocked. - private var unmatchedDestinationLabel: String { + private func unmatchedDestinationLabel(_ columns: [String]) -> String { String( format: String(localized: "Not written: %@. Each takes its default or null."), - resolved.unmatchedDestination.joined(separator: ", ") + columns.joined(separator: ", ") ) } private func binding(for column: String) -> Binding { Binding( get: { resolved.mapping[column] }, - set: { overrides[column] = .some($0) } + set: { target in + var updated = overrides + updated[column] = .some(target) + update(updated) + } ) } + + private func update(_ updated: [String: String?]) { + overrides = updated + onChange(updated) + } } diff --git a/TablePro/Views/Export/TableTransferSheet.swift b/TablePro/Views/Export/TableTransferSheet.swift index 2c488a013..652f17b72 100644 --- a/TablePro/Views/Export/TableTransferSheet.swift +++ b/TablePro/Views/Export/TableTransferSheet.swift @@ -55,7 +55,13 @@ struct TableTransferSheet: View { } private var canTransfer: Bool { - !isRunning && !selectedTables.isEmpty && destinationConnection != nil + !isRunning && !selectedTables.isEmpty && destinationConnection != nil && !hasContestedMapping + } + + /// Two source columns mapped to one destination column build an INSERT naming it twice, which + /// the server refuses only after "Delete existing rows first" has emptied the table. + private var hasContestedMapping: Bool { + selectedTables.contains { !resolvedMatch(for: $0.name).contestedDestinations.isEmpty } } var body: some View { @@ -177,6 +183,11 @@ struct TableTransferSheet: View { .foregroundStyle(.secondary) .lineLimit(1) .truncationMode(.middle) + } else if hasContestedMapping { + Text(TableTransferMappingEditor.contestedMappingMessage) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) } } actions: { Button(isRunning ? String(localized: "Stop") : String(localized: "Cancel")) { @@ -213,6 +224,7 @@ struct TableTransferSheet: View { @ViewBuilder private func mappingSummary(for table: String) -> some View { let match = resolvedMatch(for: table) + let cannotTransfer = match.isEmpty || !match.contestedDestinations.isEmpty if isMatching, destinationColumns[table] == nil { ProgressView() .scaleEffect(0.5) @@ -228,7 +240,7 @@ struct TableTransferSheet: View { HStack(spacing: 3) { Text(mappingLabel(match)) .font(.caption) - .foregroundStyle(match.isEmpty ? Color.red : .secondary) + .foregroundStyle(cannotTransfer ? Color.red : .secondary) Image(systemName: "arrow.left.arrow.right") .font(.caption) } @@ -242,10 +254,8 @@ struct TableTransferSheet: View { tableName: table, sourceColumns: sourceColumns[table] ?? [], destinationColumns: destinationColumns[table] ?? [], - overrides: Binding( - get: { overrides[table] ?? [:] }, - set: { overrides[table] = $0 } - ), + overrides: overrides[table] ?? [:], + onChange: { overrides[table] = $0 }, dismiss: { inspectedTable = nil } ) } @@ -254,6 +264,12 @@ struct TableTransferSheet: View { private func mappingLabel(_ match: TableColumnMatcher.Match) -> String { guard !match.isEmpty else { return String(localized: "No columns match") } + let contested = match.contestedDestinations + guard contested.isEmpty else { + return String( + format: String(localized: "%@ mapped more than once"), + contested.joined(separator: ", ")) + } guard match.unmatchedSource.isEmpty else { return String( format: String(localized: "%1$lld mapped, %2$lld skipped"), @@ -264,12 +280,10 @@ struct TableTransferSheet: View { } private func resolvedMatch(for table: String) -> TableColumnMatcher.Match { - let destination = destinationColumns[table] ?? [] - let automatic = TableColumnMatcher.match( - source: sourceColumns[table] ?? [], destination: destination) - guard let tableOverrides = overrides[table], !tableOverrides.isEmpty else { return automatic } - return TableColumnMatcher.applying( - overrides: tableOverrides, to: automatic, destination: destination) + TableColumnMatcher.match( + source: sourceColumns[table] ?? [], + destination: destinationColumns[table] ?? [], + overrides: overrides[table] ?? [:]) } private func binding(for table: ExportObjectItem) -> Binding { diff --git a/TableProTests/Core/Export/TableColumnMatcherTests.swift b/TableProTests/Core/Export/TableColumnMatcherTests.swift new file mode 100644 index 000000000..d89a0e092 --- /dev/null +++ b/TableProTests/Core/Export/TableColumnMatcherTests.swift @@ -0,0 +1,134 @@ +// +// TableColumnMatcherTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +struct TableColumnMatcherTests { + @Test("Columns are matched by name, and the rest are reported on both sides") + func matchesByName() { + let match = TableColumnMatcher.match( + source: ["id", "name", "legacy"], destination: ["id", "name", "created_at"]) + + #expect(match.mapping == ["id": "id", "name": "name"]) + #expect(match.unmatchedSource == ["legacy"]) + #expect(match.unmatchedDestination == ["created_at"]) + #expect(match.contestedDestinations.isEmpty) + } + + @Test("A column spelled with another case still matches") + func matchesIgnoringCase() { + let match = TableColumnMatcher.match(source: ["ID", "Email"], destination: ["id", "email"]) + + #expect(match.mapping == ["ID": "id", "Email": "email"]) + #expect(match.unmatchedSource.isEmpty) + } + + /// Both used to land on `name`, and the INSERT named it twice. + @Test("The exact spelling claims a destination column before a twin that differs only by case") + func exactSpellingWinsTheColumn() { + let match = TableColumnMatcher.match(source: ["Name", "name"], destination: ["id", "name"]) + + #expect(match.mapping == ["name": "name"]) + #expect(match.unmatchedSource == ["Name"]) + #expect(match.contestedDestinations.isEmpty) + } + + @Test("Of two twins that both differ by case from the destination, only the first is matched") + func firstTwinWinsTheColumn() { + let match = TableColumnMatcher.match(source: ["NAME", "Name"], destination: ["name"]) + + #expect(match.mapping == ["NAME": "name"]) + #expect(match.unmatchedSource == ["Name"]) + } + + @Test("Twins on both sides each reach their own column") + func twinsReachTheirOwnColumns() { + let match = TableColumnMatcher.match(source: ["Name", "name"], destination: ["name", "Name"]) + + #expect(match.mapping == ["Name": "Name", "name": "name"]) + #expect(match.unmatchedDestination.isEmpty) + } + + @Test("A destination twin left over is matched ignoring case once the exact spelling is taken") + func leftoverTwinMatchesIgnoringCase() { + let match = TableColumnMatcher.match(source: ["NAME", "Name"], destination: ["Name", "name"]) + + #expect(match.mapping == ["Name": "Name", "NAME": "name"]) + } + + @Test("An override onto a column another source column holds is kept and reported as contested") + func overrideOntoAHeldColumnIsContested() { + let match = TableColumnMatcher.match( + source: ["first_name", "last_name"], + destination: ["first_name", "last_name"], + overrides: ["last_name": "first_name"] + ) + + #expect(match.mapping == ["first_name": "first_name", "last_name": "first_name"]) + #expect(match.contestedDestinations == ["first_name"]) + #expect(match.unmatchedDestination == ["last_name"]) + } + + @Test("Skipping one of the two source columns clears the contest") + func skippingOneClearsTheContest() { + let match = TableColumnMatcher.match( + source: ["first_name", "last_name"], + destination: ["first_name", "last_name"], + overrides: ["last_name": "first_name", "first_name": nil] + ) + + #expect(match.mapping == ["last_name": "first_name"]) + #expect(match.contestedDestinations.isEmpty) + #expect(match.unmatchedSource == ["first_name"]) + } + + @Test("Repointing a case twin onto the column its twin holds is contested") + func repointedTwinIsContested() { + let match = TableColumnMatcher.match( + source: ["Name", "name"], + destination: ["name"], + overrides: ["Name": "name"] + ) + + #expect(match.contestedDestinations == ["name"]) + } + + /// The leftovers are out of name order on both sides, so a path that sorted them, as the + /// override path once did, would not compare equal. + @Test("No overrides gives the automatic match") + func noOverridesIsTheAutomaticMatch() { + let source = ["zeta", "alpha", "id"] + let destination = ["id", "omega", "beta"] + + let automatic = TableColumnMatcher.match(source: source, destination: destination) + + #expect(automatic.unmatchedSource == ["zeta", "alpha"]) + #expect(automatic.unmatchedDestination == ["omega", "beta"]) + #expect(TableColumnMatcher.match(source: source, destination: destination, overrides: [:]) == automatic) + } + + @Test("An override leaves the unmatched source columns in the source table's order") + func overrideKeepsSourceOrder() { + let match = TableColumnMatcher.match( + source: ["zeta", "alpha", "id", "name"], + destination: ["id", "name"], + overrides: ["name": nil] + ) + + #expect(match.mapping == ["id": "id"]) + #expect(match.unmatchedSource == ["zeta", "alpha", "name"]) + } + + @Test("Only destination columns named more than once are contested, in name order") + func contestedDestinationsAreSorted() { + let contested = TableColumnMatcher.contestedDestinations( + in: ["a": "z", "b": "z", "c": "y", "d": "x", "e": "x", "f": "x"]) + + #expect(contested == ["x", "z"]) + } +} diff --git a/TableProTests/Core/Export/TableTransferServiceTests.swift b/TableProTests/Core/Export/TableTransferServiceTests.swift index 7372ddffc..15bd69389 100644 --- a/TableProTests/Core/Export/TableTransferServiceTests.swift +++ b/TableProTests/Core/Export/TableTransferServiceTests.swift @@ -9,7 +9,83 @@ import Testing @testable import TablePro +/// Serves one table's rows as a source, and records what reaches it as a destination. +private final class TransferStubDriver: PluginDatabaseDriver, @unchecked Sendable { + let header: [String] + let rows: [PluginRow] + let columns: [String] + private(set) var executedQueries: [String] = [] + private(set) var executedParameters: [[PluginCellValue]] = [] + + init(header: [String] = [], rows: [PluginRow] = [], columns: [String] = []) { + self.header = header + self.rows = rows + self.columns = columns + } + + var insertStatements: [String] { + executedQueries.filter { $0.uppercased().hasPrefix("INSERT") } + } + + func defaultExportQuery(table: String, schema: String?) -> String? { + "SELECT * FROM \(table)" + } + + func streamRows(query: String) -> AsyncThrowingStream { + let header = PluginStreamHeader( + columns: header, columnTypeNames: header.map { _ in "TEXT" }) + let rows = rows + return AsyncThrowingStream { continuation in + continuation.yield(.header(header)) + continuation.yield(.rows(rows)) + continuation.finish() + } + } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + executedQueries.append(query) + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + executedQueries.append(query) + executedParameters.append(parameters) + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { + columns.map { PluginColumnInfo(name: $0, dataType: "TEXT") } + } + + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + + func fetchDatabases() async throws -> [String] { [] } + + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + struct TableTransferServiceTests { + private func adapter(_ driver: TransferStubDriver, type: DatabaseType) -> PluginDriverAdapter { + PluginDriverAdapter(connection: DatabaseConnection(name: "Test", type: type), pluginDriver: driver) + } + + private func occurrences(of needle: String, in text: String) -> Int { + text.components(separatedBy: needle).count - 1 + } @Test("A row is keyed by its header's column names, in order") func rowIsKeyedByHeader() { @@ -100,4 +176,103 @@ struct TableTransferServiceTests { #expect(request.wrapInTransaction) #expect(!request.deleteExistingRows) } + + @Test("A mapping that sends two source columns to one destination column is refused") + func contestedMappingIsRefused() { + let objects = [ExportObjectItem(name: "people", kind: .table)] + #expect(throws: TableTransferError.self) { + try TableTransferService.refuseContestedMappings( + ["people": ["first_name": "name", "last_name": "name"]], for: objects) + } + #expect(throws: Never.self) { + try TableTransferService.refuseContestedMappings( + ["people": ["first_name": "first_name", "last_name": "last_name"]], for: objects) + } + } + + /// The server refused the INSERT only after "Delete existing rows first" had run, and with no + /// transaction around the table the deletion stood: the destination was left empty. + @MainActor @Test("A contested mapping deletes nothing on the destination") + func contestedMappingDeletesNothing() async { + let source = TransferStubDriver(header: ["first_name", "last_name"], rows: [[.text("Ada"), .text("Lovelace")]]) + let destination = TransferStubDriver(columns: ["name"]) + let request = TableTransferService.Request( + objects: [ExportObjectItem(name: "people", kind: .table, isSelected: true)], + sourceType: .postgresql, + destinationType: .mysql, + columnMapping: ["people": ["first_name": "name", "last_name": "name"]], + deleteExistingRows: true, + wrapInTransaction: false + ) + + do { + try await TableTransferService().transfer( + request: request, + sourceDriver: adapter(source, type: .postgresql), + destinationDriver: adapter(destination, type: .mysql) + ) + Issue.record("A mapping naming one destination column twice was transferred") + } catch TableTransferError.contestedDestination(let table, let columns) { + #expect(table == "people") + #expect(columns == ["name"]) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(destination.executedQueries.isEmpty) + } + + /// `Name` and `name` both matched a lone `name`, and even with only one of them mapped the + /// sink folded the other onto it, so the INSERT named the column twice. + @MainActor @Test("A source column that differs only by case from a mapped one is not written twice") + func caseTwinIsNotWrittenTwice() async throws { + let source = TransferStubDriver( + header: ["id", "Name", "name"], rows: [[.text("1"), .text("Display"), .text("login")]]) + let destination = TransferStubDriver(columns: ["id", "name"]) + let request = TableTransferService.Request( + objects: [ExportObjectItem(name: "people", kind: .table, isSelected: true)], + sourceType: .postgresql, + destinationType: .mysql, + sourceColumns: ["people": ["id", "Name", "name"]], + deleteExistingRows: true, + wrapInTransaction: false + ) + + let service = TableTransferService() + try await service.transfer( + request: request, + sourceDriver: adapter(source, type: .postgresql), + destinationDriver: adapter(destination, type: .mysql) + ) + + let insert = try #require(destination.insertStatements.first) + #expect(destination.insertStatements.count == 1) + #expect(occurrences(of: "`name`", in: insert) == 1) + #expect(destination.executedParameters.last?.contains(.text("login")) == true) + #expect(destination.executedParameters.last?.contains(.text("Display")) == false) + #expect(service.state.transferredRows == 1) + #expect(service.state.warnings.contains { $0.contains("Name") }) + } + + @MainActor @Test("Twins mapped by the user to their own columns each reach their own column") + func mappedTwinsReachTheirOwnColumns() async throws { + let source = TransferStubDriver( + header: ["Name", "name"], rows: [[.text("Display"), .text("login")]]) + let destination = TransferStubDriver(columns: ["display_name", "login"]) + let request = TableTransferService.Request( + objects: [ExportObjectItem(name: "people", kind: .table, isSelected: true)], + sourceType: .postgresql, + destinationType: .mysql, + columnMapping: ["people": ["Name": "display_name", "name": "login"]] + ) + + try await TableTransferService().transfer( + request: request, + sourceDriver: adapter(source, type: .postgresql), + destinationDriver: adapter(destination, type: .mysql) + ) + + let insert = try #require(destination.insertStatements.first) + #expect(occurrences(of: "`display_name`", in: insert) == 1) + #expect(occurrences(of: "`login`", in: insert) == 1) + } } diff --git a/TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift b/TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift index aa2d8251d..1a120f8a3 100644 --- a/TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift +++ b/TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift @@ -105,6 +105,15 @@ struct ImportDataSinkAdapterMappingTests { #expect(values == [.text("x")]) } + @Test("An unknown spelling beside a skipped field of the same name reaches no column") + func unknownSpellingBesideASkippedTwinDoesNotFold() { + let sink = adapter(mapping: ["Name": "name"], sourceFields: ["Name", "NAME"]) + + let (columns, _) = sink.mappedColumnsAndValues(["NAME": .text("skipped"), "name": .text("unknown")]) + + #expect(columns.isEmpty) + } + @Test("A third spelling of two mapped fields that differ only by case reaches neither column") func ambiguousMappingKeysDoNotFold() { let sink = adapter(mapping: ["Email": "work_email", "email": "home_email"]) diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx index fd24421f6..497c4000a 100644 --- a/docs/features/import-export.mdx +++ b/docs/features/import-export.mdx @@ -247,12 +247,18 @@ Right-click tables in the sidebar and choose **Transfer To…** to copy their ro -Rows only. The destination table has to exist and its column names have to match, because inventing DDL that crosses from one engine to another would create tables whose types quietly disagree with the data landing in them. A per-table row filter set in the export tree is not carried over; narrow the transfer by transferring fewer tables. +Rows only, into a destination table that already exists. A per-table row filter set in the export tree is not carried over; narrow the transfer by transferring fewer tables. **Delete existing rows first** empties each destination table before writing. There is no undo. +### Column mapping + +Each ticked table shows how many of its columns map. Columns match by name, the same spelling first and then the same name in another case. Click the count to point a column at a different destination column, or pick **Skip** to leave it out. **Match by Name** puts every column back. + +A destination column takes one source column. Map a second column onto it and **Transfer** stays off until one of the two moves or is skipped. + ## Clipboard paste (CSV/TSV) Select a row in the data grid and press `Cmd+V` to paste tabular data straight in. Tabs parse as TSV, commas as CSV.