Skip to content

feat(datagrid): auto-detect JSON and PHP serialized values in text cells#1447

Merged
datlechin merged 3 commits into
mainfrom
feat/json-php-cell-viewer
May 28, 2026
Merged

feat(datagrid): auto-detect JSON and PHP serialized values in text cells#1447
datlechin merged 3 commits into
mainfrom
feat/json-php-cell-viewer

Conversation

@datlechin

Copy link
Copy Markdown
Member

Summary

Cells holding JSON or PHP serialize() values in text columns (TEXT/VARCHAR/LONGTEXT) now open in a structured viewer automatically, without requiring the column type to be JSON.

  • JSON-in-text auto-detect: closes the asymmetric gap where CellInteractionResolver's editable branch sniffed looksLikeJson but the read-only branch didn't.
  • PHP serialized read-only viewer: net-new. Tree view via OutlineGroup with Picker(.segmented) Tree/Raw toggle, mirroring the existing JSON viewer surface (popover, right-sidebar inspector, pop-out window).
  • Per-column override: ValueDisplayFormat gains .json and .phpSerialized cases for text columns. .raw opts out of detection entirely.

Competitive context: TablePlus #1144 (PHP unserialize) has been open 7+ years with no implementation; Sequel Ace's PHP support is a deprecated Bundle, broken on macOS 12+. TablePlus, Postico, and DataGrip all gate JSON viewing on declared column type (TablePlus #3215, #3477). This PR ships the auto-detect path none of them have.

Architecture

Detection runs only on cell open, never during grid render (zero render cost). One shared CellValueContentDetector consumed by both CellInteractionResolver (grid) and FieldEditorResolver (right sidebar) so future formats can't reintroduce the asymmetry.

  • Detector: O(1) first-scalar pre-filter → looksLikeJson (existing) or PhpSerializeParser.looksLikePhpSerialized (new). 5 MB cap via (value as NSString).length.
  • PHP parser: recursive descent over UTF-8 bytes. Handles N b i d s S a O C r R; degrades o and unknowns to .unsupported; PHP O: decodes protected (\0*\0name) and private (\0ClassName\0name) mangling; C: is a single leaf (no recursion into Serializable payload); r:/R: rendered as → #N markers (no cycle traversal). 256 depth cap, 5000 node cap.
  • Parse runs off-main via .task { ... }; raw mode is always available as the safety net.
  • JSON viewer (JSONViewerContentView/JSONViewerView/JSONViewerWindowController) is column-type-agnostic and reused as-is. Existing JSONTreeParser preserves source key order.

Tests

  • PhpSerializeParserTests — every token, INF/NAN, multi-byte strings, declared-length mismatches, depth cap, reference markers, protected/private mangling decode.
  • CellValueContentDetectorTests — positive/negative for both formats, 5 MB guard, ambiguous-prefix false positives.
  • ValueDisplayFormatTests — rawValue, Codable round-trip, applicableFormats(for:) membership.
  • FieldEditorResolverTests — JSON/PHP detection plus override precedence (.raw skips both, .json/.phpSerialized force).
  • CellInteractionResolverTests — extended with PHP detection cases; the previous readOnlyJsonLikeTextWithoutTypeReturnsViewInline test (which documented the bug) is inverted to ...ReturnsViewJson since that's now the correct behavior.

All new and modified tests pass locally (xcodebuild test). Full project build is clean.

Manual test

A test fixture and walkthrough are in the development notes (10 sample rows covering JSON object/array, deeply nested, PHP scalars, nested arrays, objects with protected/private properties, references, and false-positive guards).

Notes for review

  • Localizable.xcstrings contains some pre-existing WIP entries (pagination strings, BigQuery dataset strings, AWS credential_process strings, SSH/cloudflared messages) that propagated from the working tree when the branch was created. The PHP-specific keys I added are: Invalid PHP Serialized Value, Maximum depth reached, PHP — %@, PHP Serialized, PHP Viewer, private (%@), protected, The value could not be parsed. Use raw mode to inspect it as text., Unsupported token: %@, Value Too Large, Value too large to parse, This value is too large to parse. Use raw mode to inspect it as text. (12 keys). Feel free to split the Localizable changes into a separate commit before merging.
  • Em dashes in window titles (PHP — %@) match the existing JSONViewerWindowController.swift convention (JSON — %@) and macOS HIG ("Document — App"). CLAUDE.md's anti-em-dash rule is generally for prose; if you want to refactor both window titles to a different separator, that's a small follow-up.
  • CellInteractionMode.editPhpSerialized is defined but unused (the editable branch always returns .viewPhpSerialized for PHP, since PHP write-back is out of scope). Easy to remove if you'd rather not carry an unused case.
  • PHP fields in the sidebar force isReadOnly, which hides the field-action menu (NULL/Default/Empty/Function) on those fields. Reasonable since they can't be edited via the viewer, but if you want those actions reachable, easy to gate differently.

Closes the request from https://x.com (public reply asking for auto JSON parse and PHP unserialize).

@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: 07671d8fad

ℹ️ 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".


let phpFirstScalars: Set<Unicode.Scalar> = ["N", "b", "i", "d", "s", "S", "a", "O", "C", "o", "r", "R"]
if let first, phpFirstScalars.contains(first) {
if PhpSerializeParser.looksLikePhpSerialized(value) { return .phpSerialized }

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 Require a successful PHP parse before routing

For editable text that merely starts with a PHP-ish prefix but is not serialized data (for example s: status or i: todo), this returns .phpSerialized because looksLikePhpSerialized only checks the first token and separator. CellInteractionResolver then opens the read-only PHP viewer for the cell, so double-click no longer starts the normal editor for those plain-text values.

Useful? React with 👍 / 👎.

Comment on lines +84 to +85
guard let result = cursor.parseValue(depth: 0) else { return nil }
return result

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 Reject trailing bytes after the serialized value

When the input has a valid serialized prefix followed by additional data, such as i:1; trailing text, parse returns the prefix value and never checks that the cursor consumed the whole string. The PHP viewer will therefore present the cell as the integer 1, hiding the extra bytes from the tree view instead of treating the value as invalid/plain text.

Useful? React with 👍 / 👎.

Comment on lines +186 to +187
var entries: [PhpKeyValue] = []
entries.reserveCapacity(count)

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 Avoid reserving untrusted PHP array counts

A malformed cell like a:1000000000:{} is under the 5 MB input cap but causes reserveCapacity(count) to try allocating space for a billion entries before the parser discovers the body is invalid; the object-property path has the same pattern. Opening or auto-rendering such a value can crash the app from memory pressure, so the count should be bounded by the remaining payload or a parser cap before reserving.

Useful? React with 👍 / 👎.

@datlechin

Copy link
Copy Markdown
Member Author

Addressed review feedback in two follow-up commits:

7f8663bf fix(datagrid): respect display-format override before column type and content detection

  • Restructured CellInteractionResolver.resolve(...) so user override is consulted first, then declared column type, then content detection. .raw now short-circuits to plain text rendering (read-only → viewInline, editable → editInline/editOverlay).
  • This fixes the editable-branch bug where .raw break'd out of the switch but content detection still ran below.
  • It also gives the per-column "Display as → Raw Value" picker a consistent meaning: .raw always wins, even on declared JSON columns (matches the user's explicit intent).
  • New tests: editableOverrideRawSkipsJson, editableOverrideRawSkipsPhp, editableOverrideRawMultilineReturnsOverlay, readOnlyOverrideRawBypassesJsonColumn.

7dc38a8b refactor(datagrid): drop unused PHP edit mode, unify parser depth guard, avoid byte copies

  • Removed CellInteractionMode.editPhpSerialized and collapsed its dispatch arm in DataGridView+Click.swift. PHP is always read-only, so a separate edit case was dead code.
  • Removed the redundant depth >= depthCap guards inside parseArray and parseObjectparseValue's depth <= depthCap guard is the single source of truth; recursion through parseValue(depth: depth + 1) catches the cap at the children entry.
  • Switched parseString, parseObject, parseSerializable, and readUntil to pass ArraySlice directly to String(bytes:encoding:) instead of materializing an intermediate Array. For a 1 MB PHP string value this avoids a 1 MB byte copy per occurrence.
  • New tests: englishStartingWithPhpTokenChars covers 10 false-positive English-text starters for every PHP token char (b, i, d, S, O, C, o, r, R, N); malformedPhpStillDetected documents that the detector intentionally promotes malformed PHP to the viewer so the user sees the "couldn't parse, use Raw" affordance rather than getting a silent plain-text fallback.

All 52 tests in the 7 affected suites pass. SwiftLint strict is clean. Build is clean.

Not addressed (your call):

  • Em dashes in "PHP — %@" window title — matches existing JSONViewerWindowController.swift convention. Refactoring both is a one-line follow-up if desired.
  • Localizable.xcstrings pre-existing WIP entries — splitting requires either a separate commit removing them (and restoring later) or rebase + cherry-pick; left to you per the original PR body note.

@datlechin datlechin merged commit 8360a00 into main May 28, 2026
2 checks passed
@datlechin datlechin deleted the feat/json-php-cell-viewer branch May 28, 2026 05:17
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.

1 participant