Skip to content

fix: preserve query result text#588

Open
emrberk wants to merge 5 commits into
mainfrom
ebk_preserve_query_result_text
Open

fix: preserve query result text#588
emrberk wants to merge 5 commits into
mainfrom
ebk_preserve_query_result_text

Conversation

@emrberk

@emrberk emrberk commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • remove HTML entity decoding from the modern and legacy result grids
  • preserve result values exactly in rendered cells, clipboard output, and Markdown export
  • preserve leading whitespace so plain-text EXPLAIN plans keep their hierarchy
  • update result-grid and Markdown tests for literal HTML-entity-looking values

Problem

QuestDB's HTTP EXPLAIN response historically contained HTML entities as a Web Console presentation workaround. The console compensated by applying unescapeHtml to every result value, not only to structural query-plan indentation.

That made ordinary query results lossy: legitimate values such as  , <, >, &, ", and ' could be changed even though they were user data. Trying to detect EXPLAIN queries client-side would still be fragile and would not resolve collisions between plan structure and SQL literals embedded in a plan.

Closes #587.

Solution

The paired QuestDB server change returns EXPLAIN plans as plain text. With that contract, the Web Console can treat every response value as data:

  • remove all unescapeHtml calls and the unused helper
  • preserve whitespace in both grid implementations with white-space: pre
  • leave query-plan Markdown rows untouched
  • keep entity-looking strings literal in display, copy, and export paths

No client-side EXPLAIN detection or SQL tokenization is needed.

Validation

  • focused ResultGrid unit tests: 40 passed
  • TypeScript typecheck passed
  • relevant ESLint checks passed
  • production build passed
  • manually validated ordinary entity-like values, real leading spaces, raw angle brackets, EXPLAIN indentation, comparison operators, SQL literals inside EXPLAIN, and Markdown copy against the paired development server

Related

@emrberk

emrberk commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Review — PR #588 "fix: preserve query result text" (Level 3)

Quality gate: typecheck ✅ · lint ✅ · build ✅ · unit tests ✅ (1510 passed, incl. the new toSingleLineDisplay and updated markdown/copy tests).

Issues

# Issue Category Severity Location Description Repro Suggested fix
1 EXPLAIN plans show literal &nbsp;/&lt; on un-upgraded servers Query execution & data integrity Moderate in-diff (display path now emits server text verbatim; depends on paired server contract) The fix's correctness relies entirely on the paired server change (questdb#7406) returning EXPLAIN plans as plain text. Against a server that hasn't shipped it, EXPLAIN indentation (&nbsp;) and operators (&lt;,&gt;) now render as literal entity text in the grid, and copy/Markdown export the same broken text. Confirmed demo.questdb.io — the /exec proxy target in the modified vite.config.mtsstill entity-encodes: dataset:[["&nbsp;&nbsp;filter: 100&lt;amount"], ...]. There is no buildVersion guard gating the behavior. In the normal bundled binary the console ships with its own server so both halves land together; the risk is decoupled deployments (this dev branch → demo, QuestDB Cloud console served independently of the engine, or a standalone console pointed at an older instance). • Run EXPLAIN SELECT * FROM trades WHERE amount > 100 in the Result grid or a notebook cell against any server without questdb#7406 (e.g. this branch's own dev server → demo.questdb.io) • Plan renders as &nbsp;&nbsp;filter: 100&lt;amount instead of indented filter: 100<amount Confirm questdb#7406 is released everywhere this console build can reach before merging, and coordinate the release. (Client-side re-scoping the unescape to the QUERY PLAN column is not a clean fix — it would re-corrupt entity-looking SQL literals embedded in a plan, which is exactly why the server-side fix was chosen.)
2 Embedded tabs now render as wide tab-stops Styling & theming Minor in-diff (styles.ts CellText, _grid.scss .qg-r: nowrappre) toSingleLineDisplay collapses only [\r\n], not \t. Under the old white-space: nowrap a tab collapsed to normal inter-word spacing; under white-space: pre a VARCHAR/STRING value containing a literal tab now advances to the next tab stop, showing a visibly wide gap in both grids. Cosmetic only — no data loss (copy preserves the raw tab), fixed row height clips overflow. Arguably intended, since the PR's goal is to preserve whitespace. • Query SELECT 'a' || chr(9) || 'b' • Cell shows a wide tab gap instead of a single space If undesired, collapse tabs too: text.replace(/[\r\n\t]+/g, " ") in toSingleLineDisplay, plus a test case. Otherwise document as intended.
3 Legacy copy fallback drops newlines Async/structure Minor in-diff (src/js/console/grid.js:1716) In copyActiveCellToClipboard, valueToCopy is seeded from focusedCell.textContent, which #588 made a newline-collapsed string (toSingleLineDisplay). The primary branch correctly re-derives the raw value via getDisplayedCellValue, but if data[pageIndex][rowInPage] is absent the code falls back to the collapsed textContent — contradicting the adjacent comment ("must not leak into the clipboard"). Before #588 the fallback still carried raw newlines. Practically unreachable: a focused/visible cell's data page is loaded, so the fallback shouldn't fire under real interaction. • Focus a cell whose data page has been evicted, then Cmd+C (not reproducible through normal scrolling/focus) Seed the fallback from the raw datum, or accept the current behavior given it's unreachable — low priority.

False-positives

  • React correctness / async / hooks — No hooks, deps arrays, refs, timers, subscriptions, or memo comparisons changed. GridCell (React.memo) already computed displayValue inline in render before the PR; swapping unescapeHtml for toSingleLineDisplay at the same site changes nothing about render behavior. Clean.
  • U+2028/U+2029 (and NEL) render as line breaks under white-space: pre — Empirically false in Chromium: cell height is identical to a plain single line (17px) for these chars; only \n/\r break (and those are stripped by toSingleLineDisplay). Not a regression.
  • XSS reintroduced by removing unescapeHtml — Both grids write the value as a text node (cell.textContent / JSX {displayValue}), never innerHTML. The deleted helper was a display transform (added in fix: render HTML entities in grid cells after XSS fix #525), never a security control. The only escapeHtml use left is quick-vis.ts SlimSelect labels, untouched and still correct.
  • Copy/Markdown output re-rendered as HTML → entity or markup corruption — Every buildResultPageMarkdown/formatCellValueForCopy consumer (main Result panel, notebook actions bar, legacy grid, Cmd+C) pipes straight to copyToClipboard. No path feeds grid copy into react-markdown/the AI assistant. Clipboard is the terminal sink. All callsites SAFE.
  • Width-sampling regressionsampleColumnWidths uses formatCellValue length; newline→space is length-neutral, and display no longer shortens &amp;&, so sampled width now matches display better than before. Neutral/improvement, not a bug.
  • toSingleLineDisplay needs a non-string guardgetDisplayedCellValue provably returns a string for all non-null cell data (null is handled earlier), and every TS caller passes a string. Adding String(text) would be defensive dead-weight; dropped per the repo's minimalism.

Summary

Verdict: Approve, pending one confirmation. The change is correct, well-scoped, and well-tested. Removing unescapeHtml is a genuine fix — verified that server data arrives raw (a literal 'a&amp;b' returns a&amp;b, which the old console corrupted to a&b), and both grids render/copy via text nodes so there's no XSS surface. Display-vs-copy semantics (collapse newlines for display, preserve them for copy/export) are consistent across the React grid, the legacy grid, and the Markdown paths.

The one thing to resolve: Finding #1 — the console now depends on the paired server change (questdb#7406) for EXPLAIN rendering, and the demo/dev server this branch proxies to hasn't shipped it, so EXPLAIN plans visibly regress there. This is a documented, intentional tradeoff, but merge should be coordinated with the server release (and reviewers should be aware the local dev experience against demo will show entity-encoded plans until then).

Regressions/tradeoffs: old-server EXPLAIN rendering (Finding #1); tabs now render wider (Finding #2, likely intended).

Verification tally: 3 findings verified (1 Moderate, 2 Minor); 6 draft findings dropped as false positives (verified against source + empirical browser/curl tests). In-diff vs out-of-diff: 3 in-diff, 0 out-of-diff — Agent 12 walked all six consumer callsites (ResultGridAdapter, ResultActionsBar, ResultGridPanel/InlineResultTable, useGridKeyboardNav, legacy grid.js, quick-vis) and found every one SAFE; the changed symbols are pure display/copy utilities whose output only reaches the DOM as text or the clipboard, so a zero out-of-diff count is correct here, not an underrun.

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.

Web Console decodes HTML entities in results, corrupting displayed and copied values

1 participant