diff --git a/apps/studio/components/grid/components/grid/ColumnHeader.tsx b/apps/studio/components/grid/components/grid/ColumnHeader.tsx index 0997dbe204f0f..06fad9c4f381b 100644 --- a/apps/studio/components/grid/components/grid/ColumnHeader.tsx +++ b/apps/studio/components/grid/components/grid/ColumnHeader.tsx @@ -1,10 +1,14 @@ import type { XYCoord } from 'dnd-core' -import { ArrowRight, Key, Link, Lock } from 'lucide-react' +import { ArrowRight, Key, Link, Lock, Lightbulb } from 'lucide-react' import { useEffect, useRef } from 'react' import { useDrag, useDrop } from 'react-dnd' import { getForeignKeyCascadeAction } from 'components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.utils' import { FOREIGN_KEY_CASCADE_ACTION } from 'data/database/database-query-constants' +import { + useColumnHasIndexSuggestion, + useTableIndexAdvisor, +} from '../../context/TableIndexAdvisorContext' import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' import type { ColumnHeaderProps, ColumnType, DragItem, GridForeignKey } from '../../types' @@ -24,6 +28,8 @@ export function ColumnHeader({ const columnFormat = getColumnFormat(columnType, format) const snap = useTableEditorTableStateSnapshot() const hoverValue = column.name as string + const hasIndexSuggestion = useColumnHasIndexSuggestion(column.name as string) + const { openSheet } = useTableIndexAdvisor() // keep snap.gridColumns' order in sync with data grid component useEffect(() => { @@ -144,6 +150,21 @@ export function ColumnHeader({ )} + {hasIndexSuggestion && ( + + + + + + Index might improve performance. Click for details. + + + )} diff --git a/apps/studio/components/grid/components/header/RefreshButton.tsx b/apps/studio/components/grid/components/header/RefreshButton.tsx index d63217423209c..da14e7798724d 100644 --- a/apps/studio/components/grid/components/header/RefreshButton.tsx +++ b/apps/studio/components/grid/components/header/RefreshButton.tsx @@ -2,6 +2,7 @@ import { useQueryClient } from '@tanstack/react-query' import { RefreshCw } from 'lucide-react' import { useParams } from 'common' +import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { tableRowKeys } from 'data/table-rows/keys' @@ -13,10 +14,12 @@ export type RefreshButtonProps = { export const RefreshButton = ({ tableId, isRefetching }: RefreshButtonProps) => { const { ref } = useParams() const queryClient = useQueryClient() + const { invalidate: invalidateIndexAdvisor } = useTableIndexAdvisor() const queryKey = tableRowKeys.tableRowsAndCount(ref, tableId) async function onClick() { await queryClient.invalidateQueries({ queryKey }) + await invalidateIndexAdvisor() } return ( diff --git a/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx b/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx new file mode 100644 index 0000000000000..5423b3c315f4b --- /dev/null +++ b/apps/studio/components/grid/context/TableIndexAdvisorContext.tsx @@ -0,0 +1,144 @@ +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 { databaseKeys } from 'data/database/keys' +import { + useTableIndexAdvisorQuery, + TableIndexAdvisorData, + IndexAdvisorSuggestion, +} from 'data/database/table-index-advisor-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { Sheet, SheetContent, SheetHeader, SheetTitle } from 'ui' + +interface TableIndexAdvisorContextValue { + isLoading: boolean + isAvailable: boolean + isEnabled: boolean + columnsWithSuggestions: string[] + suggestions: TableIndexAdvisorData['suggestions'] + openSheet: (columnName: string) => void + getSuggestionsForColumn: (columnName: string) => IndexAdvisorSuggestion[] + invalidate: () => Promise +} + +const TableIndexAdvisorContext = createContext({ + isLoading: false, + isAvailable: false, + isEnabled: false, + columnsWithSuggestions: [], + suggestions: [], + openSheet: () => {}, + getSuggestionsForColumn: () => [], + invalidate: async () => {}, +}) + +interface TableIndexAdvisorProviderProps { + schema: string + table: string +} + +export function TableIndexAdvisorProvider({ + children, + schema, + table, +}: PropsWithChildren) { + const { data: project } = useSelectedProjectQuery() + const { isIndexAdvisorAvailable, isIndexAdvisorEnabled } = useIndexAdvisorStatus() + const queryClient = useQueryClient() + const [isSheetOpen, setIsSheetOpen] = useState(false) + const [selectedColumn, setSelectedColumn] = useState(undefined) + + const { data, isLoading } = useTableIndexAdvisorQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + schema, + table, + }, + { + enabled: isIndexAdvisorEnabled && !!schema && !!table, + } + ) + + const openSheet = useCallback((columnName: string) => { + setSelectedColumn(columnName) + setIsSheetOpen(true) + }, []) + + const closeSheet = useCallback(() => { + setIsSheetOpen(false) + setSelectedColumn(undefined) + }, []) + + const getSuggestionsForColumn = useCallback( + (columnName: string): IndexAdvisorSuggestion[] => { + if (!data?.suggestions) return [] + // Filter suggestions that include this column in their index statements + return data.suggestions.filter((suggestion) => + 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')) + return columns.includes(columnName) + } + return false + }) + ) + }, + [data?.suggestions] + ) + + const invalidate = useCallback(async () => { + if (project?.ref && schema && table) { + await queryClient.invalidateQueries({ + queryKey: databaseKeys.tableIndexAdvisor(project.ref, schema, table), + }) + } + }, [queryClient, project?.ref, schema, table]) + + // Get the first suggestion for the selected column to pass to QueryIndexes + const selectedSuggestion = selectedColumn ? getSuggestionsForColumn(selectedColumn)[0] : null + + const value: TableIndexAdvisorContextValue = { + isLoading, + isAvailable: isIndexAdvisorAvailable, + isEnabled: isIndexAdvisorEnabled, + columnsWithSuggestions: data?.columnsWithSuggestions ?? [], + suggestions: data?.suggestions ?? [], + openSheet, + getSuggestionsForColumn, + invalidate, + } + + return ( + + {children} + !open && closeSheet()}> + + + Index Recommendation + + {selectedSuggestion && ( + + )} + + + + ) +} + +export function useTableIndexAdvisor() { + return useContext(TableIndexAdvisorContext) +} + +export function useColumnHasIndexSuggestion(columnName: string): boolean { + const { columnsWithSuggestions } = useTableIndexAdvisor() + return columnsWithSuggestions.includes(columnName) +} diff --git a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx index 8afc26625ee47..371e7131c9fd5 100644 --- a/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/QueryIndexes.tsx @@ -1,5 +1,5 @@ -import { Check, Lightbulb, Table2 } from 'lucide-react' -import { useEffect, useState } from 'react' +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' @@ -33,18 +33,28 @@ 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' interface QueryIndexesProps { - selectedRow: any + selectedRow: Pick + columnName?: string + suggestedSelectQuery?: string + + onClose?: () => void } // [Joshen] There's several more UX things we can do to help ease the learning curve of indexes I think // e.g understanding "costs", what numbers of "costs" are actually considered insignificant -export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { +export const QueryIndexes = ({ + selectedRow, + columnName, + suggestedSelectQuery, + onClose, +}: QueryIndexesProps) => { // [Joshen] TODO implement this logic once the linter rules are in const isLinterWarning = false const { data: project } = useSelectedProjectQuery() @@ -142,6 +152,8 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { setIsExecuting(false) } finally { setIsExecuting(false) + + onClose?.() } } @@ -165,7 +177,36 @@ export const QueryIndexes = ({ selectedRow }: QueryIndexesProps) => { } return ( - + + {(columnName || suggestedSelectQuery) && ( + +
+
+

Recommendation reason

+ {columnName && ( +

+ Recommendation for column: {columnName} +

+ )} +
+ {suggestedSelectQuery && ( +
+

Based on the following query:

+ code]:m-0 [&>code>span]:flex [&>code>span]:flex-wrap' + )} + /> +
+ )} +
+
+ )}

Indexes in use

diff --git a/apps/studio/components/interfaces/QueryPerformance/hooks/useIndexInvalidation.ts b/apps/studio/components/interfaces/QueryPerformance/hooks/useIndexInvalidation.ts index 017b6304eac78..8679e85f1b2a7 100644 --- a/apps/studio/components/interfaces/QueryPerformance/hooks/useIndexInvalidation.ts +++ b/apps/studio/components/interfaces/QueryPerformance/hooks/useIndexInvalidation.ts @@ -15,6 +15,8 @@ import { QUERY_PERFORMANCE_REPORT_TYPES, } from '../QueryPerformance.constants' import { useIndexAdvisorStatus } from './useIsIndexAdvisorStatus' +import { useTableEditorTableStateSnapshot } from 'state/table-editor-table' +import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext' export function useIndexInvalidation() { const router = useRouter() @@ -29,6 +31,8 @@ export function useIndexInvalidation() { preset: parseAsString.withDefault('unified'), }) + const { invalidate: invalidateTableIndexAdvisor } = useTableIndexAdvisor() + const preset = QUERY_PERFORMANCE_PRESET_MAP[urlPreset as QUERY_PERFORMANCE_REPORT_TYPES] const orderBy = !!sort ? ({ column: sort, order } as QueryPerformanceSort) : undefined const roles = router?.query?.roles ?? [] @@ -47,5 +51,7 @@ export function useIndexInvalidation() { queryKey: databaseKeys.indexAdvisorFromQuery(project?.ref, ''), }) queryClient.invalidateQueries({ queryKey: databaseIndexesKeys.list(project?.ref) }) - }, [queryPerformanceQuery, queryClient, project?.ref]) + + invalidateTableIndexAdvisor() + }, [queryPerformanceQuery, queryClient, project?.ref, invalidateTableIndexAdvisor]) } diff --git a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx index 60f4fafec628f..c1f7675a84a5e 100644 --- a/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx +++ b/apps/studio/components/interfaces/SQLEditor/RenameQueryModal.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useCheckOpenAIKeyQuery } from 'data/ai/check-api-key-query' import { useSqlTitleGenerateMutation } from 'data/ai/sql-title-mutation' import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query' import { getContentById } from 'data/content/content-id-query' @@ -13,6 +15,7 @@ import { Snippet } from 'data/content/sql-folders-query' import type { SqlSnippet } from 'data/content/sql-snippets-query' import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { useRouter } from 'next/router' import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2' import { createTabId, useTabsStateSnapshot } from 'state/tabs' import { AiIconAnimation, Button, Form, Input, Modal } from 'ui' @@ -32,6 +35,7 @@ const RenameQueryModal = ({ onComplete, }: RenameQueryModalProps) => { const { ref } = useParams() + const router = useRouter() const { data: organization } = useSelectedOrganizationQuery() const snapV2 = useSqlEditorV2StateSnapshot() @@ -61,6 +65,8 @@ const RenameQueryModal = ({ toast.error(`Failed to rename query: ${error.message}`) }, }) + const { data: check } = useCheckOpenAIKeyQuery() + const isApiKeySet = !!check?.hasKey const generateTitle = async () => { if ('content' in snippet && isSQLSnippet) { @@ -149,11 +155,19 @@ const RenameQueryModal = ({ />
{!hasHipaaAddon && ( - + )}
{ return } - if (!isHipaaProjectDisallowed && snippet?.snippet.name === untitledSnippetTitle) { + if ( + !isHipaaProjectDisallowed && + snippet?.snippet.name.startsWith(untitledSnippetTitle) && + IS_PLATFORM + ) { // Intentionally don't await title gen (lazy) setAiTitle(id, sql) } diff --git a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx index 1de2e9d886eb3..aaa9cdcd4c186 100644 --- a/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx +++ b/apps/studio/components/interfaces/SQLEditor/UtilityPanel/UtilityActions.tsx @@ -92,23 +92,27 @@ const UtilityActions = ({ {intellisenseEnabled && } - - { - if (isFavorite) removeFavorite() - else addFavorite() - }} - > - - {isFavorite ? 'Remove from' : 'Add to'} favorites - + {IS_PLATFORM && ( + <> + + { + if (isFavorite) removeFavorite() + else addFavorite() + }} + > + + {isFavorite ? 'Remove from' : 'Add to'} favorites + + + )} Prettify SQL diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index d4aea095088d2..83781c793ac0a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -1,11 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' +import { Lightbulb, Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react' import Link from 'next/link' import { useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' import { RefreshButton } from 'components/grid/components/header/RefreshButton' +import { useTableIndexAdvisor } from 'components/grid/context/TableIndexAdvisorContext' +import { EnableIndexAdvisorButton } from 'components/interfaces/QueryPerformance/IndexAdvisor/EnableIndexAdvisorButton' import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils' import { APIDocsButton } from 'components/ui/APIDocsButton' import { ButtonTooltip } from 'components/ui/ButtonTooltip' @@ -64,6 +66,10 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp // need project lints to get security status for views const { data: lints = [] } = useProjectLintsQuery({ projectRef: project?.ref }) + // Use table-specific index advisor context + const { isAvailable: isIndexAdvisorAvailable, isEnabled: isIndexAdvisorEnabled } = + useTableIndexAdvisor() + const isTable = isTableLike(table) const isForeignTable = isTableLikeForeignTable(table) const isView = isTableLikeView(table) @@ -336,6 +342,32 @@ export const GridHeaderActions = ({ table, isRefetching }: GridHeaderActionsProp ) ) : null} + {isTable && isIndexAdvisorAvailable && !isIndexAdvisorEnabled && ( + + + + + +

+ Index Advisor +

+
+

+ Index Advisor recommends indexes to improve query performance on this table. +

+

+ Enable Index Advisor to get recommendations based on your actual query patterns. +

+
+ +
+
+
+
+ )} + {isTable && activeRealtimeVariant === RealtimeButtonVariant.TRIGGERS ? (