Skip to content

feat(compare): compare and sync structure or data between two connections (#721) - #1968

Open
datlechin wants to merge 3 commits into
mainfrom
feat/721-compare-sync
Open

feat(compare): compare and sync structure or data between two connections (#721)#1968
datlechin wants to merge 3 commits into
mainfrom
feat/721-compare-sync

Conversation

@datlechin

@datlechin datlechin commented Jul 27, 2026

Copy link
Copy Markdown
Member

Closes #721.

Adds Compare & Sync: pick a source and a target connection, compare structure or data, and generate the SQL script that brings the target in line with the source. Only three Mac clients ship this at all (Navicat, Querious for MySQL only, Valentina Pro), and it is the most-cited reason people keep a Navicat licence alongside their preferred Mac client. Sequel Pro's request has been open since 2008; its maintainer said the blocker was the UI, not the SQL generation.

Requires a Starter licence (ProFeature.compareSync).

Where it lives

A separate auxiliary window rather than a new tab kind. The HIG calls this shape an auxiliary window and rules sheets out for "complex or prolonged user flows". The codebase agrees: MainContentCoordinator.connection is a stored let, EditorTabPayload carries one connectionId, and TabPersistenceCoordinator writes one file per connection, so a second connection cannot be threaded through the tab model without touching every one of those. DatabaseManager.activeSessions is already keyed by connection, so the data layer needed no changes at all.

The payoff: MainEditorContentView.tabContent, WindowTitleResolver, and resolveDetailMinimumThickness gain zero cases.

Opened from File > Compare & Sync Databases... or a connection's context menu on the welcome screen.

Structure compare

Compares parsed metadata, never DDL text. Every text-diffing tool in this space has shipped either a permanent-diff loop or a false-positive storm; Redgate once reported 1004 objects as different over a trailing semicolon, and MySQL Workbench closed its version as Not a Bug.

  • Four states: only in source, only in target, different, identical. Tables that cannot be compared get their own group with the reason rather than being silently skipped.
  • Indexes and foreign keys match on structure, not name, so a system-generated name difference emits no DDL.
  • Six ignore rules, all defaulting to ignore: identifier case, column order, whitespace, auto-increment seed, collation/charset, comments and owners.
  • Renames are never guessed; a rename shows as drop plus add, as Alembic and Liquibase both concede is the only honest answer.
  • Script generation requires matching database types, with MySQL/MariaDB as one family. Cross-engine comparison still works as a read-only view. Column data types are engine-specific strings, so generating DDL for one engine from another's metadata is not sound, and every serious tool refuses it.

SchemaStatementGenerator is reused completely unchanged. The new SchemaSyncScriptBuilder adds only the table-level create/drop it deliberately does not model, plus cross-table ordering.

Data compare

Key-ordered streaming merge join. Neither side is materialised, so there is no equivalent of Redgate's documented 2-4x disk budget, and no server-side hash function has to agree between two engines.

  • Key columns default to the primary key and are editable per table. Composite keys supported. A table with no key is listed as not comparable rather than matched on a guess. DBeaver removed composite key support because it never worked; this supports it from day one.
  • Compare-set and write-set are separate and editable: exclude updated_at from matching and it is still written on insert or update. Editing keys or excluded columns clears that table's result and offers Compare Again.
  • NULL equals only NULL. Optional float tolerance, symmetric by construction (Redgate shipped a direction-asymmetric decimal bug). Timestamps compare as instants at a chosen precision with no client-timezone conversion, so the same instant at two offsets is not a difference.
  • When a value is flagged, the row inspector names the rule that fired.
  • Navicat's six-way row filter (All / Difference / Insert / Update / Delete / Same).

Safety

Reuses the app's existing machinery rather than inventing a parallel path.

  • A connection at SafeModeLevel.readOnly is structurally ineligible as the target, disabled in the picker with the reason, not warned about at apply time.
  • One ExecutionGate.authorizing call per run, following the dispatchStatements precedent. .confirmationPreCleared suppresses the generic dialog because the compare window already showed a typed one, while biometric auth for safeMode targets still fires. The statement loop stays inside that call so the task-local receipt holds for every statement.
  • Refuse-unsafe-by-default (Skeema's model) with typed per-statement hazards (pg-schema-diff's model). Drop table, drop column, narrowing type change, NOT NULL, and primary key changes are listed but held back until allowed for that run. There is no setting that allows them permanently.
  • Nothing checked by default, no preselected target, delete off, transaction on. Navicat 17 itself moved to opt-in.
  • Direction uses Redgate's wording ("the source will not change; the target will change") with a live plain-language sentence and a swap control. Querious, the only other native-Mac precedent, inverts this and uses three words for two endpoints.
  • A persistent "Comparing only. Nothing has been written." banner, answering the DBeaver bug where a user unticked write options expecting read-only and got an empty diff.
  • Closing the window or quitting mid-apply asks first and says that statements which already ran stay applied.
  • Statements are FK-dependency ordered: children dropped first, parents created first. No tool in this space does this today.
  • Transactions are engine-declared via supportsTransactionalDDL, not assumed. On MySQL and Oracle the window says so rather than implying a rollback you will not get. Continue-on-error disables the transaction option, mirroring RowImportRunner's existing rule.

PluginKit: additive, no version bump

Two new PluginCapabilities bits and one extracted ForeignKeyTopologicalSort (shared with SQLExportPlugin instead of duplicated). scripts/check-pluginkit-abi.sh confirms the diff is additions only, with no symbol removed or changed, so no currentPluginKitVersion bump and no release-all-plugins.sh run. Please add the abi-additive label.

Default capabilities: [] means no existing plugin becomes eligible until it opts in.

Structure differences also show source and target definitions side by side, split or unified, rendered from parsed metadata through one function so a formatting difference cannot appear as a difference. Comparison setups can be saved by name against a source, target, and mode.

Tests

89 new tests, all passing, all against fake drivers so none are gated behind a C bridge. They pin the failure modes the field actually shipped: auto-increment drift is not a difference, a name-only index difference emits no DDL, primary key membership is reported once rather than twice, float tolerance is symmetric, equivalent instants at different offsets compare equal, comparing without a key throws rather than guessing, and a refused statement never reaches the driver.

Reviewer notes, and what is not verified

  • The local full-target test run is unstable and I could not attribute it. Baseline gave 8816 passed / 88 failed; with these changes 3039 / 5945; with all 11 compare suites skipped still 3879 failures, so it is not the new suites; a second baseline run timed out without finishing. Failures carry 0.000s durations and no assertion text, meaning those tests never ran. The 11 compare suites pass in isolation repeatedly, and sampled unrelated suites pass in isolation both with and without these changes. I could not clear the source changes either. Please let CI be the judge here.
  • SQLExportPlugin.topologicallySort now delegates to the extracted function and has no test coverage, before or after. Worth exercising a real SQL export of a database with foreign keys. One behaviour did change deliberately: the original used Dictionary(uniqueKeysWithValues:), which crashes on two tables sharing a name across schemas; it now keeps the first.
  • Driver capability is an instance property, so eligibility is a runtime refusal after connecting rather than a picker filter. The docs page says so.
  • 5 UI tests cover opening from the File menu, the banner text, Compare staying disabled until both endpoints are chosen, neither picker being preselected, and Swap being disabled. They skip cleanly when the licence gate blocks the menu item. Flows needing two live fixture connections (an actual run, the close gate firing) are still not automated.

@datlechin datlechin added the abi-additive PluginKit ABI diff reviewed as additive; no version bump needed label Jul 27, 2026
@mintlify

mintlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Jul 27, 2026, 9:23 AM

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 20bfb6e978

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +63 to +64
let required: PluginCapabilities = mode == .structure ? .schemaCompare : .dataCompare
guard !driver.capabilities.contains(required) else { return nil }

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Opt supported drivers into compare capabilities

This eligibility check rejects every real database driver because the commit defines the two capability bits but never adds either bit to any PluginDatabaseDriver.capabilities implementation. I checked all driver capability declarations under Plugins; consequently both structure and data comparisons always stop with the unsupported message before doing any work.

Useful? React with 👍 / 👎.

Comment on lines +110 to +112
let result = try await self.executorRun(
statements: self.statements,
target: target,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply the script text the user edited

When the user changes the editable script preview, the binding only updates editedScript, but Apply still passes the original self.statements here. Thus edits—including removing or correcting a destructive statement—are ignored while the confirmation implies the displayed script will run; either rebuild executable statements from the edited text or make the preview read-only.

Useful? React with 👍 / 👎.

Comment on lines +213 to +217
guard entries.count < limit else {
truncated = true
return
}
entries.append(entry)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Do not discard rows needed for synchronization

For tables exceeding maxRetainedEntries (5,000 by default), this cap discards every later entry even though generateDataScript() builds SQL exclusively from summary.entries. A table whose first 5,000 rows match but whose 5,001st row differs reports the exact difference count yet generates no statement; other large tables are only partially synchronized. Retention used for display must not truncate the data used to build the sync script.

Useful? React with 👍 / 👎.

Comment on lines +239 to +240
if let leftNumber = Double(leftKey), let rightNumber = Double(rightKey) {
return leftNumber < rightNumber ? .orderedAscending : .orderedDescending

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Compare numeric keys without Double precision loss

When key strings are large exact integers, converting them to Double collapses distinct values—for example, 9007199254740992 and 9007199254740993. The merge join can then choose the wrong stream to advance, misclassify a row present on both sides as an insert, and generate duplicate-key or otherwise incorrect synchronization SQL. Numeric ordering needs an exact decimal/integer comparison consistent with the database ordering.

Useful? React with 👍 / 👎.

Comment on lines +184 to +185
if shouldRollback {
try? await driver.rollbackTransaction()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Propagate rollback failures before reporting success

If rollbackTransaction() fails after cancellation or a statement error, try? silently discards that failure and the returned result still sets rolledBack to true. The apply view then explicitly tells the user that the target is unchanged even though the transaction may remain open or partially applied; propagate the rollback error or record rollback failure separately instead of claiming success.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@mintlify

mintlify Bot commented Jul 27, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated (UTC)
TablePro 🟡 Building Jul 27, 2026, 9:22 AM

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

abi-additive PluginKit ABI diff reviewed as additive; no version bump needed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Data sync between two connections

1 participant