Skip to content

feat(datagrid): offer the engine's own default values on the Structure tab's Default cell - #2690

Merged
datlechin merged 2 commits into
mainfrom
feat/column-default-picker
Sep 9, 2026
Merged

feat(datagrid): offer the engine's own default values on the Structure tab's Default cell#2690
datlechin merged 2 commits into
mainfrom
feat/column-default-picker

Conversation

@datlechin

Copy link
Copy Markdown
Member

What this is

The Structure tab's Default cell now carries the engine's own list of default values, and still takes typed SQL. Clicking the chevron opens No default, NULL, Empty string, the engine's expressions, and Custom….

Fixes #2688.

Root cause

Two layers, and the second is why this could not be only a menu.

Surface: the Default cell was plain free text. No vocabulary, so you had to know that "now" is CURRENT_TIMESTAMP here, now() there and GETDATE() somewhere else, and there was no way to say "empty string" as distinct from "no default".

Underneath: defaultValue was an untyped String? with no agreed contract. Nobody said whether it held a literal or an expression, so each of the 12 DDL writers guessed with its own hand-copied allowlist and quoted whatever it did not recognise into a string literal. Measured against live servers during the investigation:

Engine What happened
PostgreSQL 17.11 gen_random_uuid() and CURRENT_DATE became DEFAULT 'gen_random_uuid()'. Duplicating a serial column emitted DEFAULT 'nextval(''users_id_seq''::regclass)', because the sequence guard tested ::REGCLASS as a suffix, a shape PostgreSQL never emits
MySQL 8.4.11 Editing only the comment of b DATE DEFAULT (CURRENT_DATE + INTERVAL 1 YEAR) failed with 1067. The DEFAULT (UUID()) variant succeeded and permanently stored the string 'uuid()'
ClickHouse 26.8.2.7 Editing only the comment of host String MATERIALIZED domain(url) converted the column to DEFAULT 'domain(url)', and every later row stored the literal text
SQLite 3.54.0 PRAGMA table_info reports an expression without its parentheses and a bare word without its quotes, and those are not the same thing. (datetime('now')) reads back as datetime('now') and will not parse bare; DEFAULT abc is a text literal and fails as (abc) with default value of column [a] is not constant
Snowflake, Teradata No quoting at all: pending became an invalid identifier

So any menu built on those writers would ship broken DDL. Fixing the contract is the feature.

The fix

defaultValue is now the exact SQL that follows DEFAULT, with nil meaning no clause at all. A literal carries its own quotes, an expression is written the way the engine spells it, and NULL means DEFAULT NULL rather than the absence of a default. Every driver emits it verbatim and stops guessing; each read path normalises what its catalog gives it into that form.

The three states the issue asks for are already representable once the contract is fixed: nil, "NULL", "''". No model change, no PluginColumnDefinition field, no PluginKit ABI bump.

Adding syntax the engine's own grammar demands is still the driver's job. MySQL takes a TEXT, BLOB, JSON or GEOMETRY default only in parentheses whatever the value is, so mysqlDefaultValueLiteral adds them there and nowhere else.

The control

Built from the grid's own parts, which is what the Type column already is. No NSComboBox is mounted: the grid draws its cells with CoreText and holds 26 views rather than 12,500, and a per-cell combo box would give that back.

  • The chevron opens an NSMenu with NSMenuItem.sectionHeader(title:) groups (macOS 14, our floor) and a checkmark on the item whose SQL equals the stored value.
  • The cell is still inline-editable, so typing raw SQL works as before.
  • Custom… opens a popover with a Text | SQL expression picker. Text escapes through the connected driver's own escapeStringLiteral, which is stricter than the shared helper on the engines that need it.
  • The row inspector and the Create Table sheet get the same affordance through the shared provider, as a new FieldEditorKind.valuePicker. enumPicker cannot serve: its list is the whole set of legal values, and a column default is open-ended.

The vocabulary

Curated per database type id, the shape PluginMetadataRegistry.fallbackCategory already uses. It cannot be keyed by plugin: MySQL and MariaDB share one and disagree about parentheses, and PostgreSQL, Redshift, CockroachDB and PGlite share one where only two have gen_random_uuid().

Two things deliberately absent:

  • AUTO_INCREMENT, which TablePlus's menu offers. It is a DEFAULT on no engine TablePro supports: a column attribute on MySQL, a sequence or identity on PostgreSQL, a primary-key constraint on SQLite. TablePro already models it as its own Auto Inc field.
  • Empty string on Oracle and Dameng, where a zero-length character value is null, so the two items would be one statement.

An engine the app has never heard of gets the shared literals and no expression section, rather than another engine's spelling.

Collateral fixed here

These are not extras. The menu is wrong without them, so they are scope.

  • PostgreSQL requoting nextval(...) and gen_random_uuid() into string literals.
  • MySQL rewriting an untouched expression default on any edit to the same column.
  • ClickHouse converting a MATERIALIZED or ALIAS column to a plain DEFAULT column. Only a DEFAULT kind now populates defaultValue, and MODIFY COLUMN leaves the other kinds alone, which the plugin already relied on for its reorder path. Clearing an ordinary default now emits MODIFY COLUMN … REMOVE DEFAULT; omitting the clause left the old default on the server while the save reported a removal.
  • Snowflake and Teradata interpolating the cell with no quoting.
  • Cassandra and ScyllaDB showing Default and Auto Inc cells. CQL has neither; both inherited them from the DriverPlugin fallback. ScyllaDB is registered as its own type id with its own curated snapshot, so both needed fixing.
  • Trino offering a Default cell that its DDL never carried: TrinoColumnSpec maps name, type, nullability and comment only, so a default typed there reached no statement and the save reported success. The field is gone rather than accepting a change that is discarded.
  • ER-diagram SQL export guessing at unquoted defaults, which exported Oracle's SYSDATE and USER and SQLite's X'0102' as quoted strings that run and store something else.
  • MariaDB before 10.2.7, which does not quote COLUMN_DEFAULT. Catalog normalisation is version-gated rather than keyed on the server being MariaDB at all.
  • DEFAULT CURRENT_TIMESTAMP on a non-temporal column. MySQL reports a VARCHAR literal of that text the same way and with no DEFAULT_GENERATED marker, so reading it as the expression turned a stored string into a clock reading on the next edit.

Visible change

MySQL and SQLite string defaults now display as 'abc' rather than abc. That is what the DDL says, and what PostgreSQL users already saw. Typing bare text into the cell now means a SQL expression, so a string literal comes from Custom… > Text or from typing the quotes; the SQL preview sheet shows the statement before it runs.

Before / After

There is no meaningful "before" shot: the Default cell rendered as plain text with no affordance at all. After, every Default cell carries the chevron:

Columns tab with a chevron on every Default cell

The menu itself could not be captured. The data grid draws its cells rather than mounting views, so it takes no synthetic click, and a screenshot of an open NSMenu over it cannot be scripted.

Verified

Step Result
build PASS
test (13 suites, 172 cases) PASS
test (DamengDriverTests scheme, which CI runs separately) PASS, 40 tests
plugins (AllPlugins) Only the known oracle-nio @TaskLocal macro failure in the vendored SPM dependency, which breaks this aggregate locally on every branch. No error names any file in this change
docs PASS
lint Two legacy_swiftui_aspect_ratio findings, both in files this branch never touches (SupportView.swift, ImportFromAppSourcePicker.swift), both present on main
uitest ColumnDefaultPickerUITests INCONCLUSIVE locally: "Timed out while enabling automation mode", an unanswered macOS automation prompt on this machine. The suite is committed and CI runs it

SQLiteDefaultValueTests and the MySQL catalog cases pin behaviour measured against real servers rather than assumed. ColumnDefaultRoundTripTests is the drift guard: every value the menu can choose is run through the writer that would emit it and has to come back byte-identical, so the vocabulary and the writers cannot disagree silently.

Reviewed

Both Codex passes ran against this diff. The defect review found seven issues and the adversarial pass returned No-ship on the first draft; everything above the "Known and not fixed here" section below is what those two passes turned up and what is now fixed, including the SQLite bare-word literal, the MySQL CURRENT_TIMESTAMP misreading, the MariaDB version floor, ScyllaDB, Trino, the ER exporter, and the ClickHouse removal path. Two more were producers of the contract I had missed: RowImportSheet's Default field, now labelled and documented as SQL, and DamengDriverTests, whose assertion pinned the old quoting and is updated.

Known and not fixed here

  • An already-installed registry plugin predates the contract. Its writer still applies the old quoting, so an expression chosen from the menu is quoted into a literal on Snowflake, Teradata, Oracle, Dameng, DuckDB, libSQL and Cloudflare D1 until those plugins are published. A PluginKit bump does not close this: validateBundleVersions rejects a plugin above the app's version, never below. Publishing the plugins with the app release is what closes it.
  • Cross-engine Copy To does not translate defaults within a type family. MySQL and MariaDB share SQLTypeFamily.mysql, so needsTranslation is false and MariaDB's uuid() reaches MySQL's writer unchanged, where MySQL 8 needs (uuid()). Before this change the same value was quoted into 'uuid()' and stored as text silently; it now fails loudly instead, which is better but still wrong. Fixing it means giving the translator the target engine rather than its family, and validating target DDL before the Replace plan's destructive cleanup. That is Copy To's own subsystem.
  • ClickHouse's column kind is still not on the wire. A MATERIALIZED or ALIAS column now shows an empty Default cell rather than a misleading one, and an unrelated edit no longer converts it. Deliberately choosing a default for such a column still converts it, because PluginColumnDefinition has no kind field to refuse on.

Not in this PR

  • No sequence-creation UI. TablePlus has one and it has its own open bug there.
  • No Default cell for BigQuery or SurrealDB. Neither declares the field today; adding it is a separate feature.
  • Trino declares Default and never emits a DEFAULT clause anywhere, so an edit there is still read, displayed and dropped. Reported, not fixed.

Registry plugins need publishing

The driver fixes reach bundled plugins with the next app release. Snowflake, Teradata, Oracle, Dameng, DuckDB, libSQL and Cloudflare D1 are registry-only, so their fixes are inert until those plugins are published.

https://claude.ai/code/session_01GxbNXNA2bvpPRsWnDRTjQQ

@mintlify

mintlify Bot commented Sep 9, 2026

Copy link
Copy Markdown

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

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 9, 2026, 6:08 AM

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

@datlechin

datlechin commented Sep 9, 2026

Copy link
Copy Markdown
Member Author

The Columns tab after the change. Every Default cell carries the chevron that opens the engine's list, and the cell still takes typed SQL.

The shot is committed in the branch at docs/images/structure-default-picker.png (and -dark.png), because gh cannot upload an image to a PR.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit 4b1c74d into main Sep 9, 2026
9 checks passed
@datlechin
datlechin deleted the feat/column-default-picker branch September 9, 2026 06:10
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.

Provide selectable options for column default values, like TablePlus does?

1 participant