mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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 -->
This commit is contained in:
1 parent
5627d01183
commit
67d4fed40d
22 files changed
+560
-535
No files matched your search
@@ -283,7 +283,7 @@ export function useSyncTableEditorStateFromLocalStorageWithUrl({
|
||||
}, [urlParams, table, projectRef])
|
||||
}
|
||||
|
||||
export const handleCellKeyDown = <TRow extends SupaRow = SupaRow>(
|
||||
export const handleCellKeyDown = <TRow extends Record<string, unknown> = SupaRow>(
|
||||
args: CellKeyDownArgs<TRow, unknown>,
|
||||
event: CellKeyboardEvent,
|
||||
context?: {
|
||||
|
||||
@@ -24,7 +24,6 @@ import { DisplaySettingsButton } from './QueryCell/DisplaySettingsButton'
|
||||
import { QueryResultChart } from './QueryCell/QueryResultChart'
|
||||
import { QueryResultTable } from './QueryResultTable'
|
||||
import { type QueryDisplay, type QueryResult } from './types'
|
||||
import { applyAutoLimit } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { isValidConnString } from '@/data/fetchers'
|
||||
import { useExecuteLogsSqlMutation } from '@/data/logs/execute-logs-sql-mutation'
|
||||
@@ -36,6 +35,7 @@ import {
|
||||
} from '@/data/query-sources/query-source-registry'
|
||||
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { applyAutoLimit } from '@/data/sql/utils'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
|
||||
|
||||
@@ -4,13 +4,13 @@ import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { subscriptionHasHipaaAddon } from '../Billing/Subscription/Subscription.utils'
|
||||
import { Results } from '../SQLEditor/UtilityPanel/Results'
|
||||
import { getSqlErrorLines } from '../SQLEditor/UtilityPanel/UtilityTabResults.utils'
|
||||
import { type QueryResult } from './types'
|
||||
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { DataGridResults } from '@/components/ui/DataGridResults'
|
||||
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { getSqlErrorLines } from '@/data/sql/utils'
|
||||
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
@@ -186,5 +186,5 @@ const QueryError = ({
|
||||
|
||||
// [Joshen] Eventually migrate the Results component here from SQL Editor
|
||||
const QueryResults = ({ rows }: { rows: NonNullable<QueryResult['rows']> }) => {
|
||||
return <Results rows={rows} />
|
||||
return <DataGridResults rows={rows} />
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { applyAutoLimit } from '../../SQLEditor/SQLEditor.utils'
|
||||
import { BURSTABLE_IO_METRIC_KEYS, DEPRECATED_REPORTS } from '../Reports.constants'
|
||||
import { ChartBlock } from './ChartBlock'
|
||||
import { DeprecatedChartBlock } from './DeprecatedChartBlock'
|
||||
@@ -20,6 +19,7 @@ import { useContentIdQuery } from '@/data/content/content-id-query'
|
||||
import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query'
|
||||
import { executeSql } from '@/data/sql/execute-sql-mutation'
|
||||
import { sqlKeys } from '@/data/sql/keys'
|
||||
import { applyAutoLimit } from '@/data/sql/utils'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
||||
import type { Dashboards, SqlSnippets } from '@/types'
|
||||
|
||||
@@ -7,7 +7,6 @@ import { DiffType, type IStandaloneCodeEditor } from './SQLEditor.types'
|
||||
import {
|
||||
analyzeQueryIssues,
|
||||
appendEnableRLSStatements,
|
||||
applyAutoLimit,
|
||||
assembleCompletionDiff,
|
||||
buildCompletionRequestBody,
|
||||
buildDebugChatArgs,
|
||||
@@ -30,7 +29,6 @@ import {
|
||||
resolveDiffKeyAction,
|
||||
shouldAutoGenerateTitle,
|
||||
sqlSourceToDialect,
|
||||
trimTrailingSemicolons,
|
||||
} from './SQLEditor.utils'
|
||||
import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query'
|
||||
import type { Database } from '@/data/read-replicas/replicas-query'
|
||||
@@ -49,166 +47,6 @@ const buildTrigger = (overrides: Partial<DatabaseEventTrigger> = {}): DatabaseEv
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('SQLEditor.utils.ts:trimTrailingSemicolons', () => {
|
||||
test('removes a single trailing semicolon', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('removes multiple trailing semicolons', () => {
|
||||
const sql = safeSql`select * from countries;;;;;;;`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('leaves a fragment with no trailing semicolon unchanged', () => {
|
||||
const sql = safeSql`select * from countries`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('does not touch semicolons that are not trailing', () => {
|
||||
const sql = safeSql`select 1; select 2`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SQLEditor.utils.ts:applyAutoLimit', () => {
|
||||
test('Should return false if limit passed is <= 0', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = -1
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return true if limit passed is > 0', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(true)
|
||||
})
|
||||
test('Should return false if query already has a limit', () => {
|
||||
const sql = safeSql`select * from countries limit 10;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit (check for case-insensitiveness)', () => {
|
||||
const sql = safeSql`SELECT * FROM countries LIMIT 10;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 ;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset', () => {
|
||||
const sql = safeSql`select * from countries limit 10 offset 0;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 offset 0 ;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => {
|
||||
const sql = safeSql`select * from countries offset 0 limit 1;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit, even if no value provided for limit', () => {
|
||||
const sql = safeSql`select * from countries limit`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `FETCH FIRST` instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `fetch first` instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries fetch first 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query is not a select statement', () => {
|
||||
const sql = safeSql`create table test (id int8 primary key, name varchar);`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if there are multiple queries I', () => {
|
||||
const sql1 = safeSql`select * from countries;
|
||||
select * from cities;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if there are multiple queries II', () => {
|
||||
const sql1 = safeSql`select * from countries;
|
||||
select * from cities`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
// [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm
|
||||
test('Should return false if query has with a comment I', () => {
|
||||
const sql = safeSql`-- This is a comment
|
||||
select * from cities`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query has with a comment II', () => {
|
||||
const sql = safeSql`select * from cities
|
||||
-- This is a comment`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
|
||||
// [Joshen] These will just need to test the cases when appendAutoLimit returns true then
|
||||
test('Should add the limit param properly if query ends without a semi colon', () => {
|
||||
const sql = safeSql`select * from countries`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should add the limit param properly if query ends with a semi colon', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should add the limit param properly if query ends with multiple semi colon', () => {
|
||||
const sql = safeSql`select * from countries;;;;;;;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should not append a limit if query already has one with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 ;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 10 ;')
|
||||
})
|
||||
test('returns the SafeSqlFragment result unchanged when no limit is appended', () => {
|
||||
const sql = safeSql`select * from countries limit 10;`
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, 100)
|
||||
expect(formattedSql).toBe(sql)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SQLEditor.utils.ts:shouldAutoGenerateTitle', () => {
|
||||
test('returns true when AI is enabled, the name is still the placeholder, and on platform', () => {
|
||||
expect(
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
literal,
|
||||
safeSql,
|
||||
untrustedSql,
|
||||
type SafeSqlFragment,
|
||||
type UntrustedSqlFragment,
|
||||
} from '@supabase/pg-meta'
|
||||
import { untrustedSql, type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta'
|
||||
import { TABLE_EVENT_ACTIONS } from 'common/telemetry-constants'
|
||||
|
||||
import { isLogsSource, sqlSourceToFenceLanguage, type SqlSnippetSource } from './querySource'
|
||||
@@ -26,6 +20,7 @@ import type { SnippetWithContent } from '@/data/content/sql-folders-query'
|
||||
import type { DatabaseEventTrigger } from '@/data/database-event-triggers/database-event-triggers-query'
|
||||
import { untrustedLogSql, type UntrustedLogSqlFragment } from '@/data/logs/safe-analytics-sql'
|
||||
import type { Database } from '@/data/read-replicas/replicas-query'
|
||||
import { applyAutoLimit } from '@/data/sql/utils'
|
||||
import { generateUuid } from '@/lib/api/snippets.browser'
|
||||
import { removeCommentsFromSql } from '@/lib/helpers'
|
||||
import { wrapWithRoleImpersonation } from '@/lib/role-impersonation'
|
||||
@@ -384,66 +379,6 @@ export const compareAsNewSnippet = (sqlDiff: ContentDiff) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the SQL to act on from the editor: the current selection if there is
|
||||
* one, otherwise the full editor contents, falling back to the snippet's stored
|
||||
|
||||
@@ -1,100 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
calculateResultColumnWidth,
|
||||
convertResultsToCSV,
|
||||
convertResultsToJSON,
|
||||
convertResultsToMarkdown,
|
||||
formatCellValue,
|
||||
formatClipboardValue,
|
||||
formatResults,
|
||||
getResultsHeaders,
|
||||
isLargeValue,
|
||||
} from './Results.utils'
|
||||
|
||||
describe('Results.utils', () => {
|
||||
describe('calculateResultColumnWidth', () => {
|
||||
it('uses the minimum width when the column name and values are short', () => {
|
||||
expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100)
|
||||
})
|
||||
|
||||
it('accounts for a column name that is longer than its values', () => {
|
||||
expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe(
|
||||
148.5
|
||||
)
|
||||
})
|
||||
|
||||
it('accounts for a value that is longer than the column name', () => {
|
||||
expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165)
|
||||
})
|
||||
|
||||
it('accounts for the formatted JSON representation of an object value', () => {
|
||||
expect(
|
||||
calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }])
|
||||
).toBe(288.75)
|
||||
})
|
||||
|
||||
it('accounts for the formatted JSON representation of an array value', () => {
|
||||
expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe(
|
||||
222.75
|
||||
)
|
||||
})
|
||||
|
||||
it('caps the width when the column name exceeds the maximum', () => {
|
||||
expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500)
|
||||
})
|
||||
|
||||
it('caps the width when a value exceeds the maximum', () => {
|
||||
expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500)
|
||||
})
|
||||
|
||||
it('uses the minimum width when there are no rows', () => {
|
||||
expect(calculateResultColumnWidth('id', [])).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatClipboardValue', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatClipboardValue(null)).toBe('')
|
||||
})
|
||||
|
||||
it('stringifies objects', () => {
|
||||
expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('stringifies arrays', () => {
|
||||
expect(formatClipboardValue([1, 2])).toBe('[1,2]')
|
||||
})
|
||||
|
||||
it('converts primitives to string', () => {
|
||||
expect(formatClipboardValue('hello')).toBe('hello')
|
||||
expect(formatClipboardValue(42)).toBe('42')
|
||||
expect(formatClipboardValue(false)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCellValue', () => {
|
||||
it('returns NULL for null', () => {
|
||||
expect(formatCellValue(null)).toBe('NULL')
|
||||
})
|
||||
|
||||
it('returns strings as-is', () => {
|
||||
expect(formatCellValue('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('stringifies objects', () => {
|
||||
expect(formatCellValue({ a: 1 })).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('stringifies numbers', () => {
|
||||
expect(formatCellValue(42)).toBe('42')
|
||||
})
|
||||
|
||||
it('stringifies booleans', () => {
|
||||
expect(formatCellValue(true)).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatResults', () => {
|
||||
it('should stringify object values', () => {
|
||||
const results = [{ id: 1, data: { nested: true } }]
|
||||
@@ -188,52 +102,6 @@ describe('Results.utils', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLargeValue', () => {
|
||||
it('returns false for null', () => {
|
||||
expect(isLargeValue(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isLargeValue(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an empty string', () => {
|
||||
expect(isLargeValue('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a short string under the threshold', () => {
|
||||
expect(isLargeValue('hello')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a string at the 60-char boundary', () => {
|
||||
expect(isLargeValue('a'.repeat(60))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a string just over the 60-char threshold', () => {
|
||||
expect(isLargeValue('a'.repeat(61))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a short string containing a newline', () => {
|
||||
expect(isLargeValue('hello\nworld')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an object', () => {
|
||||
expect(isLargeValue({ a: 1 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an array', () => {
|
||||
expect(isLargeValue([1, 2, 3])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a number', () => {
|
||||
expect(isLargeValue(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a boolean', () => {
|
||||
expect(isLargeValue(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('convertResultsToCSV', () => {
|
||||
it('should return undefined for empty results', () => {
|
||||
expect(convertResultsToCSV([])).toBeUndefined()
|
||||
|
||||
@@ -3,45 +3,6 @@ import Papa from 'papaparse'
|
||||
|
||||
type ResultRow = Record<string, unknown>
|
||||
|
||||
const ESTIMATED_CHARACTER_WIDTH = 8.25
|
||||
export const RESULT_COLUMN_MIN_WIDTH = 100
|
||||
const MAX_COLUMN_WIDTH = 500
|
||||
|
||||
export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) {
|
||||
const maxContentLength = rows.reduce(
|
||||
(maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length),
|
||||
columnName.length
|
||||
)
|
||||
|
||||
return Math.min(
|
||||
Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH),
|
||||
MAX_COLUMN_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
export function formatClipboardValue(value: unknown) {
|
||||
if (value === null) return ''
|
||||
if (typeof value == 'object' || Array.isArray(value)) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function formatCellValue(value: unknown) {
|
||||
if (value === null) return 'NULL'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
const LARGE_VALUE_CHAR_THRESHOLD = 60
|
||||
|
||||
export function isLargeValue(value: unknown) {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'object') return true
|
||||
const str = String(value)
|
||||
return str.length > LARGE_VALUE_CHAR_THRESHOLD || str.includes('\n')
|
||||
}
|
||||
|
||||
export function formatResults(
|
||||
results: ResultRow[]
|
||||
): Record<string, string | number | boolean | null | undefined>[] {
|
||||
|
||||
@@ -4,13 +4,13 @@ import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { forwardRef } from 'react'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { Results } from './Results'
|
||||
import { getSqlErrorLines } from './UtilityTabResults.utils'
|
||||
import { subscriptionHasHipaaAddon } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
|
||||
import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
import { DataGridResults } from '@/components/ui/DataGridResults'
|
||||
import { InlineLink, InlineLinkClassName } from '@/components/ui/InlineLink'
|
||||
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
|
||||
import { getSqlErrorLines } from '@/data/sql/utils'
|
||||
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
@@ -185,7 +185,7 @@ export const UtilityTabResults = forwardRef<HTMLDivElement, UtilityTabResultsPro
|
||||
)
|
||||
}
|
||||
|
||||
return <Results rows={result.rows} />
|
||||
return <DataGridResults rows={result.rows} />
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getSqlErrorLines } from './UtilityTabResults.utils'
|
||||
|
||||
describe('getSqlErrorLines', () => {
|
||||
it('returns formattedError lines when present', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'permission denied for table users',
|
||||
formattedError:
|
||||
'ERROR: 42501: permission denied for table users\n' +
|
||||
'HINT: To grant access to anon on a specific table:\n' +
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
'ERROR: 42501: permission denied for table users',
|
||||
'HINT: To grant access to anon on a specific table:',
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
])
|
||||
})
|
||||
|
||||
it('strips empty lines from formattedError', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
formattedError: 'ERROR: boom\n\nHINT: retry\n',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['ERROR: boom', 'HINT: retry'])
|
||||
})
|
||||
|
||||
it('falls back to message lines when formattedError is missing and message is multi-line', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message:
|
||||
'ERROR: 42501: permission denied for table users\n' +
|
||||
'HINT: To grant access to anon on a specific table:\n' +
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
'ERROR: 42501: permission denied for table users',
|
||||
'HINT: To grant access to anon on a specific table:',
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for a single-line message so callers render the fallback', () => {
|
||||
const lines = getSqlErrorLines({ message: 'permission denied for table users' })
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when both fields are missing', () => {
|
||||
expect(getSqlErrorLines({})).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when message is an empty string', () => {
|
||||
expect(getSqlErrorLines({ message: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when message only contains whitespace newlines', () => {
|
||||
// Only empty segments after filtering — treated as single-line
|
||||
expect(getSqlErrorLines({ message: '\n\n' })).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers formattedError even when message is also multi-line', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'message line 1\nmessage line 2',
|
||||
formattedError: 'formatted line 1\nformatted line 2',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['formatted line 1', 'formatted line 2'])
|
||||
})
|
||||
|
||||
it('falls through to message when formattedError is empty string', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'ERROR: line 1\nHINT: line 2',
|
||||
formattedError: '',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2'])
|
||||
})
|
||||
})
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 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 : []
|
||||
}
|
||||
+1
-1
@@ -8,7 +8,7 @@ import { TwoOptionToggle } from '@/components/ui/TwoOptionToggle'
|
||||
|
||||
interface CellDetailPanelProps {
|
||||
column: string
|
||||
value: any
|
||||
value: unknown
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export type ResultRow = Record<string, unknown>
|
||||
|
||||
const ESTIMATED_CHARACTER_WIDTH = 8.25
|
||||
export const RESULT_COLUMN_MIN_WIDTH = 100
|
||||
const MAX_COLUMN_WIDTH = 500
|
||||
|
||||
export function calculateResultColumnWidth(columnName: string, rows: readonly ResultRow[]) {
|
||||
const maxContentLength = rows.reduce(
|
||||
(maxLength, row) => Math.max(maxLength, (formatCellValue(row[columnName]) ?? '').length),
|
||||
columnName.length
|
||||
)
|
||||
|
||||
return Math.min(
|
||||
Math.max(maxContentLength * ESTIMATED_CHARACTER_WIDTH, RESULT_COLUMN_MIN_WIDTH),
|
||||
MAX_COLUMN_WIDTH
|
||||
)
|
||||
}
|
||||
|
||||
export function formatClipboardValue(value: unknown) {
|
||||
if (value === null) return ''
|
||||
if (typeof value == 'object' || Array.isArray(value)) {
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function formatCellValue(value: unknown) {
|
||||
if (value === null) return 'NULL'
|
||||
if (typeof value === 'string') return value
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
const LARGE_VALUE_CHAR_THRESHOLD = 60
|
||||
|
||||
export function isLargeValue(value: unknown) {
|
||||
if (value === null || value === undefined) return false
|
||||
if (typeof value === 'object') return true
|
||||
const str = String(value)
|
||||
return str.length > LARGE_VALUE_CHAR_THRESHOLD || str.includes('\n')
|
||||
}
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { Expand } from 'lucide-react'
|
||||
import { Button, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { formatCellValue, isLargeValue } from './Results.utils'
|
||||
import { formatCellValue, isLargeValue } from './DataGridResults.utils'
|
||||
|
||||
interface ResultCellProps {
|
||||
column: string
|
||||
+31
-8
@@ -1,7 +1,10 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import { type ComponentProps } from 'react'
|
||||
import { type CalculatedColumn } from 'react-data-grid'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
|
||||
import { type ResultRow } from '../DataGridResults.utils'
|
||||
import { DataGridResults as Results } from '../index'
|
||||
import { customRender as render } from '@/tests/lib/custom-render'
|
||||
|
||||
let contextMenuMountCount = 0
|
||||
@@ -10,7 +13,7 @@ vi.mock('ui', async () => {
|
||||
const actual = await vi.importActual<typeof import('ui')>('ui')
|
||||
return {
|
||||
...actual,
|
||||
ContextMenu: (props: any) => {
|
||||
ContextMenu: (props: ComponentProps<typeof actual.ContextMenu>) => {
|
||||
contextMenuMountCount++
|
||||
return <actual.ContextMenu {...props} />
|
||||
},
|
||||
@@ -18,20 +21,40 @@ vi.mock('ui', async () => {
|
||||
})
|
||||
|
||||
vi.mock('react-data-grid', () => ({
|
||||
default: ({ columns, rows }: any) => (
|
||||
default: ({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: CalculatedColumn<ResultRow>[]
|
||||
rows: readonly ResultRow[]
|
||||
}) => (
|
||||
<div role="table">
|
||||
<div role="row">
|
||||
{columns.map((col: any, colIdx: number) => (
|
||||
{columns.map((col, colIdx) => (
|
||||
<div key={colIdx} role="columnheader">
|
||||
{col.renderHeaderCell ? col.renderHeaderCell({}) : col.name}
|
||||
{col.renderHeaderCell
|
||||
? col.renderHeaderCell({
|
||||
column: col,
|
||||
sortDirection: undefined,
|
||||
priority: undefined,
|
||||
tabIndex: -1,
|
||||
})
|
||||
: col.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row: any, rowIdx: number) => (
|
||||
{rows.map((row, rowIdx) => (
|
||||
<div key={rowIdx} role="row">
|
||||
{columns.map((col: any, colIdx: number) => (
|
||||
{columns.map((col, colIdx) => (
|
||||
<div key={colIdx} role="cell">
|
||||
{col.renderCell?.({ row, rowIdx, isCellSelected: false })}
|
||||
{col.renderCell?.({
|
||||
column: col,
|
||||
row,
|
||||
rowIdx,
|
||||
isCellEditable: false,
|
||||
tabIndex: -1,
|
||||
onRowChange: () => {},
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
calculateResultColumnWidth,
|
||||
formatCellValue,
|
||||
formatClipboardValue,
|
||||
isLargeValue,
|
||||
} from '../DataGridResults.utils'
|
||||
|
||||
describe('Results.utils', () => {
|
||||
describe('calculateResultColumnWidth', () => {
|
||||
it('uses the minimum width when the column name and values are short', () => {
|
||||
expect(calculateResultColumnWidth('id', [{ id: 1 }])).toBe(100)
|
||||
})
|
||||
|
||||
it('accounts for a column name that is longer than its values', () => {
|
||||
expect(calculateResultColumnWidth('source_campaign_id', [{ source_campaign_id: null }])).toBe(
|
||||
148.5
|
||||
)
|
||||
})
|
||||
|
||||
it('accounts for a value that is longer than the column name', () => {
|
||||
expect(calculateResultColumnWidth('name', [{ name: 'a'.repeat(20) }])).toBe(165)
|
||||
})
|
||||
|
||||
it('accounts for the formatted JSON representation of an object value', () => {
|
||||
expect(
|
||||
calculateResultColumnWidth('metadata', [{ metadata: { campaign: 'a'.repeat(20) } }])
|
||||
).toBe(288.75)
|
||||
})
|
||||
|
||||
it('accounts for the formatted JSON representation of an array value', () => {
|
||||
expect(calculateResultColumnWidth('tags', [{ tags: ['a'.repeat(10), 'b'.repeat(10)] }])).toBe(
|
||||
222.75
|
||||
)
|
||||
})
|
||||
|
||||
it('caps the width when the column name exceeds the maximum', () => {
|
||||
expect(calculateResultColumnWidth('a'.repeat(100), [])).toBe(500)
|
||||
})
|
||||
|
||||
it('caps the width when a value exceeds the maximum', () => {
|
||||
expect(calculateResultColumnWidth('value', [{ value: 'a'.repeat(100) }])).toBe(500)
|
||||
})
|
||||
|
||||
it('uses the minimum width when there are no rows', () => {
|
||||
expect(calculateResultColumnWidth('id', [])).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatClipboardValue', () => {
|
||||
it('returns empty string for null', () => {
|
||||
expect(formatClipboardValue(null)).toBe('')
|
||||
})
|
||||
|
||||
it('stringifies objects', () => {
|
||||
expect(formatClipboardValue({ a: 1 })).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('stringifies arrays', () => {
|
||||
expect(formatClipboardValue([1, 2])).toBe('[1,2]')
|
||||
})
|
||||
|
||||
it('converts primitives to string', () => {
|
||||
expect(formatClipboardValue('hello')).toBe('hello')
|
||||
expect(formatClipboardValue(42)).toBe('42')
|
||||
expect(formatClipboardValue(false)).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatCellValue', () => {
|
||||
it('returns NULL for null', () => {
|
||||
expect(formatCellValue(null)).toBe('NULL')
|
||||
})
|
||||
|
||||
it('returns strings as-is', () => {
|
||||
expect(formatCellValue('hello')).toBe('hello')
|
||||
})
|
||||
|
||||
it('stringifies objects', () => {
|
||||
expect(formatCellValue({ a: 1 })).toBe('{"a":1}')
|
||||
})
|
||||
|
||||
it('stringifies numbers', () => {
|
||||
expect(formatCellValue(42)).toBe('42')
|
||||
})
|
||||
|
||||
it('stringifies booleans', () => {
|
||||
expect(formatCellValue(true)).toBe('true')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLargeValue', () => {
|
||||
it('returns false for null', () => {
|
||||
expect(isLargeValue(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isLargeValue(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for an empty string', () => {
|
||||
expect(isLargeValue('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a short string under the threshold', () => {
|
||||
expect(isLargeValue('hello')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a string at the 60-char boundary', () => {
|
||||
expect(isLargeValue('a'.repeat(60))).toBe(false)
|
||||
})
|
||||
|
||||
it('returns true for a string just over the 60-char threshold', () => {
|
||||
expect(isLargeValue('a'.repeat(61))).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for a short string containing a newline', () => {
|
||||
expect(isLargeValue('hello\nworld')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an object', () => {
|
||||
expect(isLargeValue({ a: 1 })).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for an array', () => {
|
||||
expect(isLargeValue([1, 2, 3])).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for a number', () => {
|
||||
expect(isLargeValue(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a boolean', () => {
|
||||
expect(isLargeValue(true)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { fireEvent, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { expect, test, vi } from 'vitest'
|
||||
|
||||
import { ResultCell } from '@/components/interfaces/SQLEditor/UtilityPanel/ResultCell'
|
||||
import { ResultCell } from '../ResultCell'
|
||||
import { customRender as render } from '@/tests/lib/custom-render'
|
||||
|
||||
const noop = () => {}
|
||||
+10
-9
@@ -1,6 +1,6 @@
|
||||
import { Copy, Expand } from 'lucide-react'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import DataGrid, { CalculatedColumn } from 'react-data-grid'
|
||||
import DataGrid, { CalculatedColumn, RenderCellProps } from 'react-data-grid'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@@ -10,20 +10,21 @@ import {
|
||||
} from 'ui'
|
||||
|
||||
import { CellDetailPanel } from './CellDetailPanel'
|
||||
import { ResultCell } from './ResultCell'
|
||||
import {
|
||||
calculateResultColumnWidth,
|
||||
formatClipboardValue,
|
||||
RESULT_COLUMN_MIN_WIDTH,
|
||||
} from './Results.utils'
|
||||
type ResultRow,
|
||||
} from './DataGridResults.utils'
|
||||
import { ResultCell } from './ResultCell'
|
||||
import { handleCellKeyDown } from '@/components/grid/SupabaseGrid.utils'
|
||||
|
||||
export const Results = ({ rows }: { rows: readonly any[] }) => {
|
||||
const [expandedCell, setExpandedCell] = useState<{ column: string; value: any } | null>(null)
|
||||
const contextMenuCellRef = useRef<{ column: string; value: any } | null>(null)
|
||||
export const DataGridResults = ({ rows }: { rows: readonly ResultRow[] }) => {
|
||||
const [expandedCell, setExpandedCell] = useState<{ column: string; value: unknown } | null>(null)
|
||||
const contextMenuCellRef = useRef<{ column: string; value: unknown } | null>(null)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, column: string, value: any) => {
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent, column: string, value: unknown) => {
|
||||
contextMenuCellRef.current = { column, value }
|
||||
|
||||
if (triggerRef.current) {
|
||||
@@ -45,7 +46,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => {
|
||||
return <div className="flex h-full items-center justify-center font-mono text-xs">{name}</div>
|
||||
}
|
||||
|
||||
const columns: CalculatedColumn<any>[] = useMemo(
|
||||
const columns: CalculatedColumn<ResultRow>[] = useMemo(
|
||||
() =>
|
||||
Object.keys(rows?.[0] ?? []).map((key, idx) => {
|
||||
return {
|
||||
@@ -62,7 +63,7 @@ export const Results = ({ rows }: { rows: readonly any[] }) => {
|
||||
frozen: false,
|
||||
sortable: false,
|
||||
isLastFrozenColumn: false,
|
||||
renderCell: ({ row }: { row: any }) => (
|
||||
renderCell: ({ row }: RenderCellProps<ResultRow>) => (
|
||||
<ResultCell
|
||||
column={key}
|
||||
value={row[key]}
|
||||
@@ -40,17 +40,14 @@ import { CodeBlock } from 'ui-patterns/CodeBlock'
|
||||
import { containsUnknownFunction, isReadOnlySelect } from '../AIAssistantPanel/AIAssistant.utils'
|
||||
import { AIEditor } from '../AIEditor'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { DataGridResults } from '../DataGridResults'
|
||||
import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
|
||||
import { formatSqlError } from './EditorPanel.utils'
|
||||
import { SaveSnippetDialog } from './SaveSnippetDialog'
|
||||
import { isExplainQuery } from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.utils'
|
||||
import { generateSnippetTitle } from '@/components/interfaces/SQLEditor/SQLEditor.constants'
|
||||
import {
|
||||
applyAutoLimit,
|
||||
createSqlSnippetSkeletonV2,
|
||||
} from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import { createSqlSnippetSkeletonV2 } from '@/components/interfaces/SQLEditor/SQLEditor.utils'
|
||||
import { useAddDefinitions } from '@/components/interfaces/SQLEditor/useAddDefinitions'
|
||||
import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
|
||||
import { SqlRunButton } from '@/components/interfaces/SQLEditor/UtilityPanel/RunButton'
|
||||
import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
|
||||
import { useContentIdQuery } from '@/data/content/content-id-query'
|
||||
@@ -58,6 +55,7 @@ import { useContentQuery, type Content } from '@/data/content/content-query'
|
||||
import { useContentUpsertMutation } from '@/data/content/content-upsert-mutation'
|
||||
import { contentKeys } from '@/data/content/keys'
|
||||
import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation'
|
||||
import { applyAutoLimit } from '@/data/sql/utils'
|
||||
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
@@ -565,7 +563,7 @@ export const EditorPanel = () => {
|
||||
>
|
||||
{showResults && (
|
||||
<div className="border-t flex-1 overflow-hidden">
|
||||
<Results rows={results} />
|
||||
<DataGridResults rows={results} />
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-foreground-light border-t py-2 px-5 flex items-center justify-between">
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { ButtonTooltip } from '../ButtonTooltip'
|
||||
import { CHART_COLORS } from '../Charts/Charts.constants'
|
||||
import { PortalChartTooltip } from '../Charts/PortalChartTooltip'
|
||||
import { DataGridResults } from '../DataGridResults'
|
||||
import { SqlWarningAdmonition } from '../SqlWarningAdmonition'
|
||||
import { BlockViewConfiguration } from './BlockViewConfiguration'
|
||||
import { EditQueryButton } from './EditQueryButton'
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
} from './QueryBlock.utils'
|
||||
import { ReportBlockContainer } from '@/components/interfaces/Reports/ReportBlock/ReportBlockContainer'
|
||||
import { ChartConfig } from '@/components/interfaces/SQLEditor/UtilityPanel/ChartConfig'
|
||||
import { Results } from '@/components/interfaces/SQLEditor/UtilityPanel/Results'
|
||||
|
||||
export const DEFAULT_CHART_CONFIG: ChartConfig = {
|
||||
type: 'bar',
|
||||
@@ -391,7 +391,7 @@ export const QueryBlock = ({
|
||||
'flex flex-col flex-1 w-full overflow-auto overscroll-contain relative max-h-64'
|
||||
)}
|
||||
>
|
||||
<Results rows={results} />
|
||||
<DataGridResults rows={results} />
|
||||
{autoLimit && (
|
||||
<p className="text-xs font-mono px-2 py-1 border-t text-foreground-light">
|
||||
Limited to only 100 rows
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
import { describe, expect, it, test } from 'vitest'
|
||||
|
||||
import { applyAutoLimit, getSqlErrorLines, trimTrailingSemicolons } from '../utils'
|
||||
|
||||
describe('getSqlErrorLines', () => {
|
||||
it('returns formattedError lines when present', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'permission denied for table users',
|
||||
formattedError:
|
||||
'ERROR: 42501: permission denied for table users\n' +
|
||||
'HINT: To grant access to anon on a specific table:\n' +
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
'ERROR: 42501: permission denied for table users',
|
||||
'HINT: To grant access to anon on a specific table:',
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
])
|
||||
})
|
||||
|
||||
it('strips empty lines from formattedError', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
formattedError: 'ERROR: boom\n\nHINT: retry\n',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['ERROR: boom', 'HINT: retry'])
|
||||
})
|
||||
|
||||
it('falls back to message lines when formattedError is missing and message is multi-line', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message:
|
||||
'ERROR: 42501: permission denied for table users\n' +
|
||||
'HINT: To grant access to anon on a specific table:\n' +
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
})
|
||||
|
||||
expect(lines).toEqual([
|
||||
'ERROR: 42501: permission denied for table users',
|
||||
'HINT: To grant access to anon on a specific table:',
|
||||
' GRANT SELECT ON TABLE public.users TO anon;',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns empty array for a single-line message so callers render the fallback', () => {
|
||||
const lines = getSqlErrorLines({ message: 'permission denied for table users' })
|
||||
expect(lines).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when both fields are missing', () => {
|
||||
expect(getSqlErrorLines({})).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when message is an empty string', () => {
|
||||
expect(getSqlErrorLines({ message: '' })).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when message only contains whitespace newlines', () => {
|
||||
// Only empty segments after filtering — treated as single-line
|
||||
expect(getSqlErrorLines({ message: '\n\n' })).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers formattedError even when message is also multi-line', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'message line 1\nmessage line 2',
|
||||
formattedError: 'formatted line 1\nformatted line 2',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['formatted line 1', 'formatted line 2'])
|
||||
})
|
||||
|
||||
it('falls through to message when formattedError is empty string', () => {
|
||||
const lines = getSqlErrorLines({
|
||||
message: 'ERROR: line 1\nHINT: line 2',
|
||||
formattedError: '',
|
||||
})
|
||||
|
||||
expect(lines).toEqual(['ERROR: line 1', 'HINT: line 2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('trimTrailingSemicolons', () => {
|
||||
test('removes a single trailing semicolon', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('removes multiple trailing semicolons', () => {
|
||||
const sql = safeSql`select * from countries;;;;;;;`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('leaves a fragment with no trailing semicolon unchanged', () => {
|
||||
const sql = safeSql`select * from countries`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select * from countries')
|
||||
})
|
||||
test('does not touch semicolons that are not trailing', () => {
|
||||
const sql = safeSql`select 1; select 2`
|
||||
expect(trimTrailingSemicolons(sql)).toBe('select 1; select 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyAutoLimit', () => {
|
||||
test('Should return false if limit passed is <= 0', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = -1
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return true if limit passed is > 0', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(true)
|
||||
})
|
||||
test('Should return false if query already has a limit', () => {
|
||||
const sql = safeSql`select * from countries limit 10;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit (check for case-insensitiveness)', () => {
|
||||
const sql = safeSql`SELECT * FROM countries LIMIT 10;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 ;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset', () => {
|
||||
const sql = safeSql`select * from countries limit 10 offset 0;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 offset 0 ;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit and offset (flip order of limit and offset)', () => {
|
||||
const sql = safeSql`select * from countries offset 0 limit 1;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query already has a limit, even if no value provided for limit', () => {
|
||||
const sql = safeSql`select * from countries limit`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `FETCH FIRST` instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `fetch first` instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries fetch first 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query uses `fetch first` (with random spaces) instead of limit ', () => {
|
||||
const sql = safeSql`select * from countries FETCH FIRST 5 rows only`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query is not a select statement', () => {
|
||||
const sql = safeSql`create table test (id int8 primary key, name varchar);`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if there are multiple queries I', () => {
|
||||
const sql1 = safeSql`select * from countries;
|
||||
select * from cities;`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if there are multiple queries II', () => {
|
||||
const sql1 = safeSql`select * from countries;
|
||||
select * from cities`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql1, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
// [Joshen] Opting to just avoid appending in this case to prevent making the logic overly complex atm
|
||||
test('Should return false if query has with a comment I', () => {
|
||||
const sql = safeSql`-- This is a comment
|
||||
select * from cities`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
test('Should return false if query has with a comment II', () => {
|
||||
const sql = safeSql`select * from cities
|
||||
-- This is a comment`
|
||||
const limit = 100
|
||||
const { appendAutoLimit } = applyAutoLimit(sql, limit)
|
||||
expect(appendAutoLimit).toBe(false)
|
||||
})
|
||||
|
||||
// [Joshen] These will just need to test the cases when appendAutoLimit returns true then
|
||||
test('Should add the limit param properly if query ends without a semi colon', () => {
|
||||
const sql = safeSql`select * from countries`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should add the limit param properly if query ends with a semi colon', () => {
|
||||
const sql = safeSql`select * from countries;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should add the limit param properly if query ends with multiple semi colon', () => {
|
||||
const sql = safeSql`select * from countries;;;;;;;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 100;')
|
||||
})
|
||||
test('Should not append a limit if query already has one with whitespace before the semi colon', () => {
|
||||
const sql = safeSql`select * from countries limit 10 ;`
|
||||
const limit = 100
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, limit)
|
||||
expect(formattedSql).toBe('select * from countries limit 10 ;')
|
||||
})
|
||||
test('returns the SafeSqlFragment result unchanged when no limit is appended', () => {
|
||||
const sql = safeSql`select * from countries limit 10;`
|
||||
const { sql: formattedSql } = applyAutoLimit(sql, 100)
|
||||
expect(formattedSql).toBe(sql)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
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 }
|
||||
}
|
||||
Reference in new issue
Block a user