Files
supabase/apps/studio/data/database/table-index-advisor-query.ts
Charis 2d4e87f579 studio: SafeSql for reports, query performance, privileges (4/7) (#45998)
## Summary

Part 4 of the SafeSql migration stack
([#45897](https://github.com/supabase/supabase/pull/45897),
[#45903](https://github.com/supabase/supabase/pull/45903),
[#45990](https://github.com/supabase/supabase/pull/45990), this PR, …).

Converts the remaining reports, query performance, observability, index
advisor, and privileges call sites of `executeSql` to produce
`SafeSqlFragment` values. The `ReportQuery.sql` field flips from
`string` to `SafeSqlFragment`, which cascades into every consumer —
landed here atomically so each branch typechecks cleanly.

Touched areas:

- `interfaces/Reports/*` — `ReportQuery.sql: SafeSqlFragment`, plus all
report definitions/utilities updated
- `interfaces/QueryPerformance/useQueryPerformanceQuery.ts`
- `interfaces/Database/IndexAdvisor/*` and
`data/database/{table-index-advisor,retrieve-index-advisor-result}-query.ts`
-
`data/privileges/{table-api-access,update-exposed-entities}-mutation.ts`
- `interfaces/Storage/StoragePolicies/StoragePolicies.tsx`
- `hooks/analytics/useDbQuery.tsx`
- `Observability/useSlowQueriesCount.ts` +
`useQueryInsightsIssues.utils.test.ts`

## Test plan

- [x] `pnpm typecheck` passes
- [x] `useQueryInsightsIssues.utils.test.ts` passes
- [x] Dev-server smoke test: reports pages, query performance, index
advisor, storage policies

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

* **Refactor**
* Reworked SQL construction and typings across reporting, query
performance, index advisor, and privilege features to use safer SQL
fragments, improving reliability and preventing query composition
issues.
* **Types**
* Reporting query types were split to distinguish database vs. logs
queries, enabling correct handling and validation.
* **Docs/Utils**
  * Added a helper to consistently generate logs SQL for report hooks.
* **Tests**
  * Updated tests to exercise the new SQL-building API.

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45998)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-05-15 14:50:38 -04:00

165 lines
5.3 KiB
TypeScript

import { getTableIndexAdvisorSql, type SafeSqlFragment } from '@supabase/pg-meta'
import { useQuery } from '@tanstack/react-query'
import { databaseKeys } from './keys'
import { filterProtectedSchemaIndexStatements } from '@/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils'
import { executeSql } from '@/data/sql/execute-sql-query'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export type TableIndexAdvisorVariables = {
projectRef?: string
connectionString?: string | null
schema: string
table: string
}
export type IndexAdvisorSuggestion = {
query: SafeSqlFragment
calls: number
total_time: number
mean_time: number
index_statements: SafeSqlFragment[]
startup_cost_before: number
startup_cost_after: number
total_cost_before: number
total_cost_after: number
improvement_percentage: number
}
export type TableIndexAdvisorResponse = {
suggestions: IndexAdvisorSuggestion[]
columnsWithSuggestions: string[]
}
// Strips ordering modifiers and outer quotes from a raw column token from an index statement.
// e.g. '"created_at" DESC NULLS LAST' -> 'created_at'
export function cleanIndexColumnName(raw: string): string {
return raw
.trim()
.replace(/\s+(asc|desc)(\s+nulls\s+(first|last))?$/i, '')
.replace(/\s+nulls\s+(first|last)$/i, '')
.trim()
.replace(/^"(.+)"$/, '$1')
}
//Extracts column names from index statements
//e.g. "CREATE INDEX ON public.users USING btree (email)" -> "email"
function extractColumnsFromIndexStatements(indexStatements: string[]): string[] {
const columns = new Set<string>()
for (const statement of indexStatements) {
// Match patterns like "USING btree (column_name)" or "USING btree (column1, column2)"
const match = statement.match(/USING\s+\w+\s*\(([^)]+)\)/i)
if (match) {
const columnPart = match[1]
// Split by comma and clean up each column name
columnPart.split(',').forEach((col) => {
const cleanedCol = cleanIndexColumnName(col)
// Only add simple identifiers — skip expressions like lower(col)
if (cleanedCol && /^[a-z_][a-z0-9_$]*$/i.test(cleanedCol)) {
columns.add(cleanedCol)
}
})
}
}
return Array.from(columns)
}
interface GetTableIndexAdvisorSuggestionsResponse {
query: SafeSqlFragment
calls: number
total_time: number
mean_time: number
index_statements: SafeSqlFragment[]
startup_cost_before: number
startup_cost_after: number
total_cost_before: number
total_cost_after: number
}
export async function getTableIndexAdvisorSuggestions({
projectRef,
connectionString,
schema,
table,
}: TableIndexAdvisorVariables): Promise<TableIndexAdvisorResponse> {
if (!projectRef) throw new Error('Project ref is required')
if (!schema) throw new Error('Schema is required')
if (!table) throw new Error('Table is required')
const sql = getTableIndexAdvisorSql(schema, table)
const { result } = await executeSql<Array<GetTableIndexAdvisorSuggestionsResponse>>({
projectRef,
connectionString,
sql,
})
const suggestions: IndexAdvisorSuggestion[] = (result || [])
.filter((row) => row.index_statements && row.index_statements.length > 0)
.map((row) => {
// Filter out protected schema index statements
const filteredStatements = filterProtectedSchemaIndexStatements(row.index_statements)
// Skip this suggestion if all statements were filtered out
if (filteredStatements.length === 0) {
return null
}
const improvement =
row.total_cost_before > 0
? ((row.total_cost_before - row.total_cost_after) / row.total_cost_before) * 100
: 0
return {
query: row.query,
calls: row.calls,
total_time: row.total_time,
mean_time: row.mean_time,
index_statements: filteredStatements,
startup_cost_before: row.startup_cost_before,
startup_cost_after: row.startup_cost_after,
total_cost_before: row.total_cost_before,
total_cost_after: row.total_cost_after,
improvement_percentage: Math.round(improvement * 100) / 100,
}
})
.filter((suggestion): suggestion is IndexAdvisorSuggestion => suggestion !== null)
// Extract all unique columns from suggestions
const allIndexStatements = suggestions.flatMap((s) => s.index_statements)
const columnsWithSuggestions = extractColumnsFromIndexStatements(allIndexStatements)
return {
suggestions,
columnsWithSuggestions,
}
}
export type TableIndexAdvisorData = Awaited<ReturnType<typeof getTableIndexAdvisorSuggestions>>
export type TableIndexAdvisorError = ResponseError
export function useTableIndexAdvisorQuery<TData = TableIndexAdvisorData>(
{ projectRef, connectionString, schema, table }: TableIndexAdvisorVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<TableIndexAdvisorData, TableIndexAdvisorError, TData> = {}
) {
return useQuery<TableIndexAdvisorData, TableIndexAdvisorError, TData>({
queryKey: databaseKeys.tableIndexAdvisor(projectRef, schema, table),
queryFn: () =>
getTableIndexAdvisorSuggestions({
projectRef,
connectionString,
schema,
table,
}),
enabled: enabled && typeof projectRef !== 'undefined' && !!schema && !!table,
retry: false,
staleTime: 5 * 60 * 1000,
...options,
})
}