diff --git a/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx b/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx index 5423b3c315f..7db08a70e88 100644 --- a/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx +++ b/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx @@ -1,15 +1,15 @@ import { useQueryClient } from '@tanstack/react-query' -import { createContext, useContext, PropsWithChildren, useState, useCallback } from 'react' - -import { QueryIndexes } from 'components/interfaces/QueryPerformance/QueryIndexes' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' +import { QueryIndexes } from 'components/interfaces/QueryPerformance/QueryIndexes' import { databaseKeys } from 'data/database/keys' import { - useTableIndexAdvisorQuery, - TableIndexAdvisorData, + cleanIndexColumnName, IndexAdvisorSuggestion, + TableIndexAdvisorData, + useTableIndexAdvisorQuery, } from 'data/database/table-index-advisor-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { createContext, PropsWithChildren, useCallback, useContext, useState } from 'react' import { Sheet, SheetContent, SheetHeader, SheetTitle } from 'ui' interface TableIndexAdvisorContextValue { @@ -80,7 +80,7 @@ export function TableIndexAdvisorProvider({ suggestion.index_statements.some((stmt) => { const match = stmt.match(/USING\s+\w+\s*\(([^)]+)\)/i) if (match) { - const columns = match[1].split(',').map((c) => c.trim().replace(/^"(.+)"$/, '$1')) + const columns = match[1].split(',').map((c) => cleanIndexColumnName(c)) return columns.includes(columnName) } return false @@ -101,6 +101,17 @@ export function TableIndexAdvisorProvider({ // Get the first suggestion for the selected column to pass to QueryIndexes const selectedSuggestion = selectedColumn ? getSuggestionsForColumn(selectedColumn)[0] : null + const prefetchedIndexAdvisorResult = selectedSuggestion + ? { + errors: [], + index_statements: selectedSuggestion.index_statements, + startup_cost_before: selectedSuggestion.startup_cost_before, + startup_cost_after: selectedSuggestion.startup_cost_after, + total_cost_before: selectedSuggestion.total_cost_before, + total_cost_after: selectedSuggestion.total_cost_after, + } + : null + const value: TableIndexAdvisorContextValue = { isLoading, isAvailable: isIndexAdvisorAvailable, @@ -116,7 +127,7 @@ export function TableIndexAdvisorProvider({ {children} !open && closeSheet()}> - + Index Recommendation @@ -125,6 +136,7 @@ export function TableIndexAdvisorProvider({ selectedRow={{ query: selectedSuggestion.query }} columnName={selectedColumn} suggestedSelectQuery={selectedSuggestion.query} + prefetchedIndexAdvisorResult={prefetchedIndexAdvisorResult} onClose={closeSheet} /> )} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx index ef1a2c71f6f..071535f74ea 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx @@ -1,32 +1,35 @@ -import { Check, Table2, Lightbulb } from 'lucide-react' -import { useState, useEffect } from 'react' - import { AccordionTrigger } from '@ui/components/shadcn/ui/accordion' import { useIndexAdvisorStatus } from 'components/interfaces/QueryPerformance/hooks/useIsIndexAdvisorStatus' import AlertError from 'components/ui/AlertError' import { DocsButton } from 'components/ui/DocsButton' -import { Admonition } from 'ui-patterns' import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useGetIndexAdvisorResult } from 'data/database/retrieve-index-advisor-result-query' +import { + GetIndexAdvisorResultResponse, + useGetIndexAdvisorResult, +} from 'data/database/retrieve-index-advisor-result-query' import { useGetIndexesFromSelectQuery } from 'data/database/retrieve-index-from-select-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { DOCS_URL } from 'lib/constants' import { useTrack } from 'lib/telemetry/track' +import { Check, Lightbulb, Table2 } from 'lucide-react' +import { useEffect, useState } from 'react' import { + Accordion_Shadcn_, AccordionContent_Shadcn_, AccordionItem_Shadcn_, - Accordion_Shadcn_, + Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, - Alert_Shadcn_, Button, + cn, CodeBlock, + Collapsible_Shadcn_, CollapsibleContent_Shadcn_, CollapsibleTrigger_Shadcn_, - Collapsible_Shadcn_, - cn, } from 'ui' +import { Admonition } from 'ui-patterns' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' + import { useIndexInvalidation } from './hooks/useIndexInvalidation' import { EnableIndexAdvisorButton } from './IndexAdvisor/EnableIndexAdvisorButton' import { @@ -34,15 +37,16 @@ import { createIndexes, hasIndexRecommendations, } from './IndexAdvisor/index-advisor.utils' -import { QueryPerformanceRow } from './QueryPerformance.types' import { IndexAdvisorDisabledState } from './IndexAdvisor/IndexAdvisorDisabledState' import { IndexImprovementText } from './IndexAdvisor/IndexImprovementText' import { QueryPanelContainer, QueryPanelScoreSection, QueryPanelSection } from './QueryPanel' +import { QueryPerformanceRow } from './QueryPerformance.types' interface QueryIndexesProps { selectedRow: Pick columnName?: string suggestedSelectQuery?: string + prefetchedIndexAdvisorResult?: GetIndexAdvisorResultResponse | null onClose?: () => void } @@ -54,6 +58,7 @@ export const QueryIndexes = ({ selectedRow, columnName, suggestedSelectQuery, + prefetchedIndexAdvisorResult, onClose, }: QueryIndexesProps) => { // [Joshen] TODO implement this logic once the linter rules are in @@ -83,22 +88,30 @@ export const QueryIndexes = ({ const { isIndexAdvisorEnabled } = useIndexAdvisorStatus() + const hasPrefetchedResult = prefetchedIndexAdvisorResult !== undefined + const { - data: indexAdvisorResult, + data: fetchedIndexAdvisorResult, error: indexAdvisorError, refetch, isError: isErrorIndexAdvisorResult, - isSuccess: isSuccessIndexAdvisorResult, - isLoading: isLoadingIndexAdvisorResult, + isSuccess: isFetchSuccessIndexAdvisorResult, + isLoading: isFetchLoadingIndexAdvisorResult, } = useGetIndexAdvisorResult( { projectRef: project?.ref, connectionString: project?.connectionString, query: selectedRow?.['query'], }, - { enabled: isIndexAdvisorEnabled } + { enabled: isIndexAdvisorEnabled && !hasPrefetchedResult } ) + const indexAdvisorResult = hasPrefetchedResult + ? prefetchedIndexAdvisorResult + : fetchedIndexAdvisorResult + const isSuccessIndexAdvisorResult = hasPrefetchedResult || isFetchSuccessIndexAdvisorResult + const isLoadingIndexAdvisorResult = hasPrefetchedResult ? false : isFetchLoadingIndexAdvisorResult + const { index_statements, startup_cost_after, @@ -208,7 +221,9 @@ export const QueryIndexes = ({ )} - +

Indexes in use

diff --git a/apps/studio/data/database/table-index-advisor-query.ts b/apps/studio/data/database/table-index-advisor-query.ts index 130140e1f3c..c682f42b090 100644 --- a/apps/studio/data/database/table-index-advisor-query.ts +++ b/apps/studio/data/database/table-index-advisor-query.ts @@ -36,6 +36,10 @@ export function getTableIndexAdvisorSql(schema: string, table: string): string { const escapedSchema = schema.replace(/'/g, "''") const escapedTable = table.replace(/'/g, "''") + // Escape regex metacharacters so schema/table names are matched literally + const regexSchema = escapedSchema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') + const regexTable = escapedTable.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&') + return /* SQL */ ` -- Get top 5 SELECT queries involving this table and run through index_advisor set search_path to public, extensions; @@ -48,14 +52,15 @@ with top_queries as ( statements.mean_exec_time + statements.mean_plan_time as mean_time from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid - where + where -- Filter for SELECT queries only (index_advisor only works with SELECT) (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') - -- Filter for queries involving our table (handles schema.table and just table references) + -- Filter for queries involving our table. Use regex word boundaries so that e.g. + -- looking for table "orders" does not match queries on "orders_items". and ( - lower(statements.query) like '%${escapedSchema.toLowerCase()}.${escapedTable.toLowerCase()}%' - or lower(statements.query) like '%from ${escapedTable.toLowerCase()}%' - or lower(statements.query) like '%join ${escapedTable.toLowerCase()}%' + lower(statements.query) ~ '(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])' + or lower(statements.query) ~ '(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])' + or lower(statements.query) ~ '(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])' ) -- Exclude system queries and statements.query not like '%pg_catalog%' @@ -80,6 +85,17 @@ left join lateral ( `.trim() } +// 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[] { @@ -92,8 +108,9 @@ function extractColumnsFromIndexStatements(indexStatements: string[]): string[] const columnPart = match[1] // Split by comma and clean up each column name columnPart.split(',').forEach((col) => { - const cleanedCol = col.trim().replace(/^"(.+)"$/, '$1') // Remove quotes if present - if (cleanedCol) { + 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) } })