Files
supabase/apps/studio/data/sql/utils.ts
Joshen Lim 67d4fed40d Joshenlim/fe 4157 explorer migrate results component into explorer (#49066)
## Context

Related to Notebooks/Explorers - this one's just shifting files from the
SQLEditor into more generic folders from a file organization POV, such
that files under the Explorer folder have no dependency on files within
the SQLEditor folder

Mainly
- UtilityTabResults.utils: `getSqlErrorLines`
  - Moved into `data/sql/utils.ts`
- SQLEditor.utils: `applyAutoLimit`, `getSqlErrorLines`,
`trimTrailingSemicolons`
  - Moved into `data/sql/utils.ts`
- SQLEditor/UtilityPanel: `ResultCell`, `Results`, `CellDetailPanel`
  - Moved into `components/ui/DataGridResults`
  - Also shifted corresponding tests over here
- Also addressed some `any` type casts 

## To test
- Just need to ensure that the SQL Editor still works as expected

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

## Summary by CodeRabbit

* **New Features**
* Standardized query results across the Studio with a shared data grid.
* Improved result-table formatting, column sizing, clipboard handling,
and large-value display.
  * Added safer automatic row limits for eligible SQL queries.
  * Centralized SQL error display and formatting utilities.

* **Refactor**
  * Improved type safety for query rows and cell values.

* **Tests**
* Added comprehensive coverage for result-grid and SQL utility behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-14 11:29:39 +07:00

81 lines
3.8 KiB
TypeScript

import { literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta'
/**
* Pick which lines to render for a SQL editor error.
*
* pg-meta returns `formattedError` with multi-line ERROR/HINT/LINE output from Postgres.
* Historically only `message` was reliably populated end-to-end, which is why the UI also
* falls back to splitting `message` on newlines — e.g. the enhanced permission-denied HINT
* added by supabase/postgres#2084 arrives in the message body on some paths.
*
* Returns an empty array when the error is single-line (message only) — callers fall back to
* a plain "Error: {message}" rendering in that case.
*/
export function getSqlErrorLines(error: { message?: string; formattedError?: string }): string[] {
const formattedLines = (error.formattedError?.split('\n') ?? []).filter((x) => x.length > 0)
if (formattedLines.length > 0) return formattedLines
const messageLines = (error.message?.split('\n') ?? []).filter((x) => x.length > 0)
return messageLines.length > 1 ? messageLines : []
}
/**
* Removes trailing `;` characters from a safe SQL fragment. Only ever removes
* existing terminators — never adds text — so the result is exactly as safe
* as the input; the brand carries over intentionally. This is the one place
* in the file allowed to reassert `SafeSqlFragment` on a derived string —
* every other function composes new fragments through `safeSql`/`literal`.
*/
export function trimTrailingSemicolons(sql: SafeSqlFragment): SafeSqlFragment {
return sql.replace(/;+\s*$/, '') as SafeSqlFragment
}
// [Joshen] Just FYI as well the checks here on whether to append limit is quite restricted
// This is to prevent dashboard from accidentally appending limit to the end of a query
// thats not supposed to have any, since there's too many cases to cover.
// We can however look into making this logic better in the future
// i.e It's harder to append the limit param, than just leaving the query as it is
// Otherwise we'd need a full on parser to do this properly
//
// Only accepts `SafeSqlFragment`: this decides whether to build (and builds)
// a new SQL fragment that gets executed, so every caller — including ones
// that only want the `appendAutoLimit` flag for a display hint — must already
// hold safe SQL. Composes the ` limit N;` suffix through `safeSql`/`literal`
// rather than gluing raw template-literal text onto the fragment and casting
// the result, so the only new content this function ever stamps safe is an
// internally-generated integer literal, never arbitrary concatenated text.
export function applyAutoLimit(
sql: SafeSqlFragment,
limit: number = 0
): { sql: SafeSqlFragment; appendAutoLimit: boolean } {
// Remove lines and whitespaces to use for checking
const cleanedSql = sql.trim().replaceAll('\n', ' ').replaceAll(/\s+/g, ' ')
// Check how many queries
const regMatch = cleanedSql.matchAll(/[a-zA-Z]*[0-9]*[;]+/g)
const queries = new Array(...regMatch)
const indexSemiColon = cleanedSql.lastIndexOf(';')
const hasComments = cleanedSql.includes('--')
const hasMultipleQueries =
queries.length > 1 || (indexSemiColon > 0 && indexSemiColon !== cleanedSql.length - 1)
// Check if need to auto limit rows
const appendAutoLimit =
limit > 0 &&
!hasComments &&
!hasMultipleQueries &&
cleanedSql.toLowerCase().startsWith('select') &&
!cleanedSql.toLowerCase().match(/fetch\s+first/i) &&
!cleanedSql.match(/limit$/i) &&
!cleanedSql.match(/limit;$/i) &&
!cleanedSql.match(/limit [0-9]* offset [0-9]*\s*[;]?$/i) &&
!cleanedSql.match(/limit [0-9]*\s*[;]?$/i)
if (!appendAutoLimit) return { sql, appendAutoLimit: false }
const core = cleanedSql.endsWith(';') ? trimTrailingSemicolons(sql) : sql
const suffixed = safeSql`${core} limit ${literal(limit)};`
return { sql: suffixed, appendAutoLimit: true }
}