From 1e84d28c81a0d15be50e2f869e516dcbb3a90dc4 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Thu, 18 Dec 2025 13:24:18 +0000 Subject: [PATCH] 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 --- .../IndexAdvisor/index-advisor.utils.ts | 85 +++++++++++++++++++ .../QueryPerformance/QueryPerformanceGrid.tsx | 13 ++- .../WithStatements/WithStatements.tsx | 9 +- .../WithStatements/WithStatements.utils.ts | 55 ++++++++---- .../retrieve-index-advisor-result-query.ts | 39 ++++++--- .../database/table-index-advisor-query.ts | 13 ++- 6 files changed, 182 insertions(+), 32 deletions(-) diff --git a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts index 134aea1c322..2c565b73141 100644 --- a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts +++ b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts @@ -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) + }) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx index 1d3e8c8bc5f..922ced2920b 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryPerformanceGrid.tsx @@ -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 (
@@ -597,7 +604,7 @@ export const QueryPerformanceGrid = ({ > Query details - {selectedRow !== undefined && isSelectQuery(reportData[selectedRow]?.query) && ( + {selectedRow !== undefined && canShowIndexesTab && ( )} - {selectedRow !== undefined && isSelectQuery(reportData[selectedRow]?.query) && ( + {selectedRow !== undefined && canShowIndexesTab && ( { 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 }) diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.ts b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.ts index 8e5ba0b86f3..a16b89110c3 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.ts +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.utils.ts @@ -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 + }) } diff --git a/apps/studio/data/database/retrieve-index-advisor-result-query.ts b/apps/studio/data/database/retrieve-index-advisor-result-query.ts index 3a90de6d2ac..001b9c7b03f 100644 --- a/apps/studio/data/database/retrieve-index-advisor-result-query.ts +++ b/apps/studio/data/database/retrieve-index-advisor-result-query.ts @@ -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 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> diff --git a/apps/studio/data/database/table-index-advisor-query.ts b/apps/studio/data/database/table-index-advisor-query.ts index ddc80986cde..130140e1f3c 100644 --- a/apps/studio/data/database/table-index-advisor-query.ts +++ b/apps/studio/data/database/table-index-advisor-query.ts @@ -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)