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)