Skip to content

fix(plugins): refuse a table transfer that maps two source columns to one destination column - #3187

Merged
datlechin merged 6 commits into
feat/import-remembered-column-mappingfrom
fix/table-transfer-duplicate-column-target
Sep 30, 2026
Merged

datlechin merged 6 commits into
feat/import-remembered-column-mappingfrom
fix/table-transfer-duplicate-column-target

Conversation

@datlechin

@datlechin datlechin commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Stacked on #3183 (feat/import-remembered-column-mapping). Review and merge #3183 first; this PR's diff is only the Table Transfer work on top of it.

Root cause

Table Transfer could send two source columns into one destination column, and nothing stopped it before a row was written.

  • TableColumnMatcher.applying(overrides:to:destination:) wrote mapping[source] = target without asking whether another source column already held target. The mapping editor offered every destination column in every row, and TableTransferSheet.canTransfer checked only running, selection and destination. TableTransferService passed the mapping through untouched.
  • TableColumnMatcher.match did the same on its own. It looked destinations up by lowercased name with the last spelling winning and no claim, so source columns Name and name both matched a lone name.
  • The import sink folded every mapping key and row field to lowercase, so even a correct mapping broke once twins were involved. feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183 fixes the sink; this PR relies on that fix and passes it what Table Transfer knows.

Every path ends the same way: the sink builds INSERT INTO t (name, name), the server refuses the first batch, and with Delete existing rows first on and Wrap each table in a transaction off, the DELETE has already committed. The destination table is left empty.

Fix

  • Matcher: the automatic match pairs exact spellings first, then case-insensitive matches among the destination columns still free, earlier source columns first. A destination column is claimed once, the same rule feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183's ImportColumnMatcher uses for the row import sheet. Match.contestedDestinations names any destination column more than one source column is mapped to. An override onto a held column is kept and reported, not resolved by quietly unmapping the other column. match(source:destination:overrides:) is the one entry point the sheet and the editor share. It replaces applying(overrides:to:destination:), and both paths build their result through one resolver, so the unmatched columns stay in each table's own column order. The override path used to return them sorted by name.
  • Sheet and editor: Transfer is disabled while any ticked table has a contested column. The table's summary reads "name mapped more than once" in red, the footer says "Each destination column can be mapped from only one source column.", and the mapping editor marks each conflicting row and repeats the message.
  • Editor state: TableTransferMappingEditor holds its overrides in @State seeded in init and reports each edit through onChange, per the .popover invariant in CLAUDE.md (Select fields not working when trying to add highlighting rule #3015). Through the old custom Binding a pick changed only the sheet's state, which the popover does not redraw from, so the conflict message and the existing "Not written" line would have stayed stale.
  • Service: transfer() refuses a contested mapping for every table before the first table is touched, so no DELETE runs. The sheet already blocks this; the check covers any other caller. It also passes the source table's columns to the sink as sourceFields, the input feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183 added, so a source column the mapping skips is never folded onto its case twin's destination column.
  • Docs: the Transfer section of docs/features/import-export.mdx gains a "Column mapping" subsection. The Delete existing rows first warning stays above it.

Dropped as duplicate of #3183

This branch was built off main in parallel with #3183 and carried its own copy of the same sink change. It now merges #3183 in and keeps none of it:

Tests

Built and tested in a worktree through verify.sh, after the merge:

  • build: PASS
  • test TableColumnMatcherTests TableTransferServiceTests ImportDataSinkAdapterMappingTests SQLServerImportBatchTests NativeDumpBatchTests RowImportMappingTests ImportColumnMatcherTests: PASS, 89 executed, 89 passed, 0 failed (12, 12, 13, 9, 6, 14, 23)
  • lint on the seven Swift files this PR changes over feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183: 0 violations. The step reports FAIL only for a stale /Applications/Xcode-beta.app path in .claude/skills/fix-issue/references/verification.md, which this branch does not touch.
  • verify.sh docs (house style, source claims, links): PASS. python3 scripts/localization.py verify: ok.

Cases this PR adds:

  • TableColumnMatcherTests (new, 12): exact spelling wins over a case twin, first twin wins when neither is exact, twins on both sides reach their own columns, an override onto a held column is contested, skipping one side clears it. "No overrides gives the automatic match" uses leftovers out of name order on both sides, and "An override leaves the unmatched source columns in the source table's order" checks the override path.
  • TableTransferServiceTests (+4): a contested mapping is refused, and with delete-first on and no transaction the destination sees no statement at all; a source with Name and name into a lone name writes name once, with the exact twin's value; twins mapped to their own columns each reach their own column. Both end-to-end cases run through the real ExportDataSourceAdapter, ImportDataSinkAdapter (feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183's) and PluginDriverAdapter over a stub plugin driver.
  • ImportDataSinkAdapterMappingTests (+1 over feat(plugins): remember row import column mappings per table and add Match by Name and Match by Position #3183): an unknown spelling beside a skipped field of the same name reaches no column.

Review

Codex reviewed the merged branch against feat/import-remembered-column-mapping and reported no findings. Earlier rounds against main are unchanged in substance: one independent review found the sink fallback writing a skipped field into its twin's column (now fixed by #3183's sink plus sourceFields from Table Transfer), a matcher test that could not fail (fixed with out-of-order leftovers), and the delete warning under the wrong heading (moved).

Not verified

  • No UI test. The transfer flow needs two live sessions open at once plus the sidebar's Transfer To... menu, and no existing UI fixture opens two connections. Covered at the matcher, service and sink level.
  • The editor's @State shape follows the measured .popover invariant; it was not measured on this popover.
  • That every driver labels result columns with the same spelling fetchColumns returns. If a driver differs by case, Table Transfer still reaches the column through the sink's fallback, because the header spelling is not in sourceFields.

Found while investigating #3172 (#3183)

@mintlify

mintlify Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 29, 2026, 1:41 PM

💡 Tip: Enable Automations to automatically generate PRs for you.

…pping' into fix/table-transfer-duplicate-column-target

# Conflicts:
#	CHANGELOG.md
#	TablePro/Core/Plugins/ImportDataSinkAdapter.swift
#	TablePro/Views/Import/RowImportSheet.swift
#	TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift
@datlechin
datlechin changed the base branch from main to feat/import-remembered-column-mapping September 29, 2026 16:54
@datlechin
datlechin added this pull request to stack #3209 September 29, 2026 20:15
…pping' into fix/table-transfer-duplicate-column-target

# Conflicts:
#	TableProTests/Core/Plugins/ImportDataSinkAdapterMappingTests.swift
…pping' into fix/table-transfer-duplicate-column-target

# Conflicts:
#	CHANGELOG.md
@datlechin
datlechin removed this pull request from stack #3209 September 30, 2026 10:12
@datlechin
datlechin added this pull request to stack #3221 September 30, 2026 10:14
@datlechin
datlechin removed this pull request from stack #3221 September 30, 2026 10:15
@datlechin
datlechin merged commit 3ada102 into feat/import-remembered-column-mapping Sep 30, 2026
1 check passed
@datlechin
datlechin deleted the fix/table-transfer-duplicate-column-target branch September 30, 2026 10:15

This branch was successfully deployed

1 active deployment
staging - docs — 40d25f12 Deployed Sep 30, 2026 by mintlify[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant