useQueryPerformanceQuery.ts220 lines · main
| 1 | import { |
| 2 | ident, |
| 3 | joinSqlFragments, |
| 4 | keyword, |
| 5 | literal, |
| 6 | safeSql, |
| 7 | type SafeSqlFragment, |
| 8 | } from '@supabase/pg-meta' |
| 9 | import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' |
| 10 | |
| 11 | import { PRESET_CONFIG } from '../Reports/Reports.constants' |
| 12 | import { Presets } from '../Reports/Reports.types' |
| 13 | import { |
| 14 | QueryPerformanceRow, |
| 15 | QueryPerformanceSort, |
| 16 | QueryPerformanceSQLParams, |
| 17 | } from './QueryPerformance.types' |
| 18 | import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' |
| 19 | import { executeSql } from '@/data/sql/execute-sql-query' |
| 20 | import useDbQuery from '@/hooks/analytics/useDbQuery' |
| 21 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 22 | import { IS_PLATFORM } from '@/lib/constants' |
| 23 | import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' |
| 24 | |
| 25 | const VALID_SORT_COLUMNS: ReadonlySet<string> = new Set<QueryPerformanceSort['column']>([ |
| 26 | 'query', |
| 27 | 'rolname', |
| 28 | 'total_time', |
| 29 | 'prop_total_time', |
| 30 | 'calls', |
| 31 | 'avg_rows', |
| 32 | 'max_time', |
| 33 | 'mean_time', |
| 34 | 'min_time', |
| 35 | ]) |
| 36 | |
| 37 | export function generateQueryPerformanceSql({ |
| 38 | preset, |
| 39 | orderBy, |
| 40 | searchQuery = '', |
| 41 | roles = [], |
| 42 | sources = [], |
| 43 | minCalls = 0, |
| 44 | minTotalTime = 0, |
| 45 | runIndexAdvisor = false, |
| 46 | filterIndexAdvisor = false, |
| 47 | page = 1, |
| 48 | pageSize = 20, |
| 49 | }: QueryPerformanceSQLParams) { |
| 50 | const safePage = Number.isFinite(page) ? Math.max(1, Math.floor(page)) : 1 |
| 51 | const safePageSize = Number.isFinite(pageSize) |
| 52 | ? Math.min(Math.max(1, Math.floor(pageSize)), 100) |
| 53 | : 20 |
| 54 | |
| 55 | const queryPerfQueries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE] |
| 56 | const baseSQL = queryPerfQueries.queries[preset] |
| 57 | |
| 58 | const isValidOrderBy = |
| 59 | orderBy != null && |
| 60 | VALID_SORT_COLUMNS.has(orderBy.column) && |
| 61 | (orderBy.order === 'asc' || orderBy.order === 'desc') |
| 62 | |
| 63 | const orderBySql = isValidOrderBy |
| 64 | ? safeSql`ORDER BY ${ident(orderBy!.column)} ${keyword(orderBy!.order)}` |
| 65 | : undefined |
| 66 | |
| 67 | const whereConditions: SafeSqlFragment[] = [] |
| 68 | if (roles.length > 0) { |
| 69 | whereConditions.push( |
| 70 | safeSql`auth.rolname in (${joinSqlFragments( |
| 71 | roles.map((r) => literal(r)), |
| 72 | ', ' |
| 73 | )})` |
| 74 | ) |
| 75 | } |
| 76 | if (searchQuery.length > 0) { |
| 77 | whereConditions.push(safeSql`statements.query ~* ${literal(searchQuery)}`) |
| 78 | } |
| 79 | if (sources.includes('dashboard') && !sources.includes('non-dashboard')) { |
| 80 | whereConditions.push(safeSql`statements.query ~* 'source: dashboard'`) |
| 81 | } |
| 82 | if (sources.includes('non-dashboard') && !sources.includes('dashboard')) { |
| 83 | whereConditions.push(safeSql`statements.query !~* 'source: dashboard'`) |
| 84 | } |
| 85 | if (Number.isFinite(minCalls) && minCalls > 0) { |
| 86 | whereConditions.push(safeSql`statements.calls >= ${literal(minCalls)}`) |
| 87 | } |
| 88 | if (Number.isFinite(minTotalTime) && minTotalTime > 0) { |
| 89 | whereConditions.push( |
| 90 | safeSql`(statements.total_exec_time + statements.total_plan_time) >= ${literal(minTotalTime)}` |
| 91 | ) |
| 92 | } |
| 93 | |
| 94 | const whereSql = joinSqlFragments(whereConditions, ' AND ') |
| 95 | |
| 96 | if (baseSQL.queryType !== 'db') { |
| 97 | throw new Error( |
| 98 | `Query performance presets must be db queries; got ${baseSQL.queryType} for preset ${preset}` |
| 99 | ) |
| 100 | } |
| 101 | |
| 102 | const sql = baseSQL.safeSql( |
| 103 | [], |
| 104 | whereSql.length > 0 ? safeSql`WHERE ${whereSql}` : undefined, |
| 105 | orderBySql, |
| 106 | runIndexAdvisor, |
| 107 | filterIndexAdvisor, |
| 108 | safePage, |
| 109 | safePageSize |
| 110 | ) |
| 111 | |
| 112 | return { sql, whereSql, orderBySql } |
| 113 | } |
| 114 | |
| 115 | export const useQueryPerformanceQuery = (props: QueryPerformanceSQLParams) => { |
| 116 | const { sql, whereSql, orderBySql } = generateQueryPerformanceSql(props) |
| 117 | return useDbQuery({ sql, params: undefined, where: whereSql, orderBy: orderBySql }) |
| 118 | } |
| 119 | |
| 120 | export interface QueryPerformanceInfiniteHook { |
| 121 | data: QueryPerformanceRow[] | undefined |
| 122 | isLoading: boolean |
| 123 | isRefetching: boolean |
| 124 | isFetchingNextPage: boolean |
| 125 | hasNextPage: boolean |
| 126 | error: unknown |
| 127 | fetchNextPage: () => void |
| 128 | refetch: () => void |
| 129 | resolvedSql: string |
| 130 | } |
| 131 | |
| 132 | export const useQueryPerformanceInfiniteQuery = ( |
| 133 | props: Omit<QueryPerformanceSQLParams, 'page'> |
| 134 | ): QueryPerformanceInfiniteHook => { |
| 135 | const queryClient = useQueryClient() |
| 136 | const { data: project } = useSelectedProjectQuery() |
| 137 | const state = useDatabaseSelectorStateSnapshot() |
| 138 | const { data: databases } = useReadReplicasQuery({ projectRef: project?.ref }) |
| 139 | const connectionString = (databases || []).find( |
| 140 | (db) => db.identifier === state.selectedDatabaseId |
| 141 | )?.connectionString |
| 142 | |
| 143 | // Clamp pageSize the same way generateQueryPerformanceSql does so getNextPageParam |
| 144 | // and the queryKey are always consistent with the SQL actually executed. |
| 145 | const rawPageSize = props.pageSize |
| 146 | const safePageSize = Number.isFinite(rawPageSize) |
| 147 | ? Math.min(Math.max(1, Math.floor(rawPageSize!)), 100) |
| 148 | : 20 |
| 149 | const { sql: page1Sql } = generateQueryPerformanceSql({ |
| 150 | ...props, |
| 151 | page: 1, |
| 152 | pageSize: safePageSize, |
| 153 | }) |
| 154 | |
| 155 | // When a read-replica is selected, require its connection string before fetching. |
| 156 | // Falling back to the primary's connection string would silently query the wrong database. |
| 157 | const isPrimarySelected = !state.selectedDatabaseId || state.selectedDatabaseId === project?.ref |
| 158 | const effectiveConnectionString = isPrimarySelected |
| 159 | ? (connectionString ?? project?.connectionString) |
| 160 | : connectionString |
| 161 | |
| 162 | const { data, isPending, isRefetching, isFetchingNextPage, hasNextPage, error, fetchNextPage } = |
| 163 | useInfiniteQuery({ |
| 164 | queryKey: [ |
| 165 | 'projects', |
| 166 | project?.ref, |
| 167 | 'query-performance-infinite', |
| 168 | { |
| 169 | ...props, |
| 170 | pageSize: safePageSize, |
| 171 | identifier: state.selectedDatabaseId, |
| 172 | connectionString: effectiveConnectionString, |
| 173 | }, |
| 174 | ], |
| 175 | initialPageParam: 1, |
| 176 | queryFn: ({ pageParam, signal }) => { |
| 177 | const { sql } = generateQueryPerformanceSql({ |
| 178 | ...props, |
| 179 | page: pageParam, |
| 180 | pageSize: safePageSize, |
| 181 | }) |
| 182 | return executeSql<QueryPerformanceRow[]>( |
| 183 | { |
| 184 | projectRef: project?.ref, |
| 185 | connectionString: effectiveConnectionString, |
| 186 | sql, |
| 187 | }, |
| 188 | signal |
| 189 | ).then((res) => res.result) |
| 190 | }, |
| 191 | getNextPageParam: (lastPage, allPages) => { |
| 192 | return lastPage.length < safePageSize ? undefined : allPages.length + 1 |
| 193 | }, |
| 194 | // Don't run until we have a connection string for the selected database. |
| 195 | // For replicas this prevents a silent fallback to the primary before replicas load. |
| 196 | // In self-hosted mode (IS_PLATFORM=false) there is no real connection string, so we |
| 197 | // skip the check — executeSql works fine without one on self-hosted deployments. |
| 198 | enabled: Boolean(project?.ref) && (!IS_PLATFORM || Boolean(effectiveConnectionString)), |
| 199 | refetchOnWindowFocus: false, |
| 200 | refetchOnReconnect: false, |
| 201 | }) |
| 202 | |
| 203 | return { |
| 204 | data: data?.pages.flatMap((page) => page) ?? undefined, |
| 205 | isLoading: isPending, |
| 206 | isRefetching, |
| 207 | isFetchingNextPage, |
| 208 | hasNextPage: hasNextPage ?? false, |
| 209 | error, |
| 210 | fetchNextPage, |
| 211 | // Reset to page 1 instead of re-fetching all loaded pages, avoiding a burst |
| 212 | // of N requests when the user clicks Refresh after scrolling through multiple pages. |
| 213 | refetch: () => |
| 214 | queryClient.resetQueries({ |
| 215 | queryKey: ['projects', project?.ref, 'query-performance-infinite'], |
| 216 | exact: false, |
| 217 | }), |
| 218 | resolvedSql: page1Sql, |
| 219 | } |
| 220 | } |