mirror of
https://github.com/supabase/supabase.git
synced 2026-09-08 19:08:44 +08:00
## Summary - define application-owned database and logs source contracts, defaults, validation, labels, and execution endpoints - extract controlled database and logs parameter controls for reuse outside SQL snippets - adapt the SQL editor to the shared source model without changing snippet behavior - standardize source icons at 16px with a 2px stroke - keep relative logs ranges aligned with the existing date picker units ## To test 1. Open an existing query in the SQL Editor and run it against the database. 2. Switch the query source to Logs, change the time range, and confirm the query still runs as expected. ## Why Explorer queries and notebook query cells need to select an execution source without coupling that source to SQL snippets. This provides the shared registry and controlled UI foundation for those consumers. ## Impact Existing SQL snippets retain their current database/logs routing and session behavior. The registry documents the SQL editor legacy database-selector adapter while new consumers own their identifier inline. The shared Logs date picker remains unchanged; query ranges support its existing minute, hour, and day units. This PR does not add the Explorer query tab itself. ## Validation - pnpm --filter studio typecheck - focused Vitest coverage for the registry, canonical log-range utilities, SQL execution adapters, source filtering, retention locking, custom ranges, and preset selection - pnpm --filter studio run lint:ratchet Component and state tests cover this change per the Studio testing guidance; no E2E test is added. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added a unified query-source menu for database queries and logs. * Added custom log time-range selection with calendar support and retention-aware upgrade prompts. * Added consistent source icons and improved database selection handling. * Added support for relative and absolute log time ranges. * **Bug Fixes** * Improved log-range validation, defaults, and current-time handling. * Updated query execution to use the correct source-specific endpoints. * **Tests** * Expanded coverage for query sources, log ranges, menus, and retention behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
285 lines
8.8 KiB
TypeScript
285 lines
8.8 KiB
TypeScript
import {
|
|
ROLE_IMPERSONATION_NO_RESULTS,
|
|
ROLE_IMPERSONATION_SQL_LINE_COUNT,
|
|
type SafeSqlFragment,
|
|
} from '@supabase/pg-meta'
|
|
import { DEFAULT_PLATFORM_APPLICATION_NAME } from '@supabase/pg-meta/src/constants'
|
|
import { useMutation, useQueryClient, type QueryKey } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
|
|
import {
|
|
calculateSummary,
|
|
createNodeTree,
|
|
} from '@/components/interfaces/ExplainVisualizer/ExplainVisualizer.parser'
|
|
import { handleError as handleErrorFetchers, post } from '@/data/fetchers'
|
|
import { QUERY_SOURCE_REGISTRY } from '@/data/query-sources/query-source-registry'
|
|
import { MB } from '@/lib/constants'
|
|
import { sqlEventParser } from '@/lib/sql-event-parser'
|
|
import { useTrack } from '@/lib/telemetry/track'
|
|
import { ResponseError, UseCustomMutationOptions } from '@/types'
|
|
|
|
// [Joshen] Intention is that we invalidate all database related keys whenever running a mutation related query
|
|
// So we attempt to ignore all the non-related query keys. We could probably look into grouping our query keys better
|
|
// actually to not make this too hacky here
|
|
const INVALIDATION_KEYS_IGNORE = ['branches', 'settings-v2', 'addons', 'custom-domains', 'content']
|
|
|
|
/**
|
|
* [Joshen] Done a bit of stress testing and experimentation, tho we should still observe and tweak where necessary
|
|
* From what I understand a query cost of 100,000 is considered to be "heavy", and 1M is "potentially dangerous"
|
|
* Reckon we ensure that the dashboard just caps query costs at "heavy", so that it doesn't impact the DB for other queries
|
|
* (e.g from the user's application)
|
|
*/
|
|
const COST_THRESHOLD = 200_000
|
|
export const COST_THRESHOLD_ERROR = 'Query cost exceeds threshold'
|
|
|
|
export type QueryResponseError = {
|
|
code: string
|
|
message: string
|
|
error: string
|
|
file: string
|
|
length: number
|
|
line: string
|
|
name: string
|
|
position: string
|
|
routine: string
|
|
severity: string
|
|
}
|
|
|
|
type ExecuteSqlVariables = {
|
|
projectRef?: string
|
|
connectionString?: string | null
|
|
sql: SafeSqlFragment
|
|
queryKey?: QueryKey
|
|
handleError?: (error: ResponseError) => { result: any }
|
|
isRoleImpersonationEnabled?: boolean
|
|
/**
|
|
* Disables transaction mode - should be used only for manual queries ran via the SQL Editor
|
|
* */
|
|
isStatementTimeoutDisabled?: boolean
|
|
/**
|
|
* Runs an EXPLAIN before actually running the query, rejects the query if cost exceeds a threshold.
|
|
* Intended to be used for interfaces that heavily rely on queries on the DB
|
|
* */
|
|
preflightCheck?: boolean
|
|
}
|
|
|
|
type ExecuteSqlMutationVariables = ExecuteSqlVariables & {
|
|
autoLimit?: number
|
|
contextualInvalidation?: boolean
|
|
}
|
|
|
|
/**
|
|
* Executes a SQL query against the user's instance.
|
|
*
|
|
* @throws {Error}
|
|
*/
|
|
export async function executeSql<T = any>(
|
|
{
|
|
projectRef,
|
|
connectionString,
|
|
sql,
|
|
queryKey,
|
|
handleError,
|
|
isRoleImpersonationEnabled = false,
|
|
isStatementTimeoutDisabled = false,
|
|
preflightCheck = false,
|
|
}: ExecuteSqlVariables,
|
|
signal?: AbortSignal,
|
|
headersInit?: HeadersInit,
|
|
fetcherOverride?: (options: {
|
|
query: string
|
|
headers?: HeadersInit
|
|
}) => Promise<{ data: T } | { error: ResponseError }>
|
|
): Promise<{ result: T }> {
|
|
if (!projectRef) throw new Error('projectRef is required')
|
|
|
|
const sqlSize = new Blob([sql]).size
|
|
// [Joshen] I think the limit is around 1MB from testing, but its not exactly 1MB it seems
|
|
if (sqlSize > 0.98 * MB) {
|
|
throw new Error('Query is too large to be run via the SQL Editor')
|
|
}
|
|
|
|
let headers = new Headers(headersInit)
|
|
if (connectionString) headers.set('x-connection-encrypted', connectionString)
|
|
|
|
let data
|
|
let error
|
|
|
|
if (fetcherOverride) {
|
|
const result = await fetcherOverride({ query: sql, headers })
|
|
if ('data' in result) {
|
|
data = result.data
|
|
} else {
|
|
error = result.error
|
|
}
|
|
} else {
|
|
const options = {
|
|
signal,
|
|
headers,
|
|
params: {
|
|
path: { ref: projectRef },
|
|
header: {
|
|
'x-connection-encrypted': connectionString ?? '',
|
|
'x-pg-application-name': isStatementTimeoutDisabled
|
|
? 'supabase/dashboard-query-editor'
|
|
: DEFAULT_PLATFORM_APPLICATION_NAME,
|
|
},
|
|
},
|
|
}
|
|
|
|
if (preflightCheck) {
|
|
/**
|
|
* [Joshen] Note that I've intentionally omitted error handling here as I'm opting
|
|
* to NOT block the UI if the preflight check fails for any reason.
|
|
*/
|
|
|
|
const { data: costCheck } = await post('/platform/pg-meta/{ref}/query', {
|
|
...options,
|
|
body: {
|
|
query: `explain ${sql}`,
|
|
disable_statement_timeout: isStatementTimeoutDisabled,
|
|
},
|
|
params: {
|
|
...options.params,
|
|
// @ts-expect-error: This is just a client side thing to identify queries better
|
|
query: { key: 'preflight-check' },
|
|
},
|
|
})
|
|
const parsedTree = !!costCheck ? createNodeTree(costCheck) : undefined
|
|
const summary = !!parsedTree ? calculateSummary(parsedTree) : undefined
|
|
const cost = summary?.totalCost ?? 0
|
|
|
|
if (cost >= COST_THRESHOLD) {
|
|
return handleErrorFetchers({
|
|
message: COST_THRESHOLD_ERROR,
|
|
code: cost,
|
|
metadata: { cost, sql },
|
|
})
|
|
}
|
|
}
|
|
|
|
const key =
|
|
queryKey?.filter((seg) => typeof seg === 'string' || typeof seg === 'number').join('-') ?? ''
|
|
const result = await post(QUERY_SOURCE_REGISTRY.database.endpoint, {
|
|
...options,
|
|
body: { query: sql, disable_statement_timeout: isStatementTimeoutDisabled },
|
|
params: {
|
|
...options.params,
|
|
// @ts-expect-error: This is just a client side thing to identify queries better
|
|
query: { key },
|
|
},
|
|
})
|
|
|
|
data = result.data
|
|
error = result.error
|
|
}
|
|
|
|
if (error) {
|
|
if (
|
|
isRoleImpersonationEnabled &&
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'error' in error &&
|
|
'formattedError' in error
|
|
) {
|
|
let updatedError = error as { error: string; formattedError: string }
|
|
|
|
const regex = /LINE (\d+):/im
|
|
const [, lineNumberStr] = regex.exec(updatedError.error) ?? []
|
|
const lineNumber = Number(lineNumberStr)
|
|
if (!isNaN(lineNumber)) {
|
|
updatedError = {
|
|
...updatedError,
|
|
error: updatedError.error.replace(
|
|
regex,
|
|
`LINE ${lineNumber - ROLE_IMPERSONATION_SQL_LINE_COUNT}:`
|
|
),
|
|
formattedError: updatedError.formattedError.replace(
|
|
regex,
|
|
`LINE ${lineNumber - ROLE_IMPERSONATION_SQL_LINE_COUNT}:`
|
|
),
|
|
}
|
|
}
|
|
|
|
error = updatedError as any
|
|
}
|
|
|
|
if (handleError !== undefined) return handleError(error as any)
|
|
else handleErrorFetchers(error)
|
|
}
|
|
|
|
if (
|
|
isRoleImpersonationEnabled &&
|
|
Array.isArray(data) &&
|
|
data?.[0]?.[ROLE_IMPERSONATION_NO_RESULTS] === 1
|
|
) {
|
|
return { result: [] as T }
|
|
}
|
|
|
|
return { result: data as T }
|
|
}
|
|
|
|
type ExecuteSqlData = Awaited<ReturnType<typeof executeSql<any[]>>>
|
|
|
|
export const useExecuteSqlMutation = ({
|
|
onSuccess,
|
|
onError,
|
|
...options
|
|
}: Omit<
|
|
UseCustomMutationOptions<ExecuteSqlData, QueryResponseError, ExecuteSqlMutationVariables>,
|
|
'mutationFn'
|
|
> = {}) => {
|
|
const queryClient = useQueryClient()
|
|
const track = useTrack()
|
|
|
|
return useMutation<ExecuteSqlData, QueryResponseError, ExecuteSqlMutationVariables>({
|
|
mutationFn: (args) => executeSql(args),
|
|
async onSuccess(data, variables, context) {
|
|
const { contextualInvalidation, sql, projectRef } = variables
|
|
|
|
try {
|
|
const tableEvents = sqlEventParser.getTableEvents(sql)
|
|
tableEvents.forEach((event) => {
|
|
if (projectRef) {
|
|
track(
|
|
event.type,
|
|
{
|
|
method: 'sql_editor',
|
|
schema_name: event.schema,
|
|
table_name: event.tableName,
|
|
},
|
|
{ project: projectRef }
|
|
)
|
|
}
|
|
})
|
|
} catch (error) {
|
|
console.error('Failed to parse SQL for telemetry:', error)
|
|
}
|
|
|
|
// [Joshen] Default to false for now, only used for SQL editor to dynamically invalidate
|
|
const sqlLower = sql.toLowerCase()
|
|
const isMutationSQL =
|
|
sqlLower.includes('create ') || sqlLower.includes('alter ') || sqlLower.includes('drop ')
|
|
if (contextualInvalidation && projectRef && isMutationSQL) {
|
|
const databaseRelatedKeys = queryClient
|
|
.getQueryCache()
|
|
.findAll({ queryKey: ['projects', projectRef] })
|
|
.map((x) => x.queryKey)
|
|
.filter((x) => !INVALIDATION_KEYS_IGNORE.some((a) => x.includes(a)))
|
|
await Promise.all(
|
|
databaseRelatedKeys.map((key) => queryClient.invalidateQueries({ queryKey: key }))
|
|
)
|
|
}
|
|
await onSuccess?.(data, variables, context)
|
|
},
|
|
async onError(data, variables, context) {
|
|
if (onError === undefined) {
|
|
toast.error(`Failed to execute SQL: ${data.message}`)
|
|
} else {
|
|
onError(data, variables, context)
|
|
}
|
|
},
|
|
...options,
|
|
})
|
|
}
|