Skip to content

feat(compare): filter and limit rows per table, review them in a grid, and fix the data sync script - #2854

Merged
datlechin merged 3 commits into
mainfrom
feat/compare-data-scope-2537
Sep 14, 2026
Merged

datlechin merged 3 commits into
mainfrom
feat/compare-data-scope-2537

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2537.

Data mode existed in Compare & Sync, but it could not do the job the issue asks for, and the script it generated was wrong in ways that would have written to the wrong table. This gives each table its own comparison scope, shows the rows in a grid, and repairs the sync path underneath.

The feature

Per-table scope. Select a table in the Rows tab and set its key columns, its compared columns, a SQL filter and a row limit. Each table keeps its own; a saved comparison stores all four.

  • The filter is one condition, read on both sides, with Use the same filter for the target on by default and a separate target filter when it is off. A semicolon, a comment (--, #, /* */) or an unclosed quote or parenthesis is refused before either side is read, and the parentheses are checked under both readings of a backslash, because MySQL treats it as an escape inside a literal and PostgreSQL does not.
  • The row limit means the first N rows in key order. It is pushed into both queries (LIMIT, OFFSET/FETCH or TOP, per dialect) along with a IS NOT NULL on each key column, and the walk stops after N keys or as soon as a side that hit its limit runs out. A per-side LIMIT alone would have reported the other side's later keys as inserts and deletes.
  • Compared columns moved off the global options onto the table. Excluding updated_at in one table no longer excludes it everywhere.

Filter boundary. A row that matches the filter on one side and exists outside it on the other is looked up by key on the other side after both streams drain, and lists as Outside Filter. It is never written, so a filtered sync cannot overwrite a row that belongs to another tenant or region, and cannot emit an INSERT that collides with an existing key. Redgate ships the naive version of this and its forum carries the resulting Violation of PRIMARY KEY report.

Row review. The rows show in the data grid, read-only, with every column. A row only in the source is underlined, a row only in the target is struck through, and a changed row shows its source line over its target line with each differing value marked. Marks carry a symbol as well as a tint, and read to VoiceOver in comparison words rather than the grid's editing words. Include is a checkbox column; Space toggles the selection.

This needed three additions to the data grid, all opt-in: a checkbox column kind, a struck-through mark for a replaced value, and a compare vocabulary for the accessibility description. Everything else in the grid behaves as before.

The defects this repairs

Found while tracing the issue, each one in the path the feature extends:

  • The script never contained an UPDATE or DELETE. The builder read the global options, whose key column list is always empty, so every keyed statement came out nil.
  • Statements were qualified with the source schema while the rows were read from the target schema, so a staging to public sync wrote back into staging.
  • Values were typed by nothing: a VARCHAR '007' was written as 007, and WHERE code = 007 matches 7, 07 and 007 on MySQL. Literals are now typed by the target column.
  • A key that is not unique paired arbitrary rows; duplicates now stop that table's comparison and name the key.
  • SQL Server identity and PostgreSQL GENERATED ALWAYS columns were inserted without their override. IDENTITY_INSERT is opened and always closed again, even when the run stops, fails or is cancelled, because it is session state on a pooled connection.
  • Timestamp equality was applied to every text column, and a key differing only in case under a case-insensitive collation was never reported.
  • The script was built from a second read that was never checked against the reviewed one, and included tables that had never been compared. The script walk now carries the comparison's difference digest and refuses when the rows moved.
  • Apply published without a fence, and the endpoint pickers, mode, options and per-table edits stayed live during a run.
  • After an Apply, Generate Script and Apply stayed disabled until the setup changed, so a second sync in the same window was impossible.
  • Read-only status was captured when the target was picked, so a connection switched to Read-Only afterwards still offered Apply.
  • A rolled-back run reported the target unchanged even where MyISAM cannot roll back.
  • A cancelled repeat comparison blanked the previous results, and a table whose comparison failed could not be excluded.
  • An UPDATE or DELETE that changes more rows than the one it was built for is now a failed statement, through the same rule the grid's own save path uses.

Verification

  • verify.sh build: PASS.
  • verify.sh test: 493 cases across the compare, data grid and data write suites, 0 failed. New coverage: the merge join's limit and filter rules, duplicate and case-only keys, the difference digest, typed literals, identity handling, session-scope closing, per-table scope persistence with the legacy decode path, the session's gating and refusals, and the grid model.
  • swiftlint --strict on the changed paths: clean.
  • docs/scripts/check-writing-style.sh and check-docs-against-source.py: clean.
  • A security review of the diff found nothing exploitable; two weaknesses it did name in the filter validator (# comments, backslash escaping) are fixed above with tests.

No UI automation: the Compare & Sync window is license gated and the UI test sandbox carries no license, so XCUITest cannot open it. CompareSyncUITests documents that, and the window's contract is covered by unit tests instead. For the same reason there are no before and after screenshots: the window cannot be opened in a throwaway sandbox.

@mintlify

mintlify Bot commented Sep 14, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 14, 2026, 6:07 PM

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

@datlechin

Copy link
Copy Markdown
Member Author

Second commit answers a review pass over the first one. What it changes:

  • The review grid resolved lines by display position. A column value filter makes display positions and line indices diverge, which would have drawn one row's marks on another and excluded the wrong row from the sync. Rows now resolve through tableRowsIndex(forDisplayRow:).
  • Include Every Listed Row and Exclude Every Listed Row changed only the exclusions, which the grid's content revision did not carry, so the checkboxes kept their old ticks until the next click or scroll.
  • A filter draft is tied to the table it was typed for, so selecting another table cannot commit a half-typed condition onto it.
  • A row the user refused now survives an Apply. The run never wrote it, so the refusal outlives it, and an exclusion outside a capped preview is no longer dropped when the answer is carried over.
  • A comparison that read both sides to the end no longer reports itself as stopped at the row limit unless a pushed limit is what ended it.
  • A column whose type nothing reported still reconciles two spellings of one instant. A numeric tolerance stays out of it, because it is opt-in for the columns it names.

…ore reporting them, and surface a rolled-back run
@datlechin

Copy link
Copy Markdown
Member Author

Third round: 20 findings from a Codex review of the whole branch against main, plus five the test run turned up afterwards. All fixed in 8943604d2 and the two commits before it.

Four that could corrupt or misreport a sync

  1. Deferred one-sided rows were unbounded. A filtered comparison held every unmatched row in memory until the walk finished, so a table with millions of rows outside the other side's filter held all of them. The walk now defers the key and reads both sides back by key in batches of 200. A key the same side cannot hand back is no longer dropped in silence: it fails the comparison with "rows could not be read back by key. Compare again", because a lost difference reads as two databases that agree.
  2. A key the user picked that is not unique paired arbitrary rows. Duplicate keys are detected on both the streaming walk and the resolved batches, and the table fails rather than guessing which row matches which.
  3. A mutation was recorded before it was verified. A statement whose row count came back wider than expected was reported as a failure, and the run then said nothing had been written, though the rows were in the target. SyncStatementOutcome.didExecute now records that the statement reached the target regardless of what the verification said, and the rolled-back-writes warning reads that.
  4. A rolled-back run never reached the user. The result was computed and dropped; session.reportRunResult presents it now, including a commit that failed after the statements ran.

Also in this round

  • The non-transactional warning names only tables the run actually wrote, not every table the script mentioned. A MyISAM table the run never reached has nothing left in it to warn about.
  • Review-grid rows resolve through DisplayRowMapping rather than by display position, so a per-column value filter cannot repoint a checkbox at another row.
  • A row's exclusion survives a re-run of the comparison.
  • Session scopes (SET IDENTITY_INSERT) close even when the run stops or is cancelled, before the rollback.
  • A saved comparison keeps its pre-per-table excluded columns until its tables load, so saving any other comparison no longer rewrites them away.

Verification

  • Build: PASS.
  • Compare and data-sync suites: 275 executed, 275 passed.
  • Data grid suites: 208 executed, 208 passed.
  • SwiftLint strict over the 48 changed app-target files: clean.

@datlechin
datlechin merged commit 67df0c0 into main Sep 14, 2026
9 checks passed
@datlechin
datlechin deleted the feat/compare-data-scope-2537 branch September 14, 2026 23:55
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.

Compare and sync data, not only structure

1 participant