Files
supabase/apps/studio/components/interfaces/Explorer/QueryCell/QueryCell.utils.ts
Charis 628473b3eb refactor(studio): extract notebook query-cell logic and give log cells display settings (#49075)
Final PR of the stack. #49069, #49070, #49072 and #49074 have merged, so
this now targets `master` directly.

**Rebased onto latest `master`**, which includes the centralized
result-rendering work (#49096). See "Conflict resolution" below.

## What's left after master's own fixes

`QueryCell` was written for database cells and adapted to log cells
afterwards. Master has since fixed most of it directly:
`handleUpdateCell` no longer bails on a non-database cell, the cell's
own binding is read via `getQuerySourceBinding`, and
`database_identifier` / `time_range` propagate across a source change.

What remains:

- **`display` was only passed for database cells**, so the `view` field
on `log_cell` stayed unreachable and a logs query could never be
charted. That is the one behavioral fix left in this PR.
- The per-backend branching is inline and untested.

## What changed

Per-backend logic moves into `QueryCell.utils.ts`, where it is
unit-tested: `changeCellSource`, `setCellSql`, `cloneQueryCell`,
`getCellDisplay`, `toQueryModel`. Each narrows on the cell tag exactly
once, so the SQL brand and the backend's parameters stay correlated
rather than being re-derived at each call site. `cloneQueryCell` also
rebuilds the chart's series array, which valtio hands over as `readonly
string[]`.

`NotebookEditor` renders through `isQueryCell` (#49069) rather than a
tag switch, so a new backend gets picked up by classifying it in
`CELL_KINDS` instead of by remembering to add a `case`.

## Conflict resolution

Two rounds of master's work landed in this file set.

**`QueryCell/index.tsx` (master's own rework).** `changeCellSource`
**subsumes the four source-change branches** master had inline, each
covered by a test:

| Master's branch | Test |
|---|---|
| database → database (replica change) | `keeps the query when only the
database changes` |
| logs → logs (time-range change) | `keeps the query when only the log
time range changes` |
| database → logs | `carries the query text over when moving from the
database to logs` |
| logs → database | `carries the query text over and restores a default
row limit …` |

Two improvements fall out of consolidating them:

- A **logs → database** move now keeps the selected replica; pinned by
`applies the selected database when moving from logs to the database`.
- The row-limit default is **named** rather than a hard-coded `100`.
`Explorer/utils.ts` now shares `DEFAULT_CELL_ROW_LIMIT` with
`createQueryCellSkeleton`, so cell creation and backend conversion can't
drift.

Untouched from master: `snap.updateCell`, `AddCellDropdown`,
`MoveCellDropdownContent`, the `SortableSection` grip props, and
`NotebookEditor`'s add-cell buttons, skeletons, `reorderCells` and
`insertCellAfter`.

**Centralized result rendering (#49096).** That PR moved
`QueryCell/QueryResultChart.tsx` up to `Explorer/`, split
`QueryResultTable` into `QueryResultError`, and added
`QueryResultRenderer`. Since this PR removes `QueryChartConfig`, the
type swap had to follow the move and also reach `QueryResultRenderer`,
which is new and referenced the removed type. `QueryResultRenderer`,
`QueryResultError` and `DataGridResults` are otherwise untouched — the
empty/error-state centralization is fully preserved, and `QueryEditor`
still renders through it.

## Behavior worth a second opinion

`changeCellSource` **carries the query text across a backend change**
and rebrands it. This is probably not what a user wants — Postgres SQL
and logs SQL are separate dialects over separate schemas, so a
carried-over query will usually fail to run, and the rebrand asserts a
dialect the text was never written in.

Keeping it for now because it destroys nothing and needs no confirmation
prompt. The tradeoff is written up at the function. Worth revisiting
once we know whether people switch source to port an existing query or
to start a fresh one — if it's the latter, clearing the body behind a
confirmation is the better answer.

Results *are* dropped on a backend change, since another engine returns
unrelated columns.

## Incidental

`Explorer/types.ts` drops `QueryChartConfig`, which duplicated the wire
schema's `ChartConfig` field for field. `chart` stays persisted
alongside `view`, so switching to the table and back returns the user's
chart settings rather than rebuilding them.

## Verification

Typecheck, Prettier, and the lint ratchet clean. 1013 tests pass across
`state/`, the Explorer surfaces, notebooks, query sources, `data/sql`,
the SQL editor, and `components/ui`; 13 of them are new coverage for the
extracted helpers.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Improved notebook cell rendering with more consistent handling of
query and markdown cells.
- Query cells now preserve SQL, source settings, display preferences,
chart configuration, and query results when edited or switched between
sources.
  - Added a default limit of 100 rows for applicable database queries.

- **Bug Fixes**
- Prevented stale query results from carrying over when changing query
sources.
- Improved chart configuration consistency across query results and
display settings.

- **Tests**
- Added comprehensive coverage for query-cell updates, source
transitions, SQL changes, display state, and chart data.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 09:42:18 -04:00

136 lines
4.8 KiB
TypeScript

import { untrustedSql } from '@supabase/pg-meta'
import { type Snapshot } from 'valtio'
import { type ExplorerQueryModel } from '../QueryEditor'
import { type QueryDisplay } from '../types'
import { type ChartConfig, type QueryCell } from '@/data/content/notebooks/notebook-schema'
import { untrustedLogSql } from '@/data/logs/safe-analytics-sql'
import {
getQuerySourceBinding,
type QuerySourceBinding,
} from '@/data/query-sources/query-source-registry'
/** Row limit a database cell starts with when it has no saved one to carry over. */
export const DEFAULT_CELL_ROW_LIMIT = 100
/**
* Valtio snapshots are deep-readonly. Readonly properties assign to mutable ones, so only
* the array needs rebuilding to turn a snapshot's chart back into a writable config.
*/
type ReadonlyChartConfig = Omit<ChartConfig, 'y_columns'> & {
readonly y_columns: readonly string[]
}
export const cloneChartConfig = (
chart: ReadonlyChartConfig | undefined
): ChartConfig | undefined => (chart ? { ...chart, y_columns: [...chart.y_columns] } : undefined)
/** The display state a query cell hands the shared editor. */
// `view` is already defaulted to 'table' by the domain transform, so there is nothing to
// fall back to here — only the chart needs copying out of the snapshot.
export const getCellDisplay = (cell: Snapshot<QueryCell>): QueryDisplay => ({
view: cell.view,
chart: cloneChartConfig(cell.chart),
})
/** Fields every query cell carries, copied out of a snapshot so the result is writable. */
const copyQueryCellBase = (cell: Snapshot<QueryCell>) => ({
id: cell.id,
title: cell.title,
view: cell.view,
chart: cloneChartConfig(cell.chart),
})
/** A writable copy of a query cell, preserving its backend and every backend-specific field. */
export const cloneQueryCell = (cell: Snapshot<QueryCell>): QueryCell =>
cell._tag === 'log_cell'
? {
...copyQueryCellBase(cell),
_tag: 'log_cell',
unchecked_sql: cell.unchecked_sql,
time_range: cell.time_range,
}
: {
...copyQueryCellBase(cell),
_tag: 'database_cell',
unchecked_sql: cell.unchecked_sql,
row_limit: cell.row_limit,
database_identifier: cell.database_identifier,
}
/**
* Applies a source binding to a query cell, carrying the query text across unchanged and
* rebranding it for the new backend's dialect.
*
* NOTE — carrying the text over is very likely not what a user wants when the backend
* actually changes. Postgres SQL and logs SQL are separate dialects over separate schemas,
* so a carried-over query will almost always fail to run, and the rebrand asserts a
* dialect the text was never written in. We keep it for now because it is the
* least-destructive option and needs no confirmation prompt; revisit once we know whether
* people switch source to port an existing query or to start a fresh one, at which point
* clearing the body (behind a confirmation) is the likely answer.
*/
export function changeCellSource(cell: Snapshot<QueryCell>, source: QuerySourceBinding): QueryCell {
const base = copyQueryCellBase(cell)
if (source._tag === 'logs') {
return {
...base,
_tag: 'log_cell',
unchecked_sql: untrustedLogSql(cell.unchecked_sql),
time_range: source.time_range,
}
}
return {
...base,
_tag: 'database_cell',
unchecked_sql: untrustedSql(cell.unchecked_sql),
row_limit: cell._tag === 'database_cell' ? cell.row_limit : DEFAULT_CELL_ROW_LIMIT,
database_identifier: source.database_identifier,
}
}
/**
* Writes the editor's text back onto a cell, branded for that cell's dialect. Separate
* from `cloneQueryCell` so the brand stays correlated with the cell tag in one narrowing
* rather than being re-derived at each call site.
*/
export function setCellSql(cell: Snapshot<QueryCell>, sql: string): QueryCell {
const base = copyQueryCellBase(cell)
if (cell._tag === 'log_cell') {
return {
...base,
_tag: 'log_cell',
unchecked_sql: untrustedLogSql(sql),
time_range: cell.time_range,
}
}
return {
...base,
_tag: 'database_cell',
unchecked_sql: untrustedSql(sql),
row_limit: cell.row_limit,
database_identifier: cell.database_identifier,
}
}
/**
* Builds the editor's query model from a cell and the editor's live text buffer. Branding
* the buffer is the editor boundary the safe-SQL model expects; which brand applies is
* decided by the cell's tag, so the dialect can't drift from the cell it belongs to.
*/
export function toQueryModel(cell: Snapshot<QueryCell>, sql: string): ExplorerQueryModel {
if (cell._tag === 'log_cell') {
return { ...getQuerySourceBinding(cell), uncheckedSql: untrustedLogSql(sql) }
}
return {
...getQuerySourceBinding(cell),
uncheckedSql: untrustedSql(sql),
rowLimit: cell.row_limit,
}
}