Skip to content

feat(datagrid): show the selected row as JSON, with foreign keys that expand into the rows they reference - #2593

Merged
datlechin merged 9 commits into
TableProApp:mainfrom
nanaaikinson:feat/json-row-inspector
Sep 1, 2026
Merged

feat(datagrid): show the selected row as JSON, with foreign keys that expand into the rows they reference#2593
datlechin merged 9 commits into
TableProApp:mainfrom
nanaaikinson:feat/json-row-inspector

Conversation

@nanaaikinson

Copy link
Copy Markdown
Contributor

The inspector gains a third tab that prints the selected row as JSON and lets a foreign key be followed without leaving the row. Modelled on Beekeeper Studio's details view, which is what the request asked for.

Two commits: the tab, then a performance fix to shared inspector code that the tab exposed.

The JSON tab

Show Row as JSON on a row's right-click menu opens the inspector on the tab; the tab is also reachable from the segmented control, which now appears whether or not the AI assistant is enabled (AI Chat drops out of the list when disabled rather than sitting there unselectable).

The row prints as one JSON object, keys in the grid's column order. Whether a value is quoted comes from the column's own type, never from the text: INT and DECIMAL print bare through JsonNumberNormalizer, so a BIGINT holding 9007199254740993 keeps all 16 digits; BOOL reads the spellings the drivers emit; a blob prints as hex; NULL prints as null. A JSON column, and a text column whose value is shaped like a document, expand as nested keys. That parse is JsonSyntaxParser, the one the JSON cell viewer already reads with, so a document cannot render one way in a cell and another in the row.

Following a key

A foreign key column carries a disclosure control. Opening it fetches the referenced row, and a foreign key inside that row opens the same way, five levels deep. The fetch is ForeignKeyRowFetcher, extracted from the fetch that used to live inside ForeignKeyPreviewView, so Preview Referenced Row and the tab now share one query path, one set of identifier quoting, and one dialect-aware limit clause. Metadata for a nested key answers from SchemaForeignKeyStore when the schema prefetch covers the table, and otherwise goes through withMetadataDriver, so the embedded-engine pooling rule holds.

employee.manager_id → employee is ordinary schema design, so the chain is checked rather than trusted: JSONForeignKeyExpansionPolicy refuses a key already followed with the same table, schema, column and value, and refuses anything past the fifth level. Both report on the row with the reason. A NULL key offers no control at all.

Always Expand Foreign Keys fetches the first level on every row selected from then on. It is session state, off at every start, because each key it follows is a query.

Filtering

The filter field takes text, matched case- and diacritic-insensitively, or a regular expression wrapped in slashes. It keeps every match plus the keys that lead to one, opens what was collapsed, and searches inside foreign key rows already fetched. An invalid expression outlines the field and filters nothing rather than falling back to substring matching, which would silently answer a different question.

Copy Visible prints exactly the lines on screen, collapsed containers as {…} and filtered-out keys not at all, through the same renderer the view draws from.

Where the state lives

JSONRowInspectorViewModel is owned by RightPanelState, not by the tab, so switching to Details and back keeps the reader's expansions and the rows already fetched for them.

The row reaches it from RightPanelState.inspectorContext's didSet rather than from the view's onChange. onChange runs after the render that already observed the new value, so the first draft drew one frame of the previous record's tree on every row change: the tab visibly flickered. Writing the context and the model in the same turn means every render sees one consistent row. JSONRowSnapshot carries its own connectionId and databaseType for that, which also makes it impossible to hand the model a row from one connection with another connection's id.

The tab is read-only. Editing stays on Details, which owns the whole write path.

The performance fix

FieldEditorResolver.resolve runs looksLikeJson, a full JSONSerialization parse of the value, and then a PHP-serialized parse after it. It was reached from two view bodies per field, RightSidebarView.fieldDetailRow and FieldDetailView.body, so a row with long text columns re-parsed every value twice per body pass: on every inspector tab switch, on every hover (isHovered is @State), and on every keystroke in a pending edit.

FieldEditState.resolvedEditor now holds the answer, filled once when MultiRowEditState builds the fields, which is once per selection. The view passes the resolved kind down through FieldEditorContext.editor so the second resolve short-circuits.

Separately, the Details tab stays mounted and is hidden rather than rebuilt when another tab is selected. Its field list is a List, so leaving the tab tore down an NSTableView and a field editor per column and returning built them again, with a cost that grows with the row's width. It is hidden with opacity(0), allowsHitTesting(false), disabled(true) so its text fields leave the key view loop, and accessibilityHidden(true). JSON and AI Chat stay conditional: JSON is a LazyVStack and cheap to rebuild with its state held elsewhere, and mounting AI Chat eagerly would create its view model and load conversations on windows that never open it.

Tests

TableProTests/Models/JSON/ holds 37 cases over the pure layer: the builder's typing rules and its document parsing, the filter's substring, regex, ancestor and fetched-subtree behaviour, the flattener's braces, commas, collapsed tokens, disclosure state and per-key status, the expansion policy's cycle and depth decisions, and the Copy Visible renderer. JSONRowInspectorUITests drives the real flow on Chinook: the context menu item opens the tab, and expanding Album.ArtistId fetches the Artist row.

Verified

Step Result
swiftlint lint --strict over TablePro, TableProTests, TableProUITests 0 violations
docs/scripts/check-writing-style.sh Same single pre-existing modifier glyph failure as main, no new one
docs/scripts/check-docs-against-source.py Passes
xcodebuild build (Debug) Succeeds, see the caveat below
Pure model tests, 37 cases Pass, in an isolated SwiftPM harness rather than in TableProTests
xcodebuild test Not run
JSONRowInspectorUITests Not run

The machine this was written on runs Xcode 26.2; CI pins 26.4.1. Under 26.2 five files that this branch does not touch fail to compile: CompareRowService (its closure parameters cross an isolation boundary), AIChatViewModel+Persistence and InspectorViewController (sending 'self' risks causing data races), PluginMetadataRegistry+RegistryDefaults (type-check timeout), and RedisReply in the test target. The successful build above needed temporary local edits to four of them plus a raised -solver-expression-time-threshold; every edit was reverted and none of it is in either commit. The test target's failure is why the suites and the UI test have not run here. CI is the first place they will.

The Details↔JSON switch cost is likewise unmeasured. The two causes above are real and are fixed at their source, but nobody has timed the result.

Docs and changelog

features/json-viewer.mdx gains a Row as JSON section, features/data-grid.mdx points at it from its foreign key section. [Unreleased] gains three Added entries for the tab, the expansion and the filter, and one Fixed entry for the inspector lag, which predates this branch.

@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: 5fb074ec7f

ℹ️ 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 +116 to +118
var contentHasher = Hasher()
contentHasher.combine(row.values)
contentHasher.combine(tableRows.columns)

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 Rebuild the JSON tree when foreign-key metadata changes

On a cold schema fetch, tableRows.columnForeignKeys is populated after a result row may already be selected, but this token only includes the cells and column names. The subsequent inspector update therefore takes the same-row early return and keeps the original tree without foreign-key nodes, so Expand controls never appear until the selection changes. Include foreign-key metadata (and column types) in change detection or compare the full snapshot.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct, and column types had the same hole. Fixed in d3e5e32.

The token is gone rather than extended. JSONRowSnapshot is Equatable, so the view model holds the last snapshot and compares the whole value; rowIdentity stays, but only to answer "is this a different row" for the reader's expansions. A hand-written token over the parts that seemed to matter was the defect, so nothing hand-written replaced it.

JSONRowSnapshotChangeTests covers late columnForeignKeys, late columnTypes, moved values on a stable identity, and the unchanged case.

Comment on lines +55 to +59
if !isSameRow {
cancelFetches()
states = JSONForeignKeyStates()
chains = [:]
}

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 Clear foreign-key expansions when reloading the same row

When a query is re-executed or refreshed, rows retain identities based on their tab and offset while their values can change. For that same identity this branch leaves states.fetched and active fetches intact; because the renderer substitutes a fetched expansion solely by its path, a new FK value such as artist_id = 2 can still show the referenced row previously fetched for artist_id = 1. Cancel and clear FK state, or version it by the content token, before rebuilding a changed row.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, and it is the worse of the two: rowIdentity is the tab plus the row's position, so a rerun keeps it while the values move underneath, and states.fetched is keyed by the node's path alone. ArtistId going 1 to 2 kept Artist 1 printed under it, presented as this row's own data.

Fixed in d3e5e32: any content change now cancels the in-flight fetches and clears states and chains before the rebuild, not just a change of row.

The same commit also replaces the fetch's own guard, which compared snapshotIdentity — exactly the value that survives a rerun, so a late-returning query could still write into a rebuilt tree. It is a monotonic generation counter now, bumped by every rebuild and every reset, matching the connection-attempt generation guard in DatabaseManager.

Comment on lines +25 to +28
for (index, column) in columns.enumerated() {
let value = index < values.count ? values[index] : .null
let type = index < columnTypes.count ? columnTypes[index] : nil
let childPath = path.appending(column)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make JSON paths unique for duplicate result-column labels

Queries can return duplicate labels, such as an unaliased join selecting two id columns, but both nodes receive this same path. That produces duplicate JSONDisplayRow.id values in the SwiftUI ForEach and also merges collapse and fetched-FK state for the two columns, so duplicate-labeled JSON/container columns cannot be rendered or expanded independently. Add the top-level column occurrence/index to the path while retaining the original label as the JSON key.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct. SELECT a.id, b.id gives columns == ["id", "id"], so both nodes shared a path: one id in the ForEach, and one entry in both the expanded set and the fetched-key map.

Fixed in d3e5e32 the way you describe: the path component leads with the column's position (0.id, 2.id) while JSONNodeKey keeps the plain label, so the printed JSON is unchanged. It is the same shape the document members already used for duplicate keys inside one object.

separatesDuplicateColumnLabels in JSONRowNodeBuilderTests pins it. Four existing tests spelled child paths out by hand and now ask the tree for them instead.

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

@datlechin

Copy link
Copy Markdown
Member

Review pass over the branch

A second review of the whole diff against main, plus a read of the two tabs side by side. Four commits, all of them answers to something a review found.

What the JSON tab printed, filtered and copied

e5359237c

  • A JSON column holding a top-level scalar printed as a string. 42 came out "42", and "ready" came out "\"ready\"" with its own quotes inside the printed ones. parsedDocument threw away a parse that was not an object or an array and let the column-type path have the value instead. Those are whole JSON documents and the column is entitled to hold one. The gate above it is what keeps a VARCHAR holding 42 a string: any column that is not typed JSON still has to start with a brace or a bracket to be parsed at all.
  • A filter that matched a container's key hid its contents, and the disclosure control on that line could not open them, because a filtered tree takes its expansion from what survived the filter rather than from the reader's expanded set. A key that matches now keeps its whole subtree.
  • A blob over 64 bytes was truncated on the pasteboard, ellipsis and all, and Copy Value carried the printed quotes. printed and unquoted both went through the same helper. Display is still capped, because a megabyte of hex is not a line anyone reads; Copy Value carries every byte and no quotes. The round trip through a Latin-1 String went too.
  • A key containing a quote, a backslash or a newline was drawn verbatim, so the object on screen was not valid JSON and disagreed with Copy Visible, which escaped it correctly.
  • Copy Value and Open disappeared from a foreign key the moment it was expanded, because its token becomes { and the row read its value out of the token. JSONDisplayRow carries the node's own scalar now.

Foreign key fetches

feec455c9

finishFetch removed the path's task handle before checking the generation. Task.cancel() cannot interrupt a query blocked in the driver, so a cancelled fetch still returns late, and when it did it deleted the handle of the fetch the rebuilt tree had started at the same path. That one could no longer be cancelled and the key was open to a duplicate query.

The model took its lookup from ForeignKeyRowFetcher statically, which is why none of its own rules had a test. It takes the fetch as a value now, defaulted to the same call, and JSONRowInspectorViewModelTests drives the rules that had none: a rerun that moves a row's values drops the rows fetched for its keys, a fetch returning after a rebuild writes nothing, a stale completion leaves its replacement in hand, a NULL key never fetches, a repeated visit reports the cycle before it costs a query.

The comment on dataGridShowRowAsJSON claimed the table view had already retargeted the selection. It has, for a click outside the selection; a click inside a multi-row selection keeps that selection deliberately, and the panel then shows its first row, the same row Details shows.

The panel

09ff19531

  • The filter field was a plain TextField with a hand-drawn border and clear button. Escape did not clear it, it had no cancel button of AppKit's own, and assistive software read it as text rather than as a search field. It is NativeSearchField now, the same control the Details tab uses six lines away.
  • Removing the if ai.enabled branch from body left the AI Chat pane rendering while the assistant is off. activeTab is persisted per connection and restored without asking whether the tab still exists, and onChange does not run for a value that was already wrong on appearance: leave a connection on AI Chat, close it, turn the assistant off, reopen it, and the picker shows no selection over a live AI pane, with AIChatViewModel built on a window that was never meant to have one. RightPanelTab.resolved(_:isAIEnabled:) is pure and every read goes through it.
  • An empty tree drew "No Matches" whether or not a filter was running.

Docs and changelog

77b600478

The page said "keys in the grid's column order"; the tab reads the result's own columns and shows the hidden ones, exactly as Details does. It said "nothing is truncated" over a blob display cap. It said "A foreign key key". The section had no screenshot, which every other feature page carries, so there is a light and dark pair now.

[Unreleased] gained the Fixed entry for the inspector lag that 5fb074ec7 fixes.

Verified

Step Result
xcodebuild build (Debug) Passes. The five files that would not compile under this machine's Xcode do compile now
xcodebuild test, 15 suites 179 of 179 pass, up from 159
swiftlint lint --strict over TablePro, TableProTests, TableProUITests 0 violations
verify.sh docs Passes

One failure is inherited, not from this branch: MainContentCoordinatorAddRowTests/addRowFollowsTheResultMode fails here and on main at cfe583787 (run 33487209497), together with Main menu validation. It has to be fixed on main before this PR can be green.

CI has never run on this PR. All eight workflow runs are action_required at 0s and need approval in the Actions tab.

The screenshots are 1512x861 rather than the usual 3024x1722, because the machine that took them has a 1080p display with no backing scale. They are real captures of this branch, not placeholder cards, and worth re-taking on a retina machine before release.

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

@datlechin

Copy link
Copy Markdown
Member

Adversarial pass over the fixes above

A second reviewer went at the approach rather than the lines, and returned a no-ship. Three of its findings were mine to answer, and one of them reversed a call I had made. 49ee20f20.

A value match was keeping the whole referenced row

JSONRowFilter.collect inserted the entire subtree whenever a node matched, but a match meant the key or the value. An expanded foreign key now deliberately carries both its own scalar and the fetched row's fields, so after opening ArtistId = 1, filtering for 1 kept every column of the Artist row underneath it: Name, Address, Country, none of which matched. That contradicts the contract the page states, that a key match is what keeps what a key holds.

Key matching and value matching are separate now. A key keeps its subtree; a value keeps its own line and the keys that lead to it.

The filter walked the same subtree once per matching ancestor

The old shape scanned every descendant, then re-walked those same descendants through insertSubtree for each node that matched. A chain of matching containers over one large subtree was nodes + sum(matching subtree sizes), quadratic down a matching chain, and displayRows runs it synchronously on every keystroke. A document under the existing 100,000-character cap can hold hundreds of such ancestors.

It is one DFS now: whether the key matches is decided before descending and carried down, so every node is visited once. nestedMatchesDoNotRewalkTheSubtree asserts the visit count over a 40-deep chain of matching keys rather than only the output.

A scalar document is validated before it is retyped

Letting a JSON column keep a top-level scalar was right, but JsonSyntaxParser is a highlighter's parser, not a validator: it drops the backslash from an unknown escape and reads 01 as a number. SQLite will store either in a column declared JSON. So "\q" would have displayed as "q", silently dropping a character the cell does hold.

A scalar is now admitted only if JSONSerialization accepts it as a fragment. A container keeps the lenient parse it has always had, since its braces are what the tree is built from.

And one I had wrong

I had made Copy Value carry a blob whole, on the grounds that the pasteboard should hold the value rather than the rendering of it. The reviewer called it an unbounded main-actor encode, which sent me to look at what the rest of the app does: RowValueCopyFormatter.copyText, which is the grid's own Copy for the same cell, caps a blob at 64 bytes. So carrying the whole thing made one cell copy two different ways depending on where you copied it from, and put an unbounded encode in front of a pasteboard write for nothing.

Copy Value is capped at the same 64 bytes now. The quotes were the actual bug; the cap was never it. The docs line changed with it.

The hex itself still goes through a byte buffer rather than a character-at-a-time append, and the Data to Latin-1 String round trip stays gone.

Not fixed, and why

  • AI availability sits at the view, not at the action boundary. The reviewer is right that resolving the tab only changes what renders: with the assistant off, a live AIChatViewModel is retained and its stream does not observe the setting, and the Query menu's AI commands want checking. All of that predates this branch, and the fix here is a strict improvement on what was there. It is worth its own issue rather than a quiet expansion of this PR.
  • A JSON null copies as NULL. JSONScalarText.unquoted(.null) has always spelled it that way, and admitting scalar documents means a JSON-typed cell holding null now reaches it. Separating database NULL from JSON null needs a case on JSONScalar and ripples through the colours, the search text and both renderers. Small, but it is a change to the type, not to this fix.

Verified again

Step Result
xcodebuild build (Debug) Passes
xcodebuild test, 10 suites 78 of 78 pass
swiftlint lint --strict over TablePro, TableProTests 0 violations
verify.sh docs Passes

@datlechin
datlechin merged commit 7f1f1df into TableProApp:main Sep 1, 2026
4 checks passed
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.

2 participants