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:
kemal.earth authored and GitHub committed 2025-12-18 13:24:18 +00:00
1 parent 31d2e8ac5b
commit 1e84d28c81
6 files changed
+182 -32

No files matched your search

@@ -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"
@@ -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 })
@@ -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)