Skip to content

feat(datagrid): pick a foreign key value from the rows it references - #2610

Merged
datlechin merged 2 commits into
mainfrom
feat/foreign-key-value-picker
Sep 2, 2026
Merged

feat(datagrid): pick a foreign key value from the rows it references#2610
datlechin merged 2 commits into
mainfrom
feat/foreign-key-value-picker

Conversation

@datlechin

Copy link
Copy Markdown
Member

What

Editing a foreign key cell meant knowing the target row's key by heart. The grid gave Track.AlbumId the same plain text overlay it gives any other integer column: no list of valid keys, no name for the row a key points at, and the mistake only surfaced when the server rejected the save.

A writable foreign key cell now opens a picker instead of that editor. Double-click, Return, or Choose … Row… on the cell's right-click menu.

Before / After

Before, Return on Track.AlbumId opened the plain text overlay over the integer 5, with nothing to say which album that is or which other keys exist.

After, the same keystroke lists the rows it points at, with Album.Title beside each key, a check on the one the cell already holds, and Set NULL because the column takes it. This shot ships as the docs page's image, so it renders from the branch:

Foreign key value picker over Track.AlbumId, listing Album rows by title

The dark pair is docs/images/fk-value-picker-dark.png. The before shot is not committed, since nothing in docs/ uses it; it is at scratchpad/before-light.png locally if it is wanted inline.

How

The grid already had the reference. TableRows.columnForeignKeys carries a ForeignKeyInfo per column, prefetched for the whole schema by SchemaForeignKeyStore, and the navigation arrow, Preview Referenced Row and the JSON inspector's key expansion all read it. Nothing used it to help write a key.

CellInteractionResolver gains .editForeignKey, returned from the plain-text branch when the cell is writable, so a foreign key column holding a blob, JSON or PHP-serialized value keeps the editor its content needs and a read-only cell keeps every viewer it has. DataGridView+Popovers presents the picker through the existing activeCellEditorPopover slot and commitPopoverEdit, which is the same anchoring, dismissal and commit path the JSON, hex, date and array editors already use, and it falls back to the plain editor whenever the picker cannot be built.

Both reads go through DatabaseManager.withMetadataDriver: the referenced table's columns because that is the rule for a metadata read, and the rows because a search runs on every keystroke and the session driver is the one carrying the user's own query. ForeignKeyRowFetcher reads its single row on the session driver; a picker cannot afford to. The scope comes from the grid's own databaseName/schemaName, not from browseScope, so a tab that stays on the database it opened is not read against whatever the sidebar moved to.

The search predicate is a type question

FilterSQLGenerator owns the dialect, the case folding and the escaping, so ForeignKeyLookupQuery reuses it and decides only which columns may carry a predicate at all:

  • A contains predicate goes only on a column whose raw type name is a known character type. ColumnType cannot answer this: ColumnTypeClassifier files UUID, UNIQUEIDENTIFIER and every type it does not recognise under .text, and PostgreSQL has no ~~ for uuid, for an enum or for an array. The whitelist fails closed, so an unfamiliar type costs a search rather than an error on every search.
  • A predicate on the key goes on only when the term is a literal the engine can read as that type: contains for a character key, equal for a numeric key when the term reads as a number, equal for a uuid key when the term parses as one. renderLiteral quotes anything else, and AlbumId = 'rock' on an integer, or a malformed literal on a uuid, is an error rather than a query that returns nothing.
  • <key> IS NOT NULL always, ahead of the ordering and the limit. A referenced column may be a nullable UNIQUE one, and ascending order puts its NULLs first, so the page could be fifty rows the picker then discards.
  • When no predicate survives, no query is sent at all and the list is empty.

ORDER BY the key, with LIMIT 50 or OFFSET … FETCH NEXT per the dialect's pagination style.

The label column

Resolved from the stored choice, then a short preferred-name list (name, title, label, username, email, code, description), then the first text-like column that is not the key. The stored choice is honoured only when the table still carries that column, because the name reaches the query as a quoted identifier.

Remembered per referenced table rather than per source column: orders.user_id and comments.user_id both want users.name, so setting it once for users is what remembering it should mean. Device-local UserDefaults through the injectable KeyValueStore, the same shape as ValueDisplayFormatStorage, so it needs no CloudKit record type.

What review changed

Eight defects, each now with a test where one can reach it. The first two came from driving the built app against the Chinook sample, the rest from the Codex pass.

Use "…" led the list and was preselected whatever the user typed, so searching Album by title with Big put the search term under Return and would have written Big into an integer column. The term is offered as a value only when it could be a key.

With nothing typed the head of the list was selected, so opening the picker and pressing Return wrote the referenced table's first key over the one already in the cell. The cell's own value is selected instead, and nothing at all when the first page does not carry it.

A column of a composite key no longer opens the picker at all. The picker writes one column, so it would have offered a list of keys of which only some pair with the values the row already holds in the constraint's other columns, and the save is rejected on a reference the picker presented as valid. ForeignKeyConstraintSpan reads the span from the constraint name the columns share; an unnamed constraint is read as single-column, which costs a picker rather than a refused write.

A row is identified by its position, not by its key. A foreign key may reference one column of a composite unique key, which is not unique on its own, so two rows can carry the same key and two entries sharing an id is undefined behaviour in the List that renders them.

An empty column read no longer leaves the spinner up for good. The search waited on a non-empty column list, so a referenced table that answered with none left the picker loading with no way out. It waits on the read having finished instead, and the key guard reports what is wrong.

Every search on a PostgreSQL UUID foreign key was an error. uuid classifies as .text, so the key took a contains predicate and the query came out uuid_column ILIKE '%…%', which PostgreSQL rejects. UUID keys are common enough that this alone would have made the feature useless on a large share of PostgreSQL schemas. The predicate rules above are the fix, and the automatic label pick narrowed with them.

A nullable referenced key could show an empty list over a full table. Ascending order puts NULLs first, the limit took fifty of them, and rows(from:) discarded all fifty because a NULL key references nothing.

A generated column with foreign key metadata opened a picker whose commit went nowhere. CellInteractionResolver knows only the columns the plugin declares immutable, so the picker opened, the choice was dropped by recordCellEdit, and the popover closed over an unchanged cell. showForeignKeyPicker asks canStartInlineEdit again.

A Return during an in-flight search committed the previous row's key. Rows and selection survived the debounce, so typing a new key and pressing Return before the lookup landed found the old row id in the old entries. The selection is dropped the moment the search changes.

Verified

Step Result
verify.sh build PASS
verify.sh test (17 suites) PASS, 155 cases
verify.sh lint TablePro TableProTests TableProUITests 0 violations
verify.sh docs PASS
End to end against the Chinook sample Picker, search, label menu, Set NULL and the current-value check all driven by hand

New suites: ForeignKeyLookupQueryTests (both pagination styles, numeric, character and uuid keys, a type the classifier only guessed at, an enum label, a term no column can carry, the NOT NULL guard, quote and wildcard escaping on both LIKE conventions), ForeignKeyPickerEntryTests, ForeignKeyConstraintSpanTests, ForeignKeyLabelColumnTests, ForeignKeyLabelColumnStoreTests, plus a foreign key suite in CellInteractionResolverTests.

ForeignKeyPickerUITests drives the flow against the Chinook sample. It did not run locally: XCUITest cannot start while another TablePro is running, and one was, so CI is the first run. No PluginKit change, so the ABI check does not apply; nothing under Plugins/, so the plugin aggregate does not either.

Left for a follow-up

Two findings from the same review are real and pre-existing rather than introduced here, so they are not in this diff:

  • A MySQL cross-database foreign key. The plugin reports the other database in REFERENCED_TABLE_SCHEMA, and targetScope puts it in schema while the connection stays on the current database. MySQL advertises no schema switching and its fetchColumns ignores the schema argument, so the read lands on the wrong database. ForeignKeyRowFetcher has resolved it this way since Preview Referenced Row shipped, and fixing it means teaching the scope that a .byDatabase engine's referenced namespace is a database.
  • A superseded search is not cancelled at the database. SwiftUI cancels the .task, but plugin execute runs its C query through non-cooperative pluginDispatchAsync, so the metadata pool serialises the replacement behind a %term% scan that no longer has a reader. Every metadata read in the app has this property; cancelling one needs a handle on the pooled driver's running query.

Not in this change

  • The row inspector. FieldEditorContext carries no foreign key information, so a picker there means plumbing it through RightSidebarView and FieldEditorResolver. Worth doing separately.
  • Composite foreign keys. Those columns keep the plain text editor, for the reason above. The navigation arrow and Preview Referenced Row are unchanged there.
  • The foreign key accessory. The arrow keeps its meaning and its width reservation.

Fixes #2511

https://claude.ai/code/session_01HrHeVBX1ud3gtERNhc8LXn

@mintlify

mintlify Bot commented Sep 2, 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 2, 2026, 11:20 AM

💡 Tip: Enable Workflows 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 4be94ac into main Sep 2, 2026
9 checks passed
@datlechin
datlechin deleted the feat/foreign-key-value-picker branch September 2, 2026 12:15
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.

Foreign key value picker in the data grid

1 participant