Skip to content

feat(export): full import and export, object scope through native dump tools - #2621

Merged
datlechin merged 8 commits into
mainfrom
feat/full-import-export
Sep 3, 2026
Merged

feat(export): full import and export, object scope through native dump tools#2621
datlechin merged 8 commits into
mainfrom
feat/full-import-export

Conversation

@datlechin

@datlechin datlechin commented Sep 3, 2026

Copy link
Copy Markdown
Member

Fixes #2618.

What blocked the issue

Not five independent features. One model:

  • PluginExportTable could only describe a table. There was no kind.
  • ExportFormatPlugin.perTableOptionColumns was static, one option set for the whole format, and optionValues was a positional [Bool]. A routine has no "Data" column, so the shape could not carry object kinds.
  • ExportPreselection only spoke tables and containers.

Every later phase was blocked on that, so this is a refactor rather than five additions.

Phase 1: object scope

PluginExportObjectKind is now carried on PluginExportTable through a new initializer overload; both published initializers keep their exact signatures and are marked @_disfavoredOverload, because adding a parameter to one replaces its mangled symbol and breaks every shipped plugin (0.49.0 shipped that with columnMeta:).

A format declares supportedObjectKinds; one that declares none receives tables and views, which is what every format written before this expects. supportsOption(columnId:for:) blanks a column a kind does not support in place, so optionValues stays positionally aligned for every kind rather than shifting.

Two additions to PluginExportDataSource, both defaulted: fetchObjectDDL(_:) and fetchGrantStatements(principal:host:). Most of the driver work already existed (fetchRoutines, fetchAllTriggers, fetchUserDefinedTypes, fetchViewDefinition, PluginPrincipalManagement.fetchGrants), so the adapter mostly plumbs.

Two things worth calling out:

  • Views now go through fetchViewDefinition, not fetchTableDDL. Verified both return a full runnable CREATE VIEW on MySQL (SHOW CREATE VIEW) and PostgreSQL (pg_views). PostgreSQL's excludes materialized views, so .materializedView falls back to fetchTableDDL.
  • The export tree moved from SwiftUI to NSOutlineView. Three levels driven by programmatic isExpanded DisclosureGroups is the exact pattern that crashed the connection sidebar (Apple bug, Developer Forums 681275). The one-level tree got away with it; a three-level one would not. It also makes a database with thousands of objects lazy.

DROP for a trigger or routine is asked of the driver (generateDropTriggerSQL, generateDropRoutineSQL), because MySQL has no DROP ROUTINE and its DROP TRIGGER takes no ON clause.

Phase 2: data scope and SQL dialect

PluginExportRowScope carries a WHERE, a row limit and a column subset per object, edited from a popover on the row. The filter is the user's own SQL against their own connection, so it is not sanitized in the injection sense; what it must not do is smuggle a second statement into the SELECT the export builds, so a trailing ; is dropped and a ; anywhere else refuses the filter and the summary says the table went out whole.

SQLExportOptions gains:

  • Insert mode (skip, replace, update existing). Rendered by SQLExportInsertRenderer, which is pure and fully covered: MySQL puts it in the verb, SQLite in a resolution clause, PostgreSQL in a trailing ON CONFLICT that has to name a target. An engine with no spelling writes plain inserts and warns rather than shipping a dump that fails on restore.
  • Split by size. SQLExportFileWriter rotates between writes, never inside one, so a part always ends on a complete statement.
  • One snapshot, dialect-correct per engine.

Phase 3: native dump tools per engine

PostgresDumpService is now NativeDumpService, driven by a NativeDumpDescriptor per engine. PostgreSQL and Redshift keep exactly what they had; MySQL/MariaDB, MongoDB, SQLite and libSQL are new.

Generalizing was not parameterizing a binary name: pg_dump -Fc is told a path, mysqldump and sqlite3 .dump write to standard output, and mongodump takes neither host flags nor a -d. So a descriptor supplies its own arguments and says how its output is delivered, and the runner redirects stdout or stdin accordingly.

Passwords never reach argv, which every process on the machine can read through ps. PostgreSQL gets PGPASSWORD and MySQL MYSQL_PWD; MongoDB's tools read neither, so a 0600 config file is written and removed when the process exits, however it exits. A test asserts no engine leaks the password into the argument list in either direction.

Phase 4: formats and transfer

Four of the "missing formats" in the issue already shipped or were one option away, which is worth recording rather than building redundant plugins for:

  • TSV export and import: already a delimiter option in both CSV plugins.
  • NDJSON import: JSONImportParsing.isLineDelimited already detects it, and .jsonl / .ndjson were already accepted extensions.
  • NDJSON export: added as a layout option on the existing JSON plugin, not a new one.

Transfer To… is the real addition: TableTransferService joins an export data source to another connection's import sink, so rows move with no file in between. Rows only, because inventing DDL that crosses engines is a different problem and getting it half right creates tables whose types quietly disagree with the data.

Phase 5: profiles, reports, gating

  • ExportProfileStorage saves a selection under a name and reapplies it, including each object's options and row scope. Applying is idempotent, and a profile naming objects the database no longer holds says how many it still matches.
  • Save Report… on an import that skipped rows writes a CSV with a line, statement and the server's own error per row. A count is not actionable.
  • The transfer sheet excludes read-only destinations and confirms before a delete-and-replace, because its rows reach the driver through the import sink rather than the execution gate.

Second batch: formats, dumps and the defects found on the way

Two defects in the first batch, both mine. TableTransferSheet never populated columnMapping, so the sink skipped every field and each transfer failed on its first batch with "No values in this row matched the column mapping"; my tests covered the pure helper and the request struct but never the seam. It now matches columns by name with an editable per-table mapping, reports unmatched columns before starting, and refuses a table matching nothing by name. Separately, ImportErrorReport wrote server-controlled error text into a CSV with no formula sanitization, which is CSV injection into a file the user opens in a spreadsheet; the CSV export plugin has guarded that for a long time and the report now does too.

Parquet needed no new library, and finding that out found a shipped defect. The published libduckdb.a linked none of the extensions scripts/duckdb-macos-extensions.cmake declares: sum, round, json and parquet all failed against it, which is the offline failure that cmake file exists to prevent. The library is rebuilt and republished, scripts/check-duckdb-extensions.sh now probes the shipped binary against its own config so the two cannot drift again, and #2626 records the defect on its own.

Parquet export stages rows in an in-memory DuckDB and lets it encode, because Parquet is Thrift-encoded metadata over dictionary and RLE column chunks and a hand-written encoder has nothing local to check it against. Verified end to end against the real artifact: a written file read back as id BIGINT · name VARCHAR · paid BOOLEAN · due DATE · amt DOUBLE, with an unparseable date becoming null rather than failing the export. DuckDB spills to a temp directory, so a table larger than memory takes longer instead of failing. The plugin is registry-only: it carries its own 149MB universal DuckDB and that is not worth adding to every app download for one format.

Also in this batch: Markdown, HTML and XML export; XLSX import with its own ZIP reader and sheet parser; Server-Side Export for Oracle, Snowflake and BigQuery; SQL Server backup and restore through SqlPackage; MySQL events and PostgreSQL standalone sequences as object kinds; the shared row writers moved into PluginKit so MCP and the export plugins escape values identically.

sqlpackage is the one tool with no password channel but argv, where ps can read it. The descriptor carries exposesPasswordInArguments and the backup flow asks before running one, rather than leaving the user to find out.

Deliberately not done

  • Postgres COPY export. Not reached. Lower value than what shipped, and the SQL export already produces a restorable file for PostgreSQL.
  • UI automation. Unchanged from below.

Two things to weigh rather than merge blind:

  • sqlpackage and Oracle Data Pump are written from documentation, not from a run. I had no SQL Server with SqlPackage and no Oracle with a DIRECTORY object to test against, so an argument could be wrong in a way only a user hits. Everything else here was exercised.
  • Two plugins now statically link DuckDB (the driver and Parquet export), so a user with both installed carries two copies of its symbols. Both are registry-only and each uses its own isolated in-memory instance, and macOS bundles are two-level namespaced, so this should be waste rather than breakage. I did not build a two-bundle harness to prove it.

Verification

  • build: PASS
  • test over 26 suites (220 cases): PASS
  • lint over TablePro, every touched plugin, TableProPluginKit and TableProTests: 0 violations
  • scripts/ci/check-plugin-manifest.py: 29 plugins agree with the registry
  • scripts/check-duckdb-extensions.sh: every declared extension linked
  • docs: PASS, house style and source claims both agree
  • abi against the merge base: additive. The one line the diff shows as removed is the published init(...schema:), re-added byte-identically with @_disfavoredOverload, which is a type-checker hint and not part of the mangled symbol. No version bump, no plugin re-release.
  • plugins (AllPlugins) fails locally on the vendored oracle-nio fork's @TaskLocal macro (unknown attribute 'usableFromInlinenonisolated'), which is a known toolchain incompatibility unrelated to this diff. The two plugin targets this touches, SQLExport and JSONExport, both build; CI runs the aggregate on its own toolchain.

Codex reviewed nothing. It ran for 1h23m and then failed with "Reviewer failed to output a response" (no credit error in its log). Its reasoning trace did name two concerns, which I chased myself: export memory, which is why Parquet now spills to disk, and plugin symbol duplication, which is the DuckDB caveat above. No second model read this diff.

No UI automation. The export tree, the row-scope popover and the transfer sheet all need a live connection with routines, triggers and a second open session to exercise, which TableProUITests cannot set up deterministically.

https://claude.ai/code/session_011EqgjCjCAU6tiiVmnMpF86

@mintlify

mintlify Bot commented Sep 3, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated (UTC)
TablePro 🟢 Ready View Preview Sep 3, 2026, 7:49 AM

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

@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.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit 14edddc into main Sep 3, 2026
14 checks passed
@datlechin
datlechin deleted the feat/full-import-export branch September 3, 2026 14:49
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.

Full import and export: object scope, dialect-correct dumps, native dump tools per engine

1 participant