mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
8ac64a4349
## Context Adds a "Copy as Markdown" CTA for notebooks <img width="265" height="198" alt="image" src="https://github.com/user-attachments/assets/5eccf36e-24ea-4d2f-b1cf-72d5353b70a2" /> Query cell titles will be rendered as h3 tags and labelled either Postgres or Logs - Clickhouse (with time range) The query content will then be rendered as triple backticks with `sql` e.g ` ```sql...``` ` Query results will be copied to markdown if the query has been run, will otherwise be omitted Also, if the query was updated (e.g content, source, etc) after it was run (as the result is hence stale), result will also be omitted e.g: | Notebook | Markdown | | --- | --- | | <img width="1291" height="630" alt="image" src="https://github.com/user-attachments/assets/c49f23e5-c70b-46dd-a298-6e1a90cd30d7" /> | <img width="731" height="536" alt="image" src="https://github.com/user-attachments/assets/4bbe3041-bd8e-42d3-86d2-1691e7a6bc7b" /> | | <img width="1220" height="832" alt="image" src="https://github.com/user-attachments/assets/67de523a-18f7-4f1b-b764-7f0f3152f9e7" /> | <img width="757" height="803" alt="image" src="https://github.com/user-attachments/assets/d217504b-bee6-4088-9049-87ff647b9bc7" /> | <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added Markdown export for notebook queries, results, errors, and time ranges. * Added error notifications when copying notebook content fails. * **Bug Fixes** * Prevented stale query results after relevant source or time-range changes. * Improved Markdown export for queries containing backticks. * Escaped backslashes, pipes, and line breaks in Markdown tables. * **Style** * Adjusted spacing for empty query-result messages. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
173 lines
6.1 KiB
TypeScript
173 lines
6.1 KiB
TypeScript
import { untrustedSql } from '@supabase/pg-meta'
|
|
import isEqual from 'lodash/isEqual'
|
|
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_series'> & {
|
|
readonly y_series: readonly string[]
|
|
}
|
|
|
|
export const cloneChartConfig = (
|
|
chart: ReadonlyChartConfig | undefined
|
|
): ChartConfig | undefined => (chart ? { ...chart, y_series: [...chart.y_series] } : 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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether a source change makes a cell's last in-session result stale enough to clear.
|
|
* A backend change (database ↔ logs) invalidates outright, since another engine returns
|
|
* unrelated columns. A log cell's time range is a request parameter rather than part of the
|
|
* SQL text (see notebookToMarkdown), so a plain SQL-text comparison wouldn't catch a result
|
|
* that's stale only because the range moved — that has to be checked here instead.
|
|
*/
|
|
export function shouldInvalidateResultOnSourceChange(
|
|
cell: Snapshot<QueryCell>,
|
|
source: QuerySourceBinding
|
|
): boolean {
|
|
const isBackendChange = (source._tag === 'logs') !== (cell._tag === 'log_cell')
|
|
const isTimeRangeChange =
|
|
source._tag === 'logs' &&
|
|
cell._tag === 'log_cell' &&
|
|
!isEqual(source.time_range, cell.time_range)
|
|
|
|
return isBackendChange || isTimeRangeChange
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a new row limit onto a database cell. A log cell has no row limit concept, so it
|
|
* passes through unchanged.
|
|
*/
|
|
export function setCellRowLimit(cell: Snapshot<QueryCell>, rowLimit: number): QueryCell {
|
|
if (cell._tag === 'log_cell') return cloneQueryCell(cell)
|
|
|
|
return {
|
|
...copyQueryCellBase(cell),
|
|
_tag: 'database_cell',
|
|
unchecked_sql: cell.unchecked_sql,
|
|
row_limit: rowLimit,
|
|
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,
|
|
}
|
|
}
|