index-advisor.utils.ts192 lines · main
| 1 | import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta' |
| 2 | import { toast } from 'sonner' |
| 3 | |
| 4 | import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' |
| 5 | import { GetIndexAdvisorResultResponse } from '@/data/database/retrieve-index-advisor-result-query' |
| 6 | import { executeSql } from '@/data/sql/execute-sql-query' |
| 7 | import { INTERNAL_SCHEMAS } from '@/hooks/useProtectedSchemas' |
| 8 | |
| 9 | /** |
| 10 | * Gets the required extensions for index advisor |
| 11 | * @param extensions Array of database extensions |
| 12 | * @returns Object containing hypopg, index_advisor, and test_extension extensions if they exist |
| 13 | */ |
| 14 | export function getIndexAdvisorExtensions(extensions: DatabaseExtension[] = []) { |
| 15 | const hypopg = extensions.find((ext) => ext.name === 'hypopg') |
| 16 | const indexAdvisor = extensions.find((ext) => ext.name === 'index_advisor') |
| 17 | return { hypopg, indexAdvisor } |
| 18 | } |
| 19 | |
| 20 | /** |
| 21 | * Calculates the percentage improvement between before and after costs |
| 22 | * |
| 23 | * @param costBefore Cost before optimization |
| 24 | * @param costAfter Cost after optimization |
| 25 | * @returns Percentage improvement, or 0 if inputs are invalid |
| 26 | */ |
| 27 | export function calculateImprovement( |
| 28 | costBefore: number | undefined, |
| 29 | costAfter: number | undefined |
| 30 | ): number { |
| 31 | if (costBefore === undefined || costAfter === undefined) { |
| 32 | return 0 |
| 33 | } |
| 34 | |
| 35 | const before = Number(costBefore) |
| 36 | const after = Number(costAfter) |
| 37 | |
| 38 | if (before <= 0 || before <= after) { |
| 39 | return 0 |
| 40 | } |
| 41 | |
| 42 | return ((before - after) / before) * 100 |
| 43 | } |
| 44 | |
| 45 | interface CreateIndexParams { |
| 46 | projectRef?: string |
| 47 | connectionString?: string | null |
| 48 | indexStatements: SafeSqlFragment[] |
| 49 | onSuccess?: () => void |
| 50 | onError?: (error: any) => void |
| 51 | } |
| 52 | |
| 53 | /** |
| 54 | * Creates database indexes using the provided SQL statements |
| 55 | * |
| 56 | * @param params Parameters for index creation |
| 57 | * @returns Promise that resolves when the index creation completes |
| 58 | */ |
| 59 | export async function createIndexes({ |
| 60 | projectRef, |
| 61 | connectionString, |
| 62 | indexStatements, |
| 63 | onSuccess, |
| 64 | onError, |
| 65 | }: CreateIndexParams): Promise<void> { |
| 66 | if (!projectRef) { |
| 67 | const error = new Error('Project ref is required') |
| 68 | if (onError) onError(error) |
| 69 | return Promise.reject(error) |
| 70 | } |
| 71 | |
| 72 | if (indexStatements.length === 0) { |
| 73 | const error = new Error('No index statements provided') |
| 74 | if (onError) onError(error) |
| 75 | return Promise.reject(error) |
| 76 | } |
| 77 | |
| 78 | try { |
| 79 | await executeSql({ |
| 80 | projectRef, |
| 81 | connectionString, |
| 82 | sql: safeSql`${joinSqlFragments(indexStatements, ';\n')};`, |
| 83 | }) |
| 84 | |
| 85 | toast.success('Successfully created index') |
| 86 | if (onSuccess) onSuccess() |
| 87 | return Promise.resolve() |
| 88 | } catch (error: any) { |
| 89 | toast.error(`Failed to create index: ${error.message}`) |
| 90 | if (onError) onError(error) |
| 91 | return Promise.reject(error) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | /** |
| 96 | * Checks if the index advisor result contains recommendations |
| 97 | * |
| 98 | * @param result The index advisor result object |
| 99 | * @param isSuccess Whether the query was successful |
| 100 | * @returns Whether there are index recommendations available |
| 101 | */ |
| 102 | export function hasIndexRecommendations( |
| 103 | result: GetIndexAdvisorResultResponse | undefined | null, |
| 104 | isSuccess: boolean |
| 105 | ): boolean { |
| 106 | return Boolean(isSuccess && result?.index_statements && result.index_statements.length > 0) |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Filters out index statements that reference protected schemas |
| 111 | * Index statements are typically in the format: "CREATE INDEX ON schema.table USING ..." |
| 112 | * |
| 113 | * @param indexStatements Array of index statement strings |
| 114 | * @returns Filtered array excluding statements referencing protected schemas |
| 115 | */ |
| 116 | export function filterProtectedSchemaIndexStatements( |
| 117 | indexStatements: SafeSqlFragment[] |
| 118 | ): SafeSqlFragment[] { |
| 119 | if (!indexStatements || indexStatements.length === 0) { |
| 120 | return [] |
| 121 | } |
| 122 | |
| 123 | return indexStatements.filter((statement) => { |
| 124 | // Match patterns like "CREATE INDEX ON schema.table" or "CREATE INDEX ON "schema"."table"" |
| 125 | // Handle both quoted and unquoted schema names |
| 126 | const schemaMatch = statement.match(/ON\s+(?:"?(\w+)"?\.|(\w+)\.)/i) |
| 127 | |
| 128 | if (!schemaMatch) { |
| 129 | // If we can't parse the schema, keep it (safer to show than hide) |
| 130 | return true |
| 131 | } |
| 132 | |
| 133 | // Extract schema name (handle both quoted and unquoted) |
| 134 | const schemaName = schemaMatch[1] || schemaMatch[2] |
| 135 | |
| 136 | if (!schemaName) { |
| 137 | return true |
| 138 | } |
| 139 | |
| 140 | // Check if schema is in the protected schemas list |
| 141 | return !INTERNAL_SCHEMAS.includes(schemaName.toLowerCase()) |
| 142 | }) |
| 143 | } |
| 144 | |
| 145 | /** |
| 146 | * Filters an index advisor result to remove recommendations for protected schemas |
| 147 | * |
| 148 | * @param result The index advisor result object |
| 149 | * @returns Filtered result with protected schema recommendations removed |
| 150 | */ |
| 151 | export function filterProtectedSchemaIndexAdvisorResult( |
| 152 | result: GetIndexAdvisorResultResponse | null | undefined |
| 153 | ): GetIndexAdvisorResultResponse | null { |
| 154 | if (!result || !result.index_statements) { |
| 155 | return result ?? null |
| 156 | } |
| 157 | |
| 158 | const filteredStatements = filterProtectedSchemaIndexStatements(result.index_statements) |
| 159 | |
| 160 | // If all statements were filtered out, return null |
| 161 | if (filteredStatements.length === 0) { |
| 162 | return null |
| 163 | } |
| 164 | |
| 165 | return { |
| 166 | ...result, |
| 167 | index_statements: filteredStatements, |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | /** |
| 172 | * Checks if a query involves protected schemas by examining the SQL query text |
| 173 | * |
| 174 | * @param query The SQL query string |
| 175 | * @returns Whether the query involves protected schemas |
| 176 | */ |
| 177 | export function queryInvolvesProtectedSchemas(query: string | undefined | null): boolean { |
| 178 | if (!query) return false |
| 179 | |
| 180 | const queryLower = query.toLowerCase() |
| 181 | |
| 182 | // Check if the query references any protected schemas |
| 183 | // Match patterns like "schema.table", "FROM schema.table", "JOIN schema.table", etc. |
| 184 | return INTERNAL_SCHEMAS.some((schema) => { |
| 185 | // Match schema.table patterns (with or without quotes) |
| 186 | const schemaPattern = new RegExp( |
| 187 | `(?:from|join|update|insert\\s+into|delete\\s+from)\\s+(?:${schema}\\.|"${schema}"\\.)`, |
| 188 | 'i' |
| 189 | ) |
| 190 | return schemaPattern.test(queryLower) |
| 191 | }) |
| 192 | } |