mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
feat(studio): filter out internal schemas from index advisor (#41428)
* feat: prototype of filtering out internal schemas from index advisor * chore: coderabbit suggestion * chore: use zod for ia schema
This commit is contained in:
1 parent
31d2e8ac5b
commit
1e84d28c81
6 files changed
+182
-32
No files matched your search
+85
@@ -4,6 +4,8 @@ import { DatabaseExtension } from 'data/database-extensions/database-extensions-
|
||||
import { GetIndexAdvisorResultResponse } from 'data/database/retrieve-index-advisor-result-query'
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
|
||||
import { INTERNAL_SCHEMAS } from 'hooks/useProtectedSchemas'
|
||||
|
||||
/**
|
||||
* Gets the required extensions for index advisor
|
||||
* @param extensions Array of database extensions
|
||||
@@ -101,3 +103,86 @@ export function hasIndexRecommendations(
|
||||
): boolean {
|
||||
return Boolean(isSuccess && result?.index_statements && result.index_statements.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters out index statements that reference protected schemas
|
||||
* Index statements are typically in the format: "CREATE INDEX ON schema.table USING ..."
|
||||
*
|
||||
* @param indexStatements Array of index statement strings
|
||||
* @returns Filtered array excluding statements referencing protected schemas
|
||||
*/
|
||||
export function filterProtectedSchemaIndexStatements(indexStatements: string[]): string[] {
|
||||
if (!indexStatements || indexStatements.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return indexStatements.filter((statement) => {
|
||||
// Match patterns like "CREATE INDEX ON schema.table" or "CREATE INDEX ON "schema"."table""
|
||||
// Handle both quoted and unquoted schema names
|
||||
const schemaMatch = statement.match(/ON\s+(?:"?(\w+)"?\.|(\w+)\.)/i)
|
||||
|
||||
if (!schemaMatch) {
|
||||
// If we can't parse the schema, keep it (safer to show than hide)
|
||||
return true
|
||||
}
|
||||
|
||||
// Extract schema name (handle both quoted and unquoted)
|
||||
const schemaName = schemaMatch[1] || schemaMatch[2]
|
||||
|
||||
if (!schemaName) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check if schema is in the protected schemas list
|
||||
return !INTERNAL_SCHEMAS.includes(schemaName.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters an index advisor result to remove recommendations for protected schemas
|
||||
*
|
||||
* @param result The index advisor result object
|
||||
* @returns Filtered result with protected schema recommendations removed
|
||||
*/
|
||||
export function filterProtectedSchemaIndexAdvisorResult(
|
||||
result: GetIndexAdvisorResultResponse | null | undefined
|
||||
): GetIndexAdvisorResultResponse | null {
|
||||
if (!result || !result.index_statements) {
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
const filteredStatements = filterProtectedSchemaIndexStatements(result.index_statements)
|
||||
|
||||
// If all statements were filtered out, return null
|
||||
if (filteredStatements.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
index_statements: filteredStatements,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a query involves protected schemas by examining the SQL query text
|
||||
*
|
||||
* @param query The SQL query string
|
||||
* @returns Whether the query involves protected schemas
|
||||
*/
|
||||
export function queryInvolvesProtectedSchemas(query: string | undefined | null): boolean {
|
||||
if (!query) return false
|
||||
|
||||
const queryLower = query.toLowerCase()
|
||||
|
||||
// Check if the query references any protected schemas
|
||||
// Match patterns like "schema.table", "FROM schema.table", "JOIN schema.table", etc.
|
||||
return INTERNAL_SCHEMAS.some((schema) => {
|
||||
// Match schema.table patterns (with or without quotes)
|
||||
const schemaPattern = new RegExp(
|
||||
`(?:from|join|update|insert\\s+into|delete\\s+from)\\s+(?:${schema}\\.|"${schema}"\\.)`,
|
||||
'i'
|
||||
)
|
||||
return schemaPattern.test(queryLower)
|
||||
})
|
||||
}
|
||||
@@ -25,7 +25,10 @@ import { InfoTooltip } from 'ui-patterns/info-tooltip'
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { Admonition } from 'ui-patterns'
|
||||
import { useQueryPerformanceSort } from './hooks/useQueryPerformanceSort'
|
||||
import { hasIndexRecommendations } from './IndexAdvisor/index-advisor.utils'
|
||||
import {
|
||||
hasIndexRecommendations,
|
||||
queryInvolvesProtectedSchemas,
|
||||
} from './IndexAdvisor/index-advisor.utils'
|
||||
import { IndexSuggestionIcon } from './IndexAdvisor/IndexSuggestionIcon'
|
||||
import { QueryDetail } from './QueryDetail'
|
||||
import { QueryIndexes } from './QueryIndexes'
|
||||
@@ -484,6 +487,10 @@ export const QueryPerformanceGrid = ({
|
||||
)
|
||||
}
|
||||
|
||||
const selectedQuery = selectedRow !== undefined ? reportData[selectedRow]?.query : undefined
|
||||
const isProtectedSchemaQuery = queryInvolvesProtectedSchemas(selectedQuery)
|
||||
const canShowIndexesTab = isSelectQuery(selectedQuery) && !isProtectedSchemaQuery
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-grow bg-alternative min-h-0">
|
||||
<div ref={dataGridContainerRef} className="flex-1 min-w-0 overflow-x-auto">
|
||||
@@ -597,7 +604,7 @@ export const QueryPerformanceGrid = ({
|
||||
>
|
||||
Query details
|
||||
</TabsTrigger_Shadcn_>
|
||||
{selectedRow !== undefined && isSelectQuery(reportData[selectedRow]?.query) && (
|
||||
{selectedRow !== undefined && canShowIndexesTab && (
|
||||
<TabsTrigger_Shadcn_
|
||||
value="suggestion"
|
||||
className="px-0 pb-0 data-[state=active]:bg-transparent !shadow-none"
|
||||
@@ -617,7 +624,7 @@ export const QueryPerformanceGrid = ({
|
||||
/>
|
||||
)}
|
||||
</TabsContent_Shadcn_>
|
||||
{selectedRow !== undefined && isSelectQuery(reportData[selectedRow]?.query) && (
|
||||
{selectedRow !== undefined && canShowIndexesTab && (
|
||||
<TabsContent_Shadcn_
|
||||
value="suggestion"
|
||||
className="mt-0 flex-grow min-h-0 overflow-y-auto"
|
||||
|
||||
+7
-2
@@ -25,6 +25,7 @@ import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
|
||||
import { captureQueryPerformanceError } from '../QueryPerformance.utils'
|
||||
import { getErrorMessage } from 'lib/get-error-message'
|
||||
import { parseAsString, useQueryStates } from 'nuqs'
|
||||
|
||||
interface WithStatementsProps {
|
||||
queryHitRate: PresetHookResult
|
||||
@@ -55,6 +56,10 @@ export const WithStatements = ({
|
||||
true
|
||||
)
|
||||
|
||||
const [{ indexAdvisor }] = useQueryStates({
|
||||
indexAdvisor: parseAsString.withDefault('false'),
|
||||
})
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryPerformanceQuery.runQuery()
|
||||
queryHitRate.runQuery()
|
||||
@@ -62,8 +67,8 @@ export const WithStatements = ({
|
||||
}
|
||||
|
||||
const processedData = useMemo(() => {
|
||||
return transformStatementDataToRows(data || [])
|
||||
}, [data])
|
||||
return transformStatementDataToRows(data || [], indexAdvisor === 'true')
|
||||
}, [data, indexAdvisor])
|
||||
|
||||
const { data: databases } = useReadReplicasQuery({ projectRef: ref })
|
||||
|
||||
|
||||
+40
-15
@@ -1,22 +1,47 @@
|
||||
import { QueryPerformanceRow } from '../QueryPerformance.types'
|
||||
import {
|
||||
filterProtectedSchemaIndexAdvisorResult,
|
||||
queryInvolvesProtectedSchemas,
|
||||
} from '../IndexAdvisor/index-advisor.utils'
|
||||
|
||||
export const transformStatementDataToRows = (data: any[]): QueryPerformanceRow[] => {
|
||||
export const transformStatementDataToRows = (
|
||||
data: any[],
|
||||
filterIndexAdvisor: boolean = false
|
||||
): QueryPerformanceRow[] => {
|
||||
if (!data || data.length === 0) return []
|
||||
|
||||
const totalTimeAcrossAllQueries = data.reduce((sum, row) => sum + (row.total_time || 0), 0)
|
||||
|
||||
return data.map((row) => ({
|
||||
query: row.query,
|
||||
rolname: row.rolname || undefined,
|
||||
calls: row.calls || 0,
|
||||
mean_time: row.mean_time || 0,
|
||||
min_time: row.min_time || 0,
|
||||
max_time: row.max_time || 0,
|
||||
total_time: row.total_time || 0,
|
||||
rows_read: row.rows_read || 0,
|
||||
cache_hit_rate: row.cache_hit_rate || 0,
|
||||
prop_total_time:
|
||||
totalTimeAcrossAllQueries > 0 ? (row.total_time / totalTimeAcrossAllQueries) * 100 : 0,
|
||||
index_advisor_result: row.index_advisor_result || null,
|
||||
}))
|
||||
return data
|
||||
.map((row) => {
|
||||
const filteredIndexAdvisorResult = row.index_advisor_result
|
||||
? filterProtectedSchemaIndexAdvisorResult(row.index_advisor_result)
|
||||
: null
|
||||
|
||||
return {
|
||||
query: row.query,
|
||||
rolname: row.rolname || undefined,
|
||||
calls: row.calls || 0,
|
||||
mean_time: row.mean_time || 0,
|
||||
min_time: row.min_time || 0,
|
||||
max_time: row.max_time || 0,
|
||||
total_time: row.total_time || 0,
|
||||
rows_read: row.rows_read || 0,
|
||||
cache_hit_rate: row.cache_hit_rate || 0,
|
||||
prop_total_time:
|
||||
totalTimeAcrossAllQueries > 0 ? (row.total_time / totalTimeAcrossAllQueries) * 100 : 0,
|
||||
index_advisor_result: filteredIndexAdvisorResult,
|
||||
}
|
||||
})
|
||||
.filter((row) => {
|
||||
if (filterIndexAdvisor) {
|
||||
const hasValidRecommendations = row.index_advisor_result !== null
|
||||
const involvesProtectedSchemas = queryInvolvesProtectedSchemas(row.query)
|
||||
|
||||
if (involvesProtectedSchemas && !hasValidRecommendations) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
import { databaseKeys } from './keys'
|
||||
import { filterProtectedSchemaIndexAdvisorResult } from 'components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils'
|
||||
|
||||
export type GetIndexAdvisorResultVariables = {
|
||||
projectRef?: string
|
||||
@@ -10,14 +12,16 @@ export type GetIndexAdvisorResultVariables = {
|
||||
query: string
|
||||
}
|
||||
|
||||
export type GetIndexAdvisorResultResponse = {
|
||||
errors: string[]
|
||||
index_statements: string[]
|
||||
startup_cost_before: number
|
||||
startup_cost_after: number
|
||||
total_cost_before: number
|
||||
total_cost_after: number
|
||||
}
|
||||
const IndexAdvisorResultSchema = z.object({
|
||||
errors: z.array(z.string()),
|
||||
index_statements: z.array(z.string()),
|
||||
startup_cost_before: z.number(),
|
||||
startup_cost_after: z.number(),
|
||||
total_cost_before: z.number(),
|
||||
total_cost_after: z.number(),
|
||||
})
|
||||
|
||||
export type GetIndexAdvisorResultResponse = z.infer<typeof IndexAdvisorResultSchema>
|
||||
|
||||
export async function getIndexAdvisorResult({
|
||||
projectRef,
|
||||
@@ -26,15 +30,28 @@ export async function getIndexAdvisorResult({
|
||||
}: GetIndexAdvisorResultVariables) {
|
||||
if (!projectRef) throw new Error('Project ref is required')
|
||||
|
||||
// swap single quotes for double to prevent syntax errors
|
||||
const escapedQuery = query.replace(/'/g, "''")
|
||||
|
||||
const { result } = await executeSql({
|
||||
const { result: results } = await executeSql({
|
||||
projectRef,
|
||||
connectionString,
|
||||
sql: `select * from index_advisor('${escapedQuery}');`,
|
||||
})
|
||||
return result[0] as GetIndexAdvisorResultResponse
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
throw new Error('Index advisor returned no results')
|
||||
}
|
||||
|
||||
const parsed = IndexAdvisorResultSchema.safeParse(results[0])
|
||||
if (!parsed.success) {
|
||||
const firstError = parsed.error.errors[0]
|
||||
const errorPath = firstError.path.length > 0 ? ` at path: ${firstError.path.join('.')}` : ''
|
||||
throw new Error(
|
||||
`Invalid index advisor response${errorPath}: ${firstError.message}. Received: ${JSON.stringify(results[0])}`
|
||||
)
|
||||
}
|
||||
|
||||
return filterProtectedSchemaIndexAdvisorResult(parsed.data)
|
||||
}
|
||||
|
||||
export type GetIndexAdvisorResultData = Awaited<ReturnType<typeof getIndexAdvisorResult>>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { executeSql } from 'data/sql/execute-sql-query'
|
||||
import type { ResponseError, UseCustomQueryOptions } from 'types'
|
||||
import { databaseKeys } from './keys'
|
||||
|
||||
import { filterProtectedSchemaIndexStatements } from 'components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils'
|
||||
|
||||
export type TableIndexAdvisorVariables = {
|
||||
projectRef?: string
|
||||
connectionString?: string | null
|
||||
@@ -134,6 +136,14 @@ export async function getTableIndexAdvisorSuggestions({
|
||||
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
|
||||
@@ -144,7 +154,7 @@ export async function getTableIndexAdvisorSuggestions({
|
||||
calls: row.calls,
|
||||
total_time: row.total_time,
|
||||
mean_time: row.mean_time,
|
||||
index_statements: row.index_statements,
|
||||
index_statements: filteredStatements,
|
||||
startup_cost_before: row.startup_cost_before,
|
||||
startup_cost_after: row.startup_cost_after,
|
||||
total_cost_before: row.total_cost_before,
|
||||
@@ -152,6 +162,7 @@ export async function getTableIndexAdvisorSuggestions({
|
||||
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)
|
||||
|
||||
Reference in new issue
Block a user