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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
94 changes: 63 additions & 31 deletions TablePro/Core/Services/Export/TableColumnMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
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<String>()
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
}
}
24 changes: 24 additions & 0 deletions TablePro/Core/Services/Export/TableTransferService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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
Expand All @@ -161,13 +168,30 @@ 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)
}
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
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -3507,6 +3507,9 @@
},
"%@ loses its default" : {

},
"%@ mapped more than once" : {

},
"%@ matches" : {

Expand Down Expand Up @@ -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." : {

Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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." : {

Expand Down
107 changes: 78 additions & 29 deletions TablePro/Views/Export/TableTransferMappingEditor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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<String?> {
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)
}
}
Loading