diff --git a/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts index 78664c0fd18..17c19dcd67f 100644 --- a/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts +++ b/apps/studio/components/interfaces/Observability/useSlowQueriesCount.ts @@ -1,9 +1,10 @@ +import { safeSql, type SafeSqlFragment } from '@supabase/pg-meta' import { useMemo } from 'react' import useDbQuery from '@/hooks/analytics/useDbQuery' -export function buildSlowQueriesCountSql(): string { - return ` +export function buildSlowQueriesCountSql(): SafeSqlFragment { + return safeSql` -- observability-slow-queries-count set search_path to public, extensions; diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts index 0f3fd2c06a3..8b87bc260e5 100644 --- a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsIssues.utils.test.ts @@ -50,7 +50,7 @@ describe('classifyQuery', () => { ...baseRow, index_advisor_result: { errors: [], - index_statements: ['CREATE INDEX ...'], + index_statements: [safeSql`CREATE INDEX ...`], startup_cost_before: 0, startup_cost_after: 0, total_cost_before: 0, @@ -87,7 +87,7 @@ describe('classifyQuery', () => { ...baseRow, index_advisor_result: { errors: ['critical error'], - index_statements: ['CREATE INDEX ...'], + index_statements: [safeSql`CREATE INDEX ...`], startup_cost_before: 0, startup_cost_after: 0, total_cost_before: 0, 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 723129f64b2..12f476e7066 100644 --- a/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts +++ b/apps/studio/components/interfaces/QueryPerformance/IndexAdvisor/index-advisor.utils.ts @@ -1,3 +1,4 @@ +import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta' import { toast } from 'sonner' import { DatabaseExtension } from '@/data/database-extensions/database-extensions-query' @@ -44,7 +45,7 @@ export function calculateImprovement( interface CreateIndexParams { projectRef?: string connectionString?: string | null - indexStatements: string[] + indexStatements: SafeSqlFragment[] onSuccess?: () => void onError?: (error: any) => void } @@ -78,7 +79,7 @@ export async function createIndexes({ await executeSql({ projectRef, connectionString, - sql: indexStatements.join(';\n') + ';', + sql: safeSql`${joinSqlFragments(indexStatements, ';\n')};`, }) toast.success('Successfully created index') @@ -112,7 +113,9 @@ export function hasIndexRecommendations( * @param indexStatements Array of index statement strings * @returns Filtered array excluding statements referencing protected schemas */ -export function filterProtectedSchemaIndexStatements(indexStatements: string[]): string[] { +export function filterProtectedSchemaIndexStatements( + indexStatements: SafeSqlFragment[] +): SafeSqlFragment[] { if (!indexStatements || indexStatements.length === 0) { return [] } diff --git a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx index 7632da087b0..bf085c8b8ae 100644 --- a/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx +++ b/apps/studio/components/interfaces/QueryPerformance/WithStatements/WithStatements.tsx @@ -1,3 +1,4 @@ +import { safeSql } from '@supabase/pg-meta/src/pg-format' import { LOCAL_STORAGE_KEYS, useParams } from 'common' import { RefreshCw, RotateCcw, X } from 'lucide-react' import { parseAsString, useQueryStates } from 'nuqs' @@ -297,7 +298,7 @@ export const WithStatements = ({ await executeSql({ projectRef: project?.ref, connectionString, - sql: `SELECT pg_stat_statements_reset();`, + sql: safeSql`SELECT pg_stat_statements_reset();`, }) handleRefresh() setShowResetgPgStatStatements(false) diff --git a/apps/studio/components/interfaces/QueryPerformance/useQueryPerformanceQuery.ts b/apps/studio/components/interfaces/QueryPerformance/useQueryPerformanceQuery.ts index f1fc97ebbae..317205ab773 100644 --- a/apps/studio/components/interfaces/QueryPerformance/useQueryPerformanceQuery.ts +++ b/apps/studio/components/interfaces/QueryPerformance/useQueryPerformanceQuery.ts @@ -1,4 +1,11 @@ -import { ident, literal } from '@supabase/pg-meta/src/pg-format' +import { + ident, + joinSqlFragments, + keyword, + literal, + safeSql, + type SafeSqlFragment, +} from '@supabase/pg-meta' import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query' import { PRESET_CONFIG } from '../Reports/Reports.constants' @@ -54,36 +61,47 @@ export function generateQueryPerformanceSql({ (orderBy.order === 'asc' || orderBy.order === 'desc') const orderBySql = isValidOrderBy - ? `ORDER BY ${ident(orderBy!.column)} ${orderBy!.order}` + ? safeSql`ORDER BY ${ident(orderBy!.column)} ${keyword(orderBy!.order)}` : undefined - const whereConditions = [] + const whereConditions: SafeSqlFragment[] = [] if (roles.length > 0) { - whereConditions.push(`auth.rolname in (${roles.map((r) => `${literal(r)}`).join(', ')})`) + whereConditions.push( + safeSql`auth.rolname in (${joinSqlFragments( + roles.map((r) => literal(r)), + ', ' + )})` + ) } if (searchQuery.length > 0) { - whereConditions.push(`statements.query ~* ${literal(searchQuery)}`) + whereConditions.push(safeSql`statements.query ~* ${literal(searchQuery)}`) } if (sources.includes('dashboard') && !sources.includes('non-dashboard')) { - whereConditions.push(`statements.query ~* 'source: dashboard'`) + whereConditions.push(safeSql`statements.query ~* 'source: dashboard'`) } if (sources.includes('non-dashboard') && !sources.includes('dashboard')) { - whereConditions.push(`statements.query !~* 'source: dashboard'`) + whereConditions.push(safeSql`statements.query !~* 'source: dashboard'`) } if (Number.isFinite(minCalls) && minCalls > 0) { - whereConditions.push(`statements.calls >= ${minCalls}`) + whereConditions.push(safeSql`statements.calls >= ${literal(minCalls)}`) } if (Number.isFinite(minTotalTime) && minTotalTime > 0) { whereConditions.push( - `(statements.total_exec_time + statements.total_plan_time) >= ${minTotalTime}` + safeSql`(statements.total_exec_time + statements.total_plan_time) >= ${literal(minTotalTime)}` ) } - const whereSql = whereConditions.join(' AND ') + const whereSql = joinSqlFragments(whereConditions, ' AND ') - const sql = baseSQL.sql( + if (baseSQL.queryType !== 'db') { + throw new Error( + `Query performance presets must be db queries; got ${baseSQL.queryType} for preset ${preset}` + ) + } + + const sql = baseSQL.safeSql( [], - whereSql.length > 0 ? `WHERE ${whereSql}` : undefined, + whereSql.length > 0 ? safeSql`WHERE ${whereSql}` : undefined, orderBySql, runIndexAdvisor, filterIndexAdvisor, diff --git a/apps/studio/components/interfaces/Reports/Reports.constants.ts b/apps/studio/components/interfaces/Reports/Reports.constants.ts index c4d565f1278..904e9ac7d6d 100644 --- a/apps/studio/components/interfaces/Reports/Reports.constants.ts +++ b/apps/studio/components/interfaces/Reports/Reports.constants.ts @@ -1,3 +1,4 @@ +import { literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta' import dayjs from 'dayjs' import type { DatetimeHelper } from '../Settings/Logs/Logs.types' @@ -78,6 +79,10 @@ export const DEFAULT_QUERY_PARAMS = { iso_timestamp_end: REPORTS_DATEPICKER_HELPERS[0].calcTo(), } +function rewriteWhereToAnd(sql: SafeSqlFragment): SafeSqlFragment { + return sql.replace(/^WHERE/, 'AND') as SafeSqlFragment +} + export const generateRegexpWhere = (filters: ReportFilterItem[], prepend = true) => { if (filters.length === 0) return '' const conditions = filters @@ -380,7 +385,13 @@ limit 12 queries: { mostFrequentlyInvoked: { queryType: 'db', - sql: (_params, where, orderBy, runIndexAdvisor = false, _filterIndexAdvisor = false) => ` + safeSql: ( + _params, + where, + orderBy, + runIndexAdvisor = false, + _filterIndexAdvisor = false + ) => safeSql` -- reports-query-performance-most-frequently-invoked set search_path to public, extensions; @@ -410,7 +421,7 @@ select else 0 end as cache_hit_rate${ runIndexAdvisor - ? `, + ? safeSql`, case when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') then ( @@ -426,18 +437,24 @@ select ) else null end as index_advisor_result` - : '' + : safeSql`` } from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid -- skip queries that were never actually executed - WHERE statements.calls > 0 ${where ? where.replace(/^WHERE/, 'AND') : ''} - ${orderBy || 'order by statements.calls desc'} + WHERE statements.calls > 0 ${where ? rewriteWhereToAnd(where) : safeSql``} + ${orderBy || safeSql`order by statements.calls desc`} limit 20`, }, mostTimeConsuming: { queryType: 'db', - sql: (_, where, orderBy, runIndexAdvisor = false, _filterIndexAdvisor = false) => ` + safeSql: ( + _, + where, + orderBy, + runIndexAdvisor = false, + _filterIndexAdvisor = false + ) => safeSql` -- reports-query-performance-most-time-consuming set search_path to public, extensions; @@ -459,7 +476,7 @@ select 0 ) as prop_total_time${ runIndexAdvisor - ? `, + ? safeSql`, case when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') then ( @@ -475,18 +492,24 @@ select ) else null end as index_advisor_result` - : '' + : safeSql`` } from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid -- skip queries that were never actually executed - WHERE statements.calls > 0 ${where ? where.replace(/^WHERE/, 'AND') : ''} - ${orderBy || 'order by total_time desc'} + WHERE statements.calls > 0 ${where ? rewriteWhereToAnd(where) : safeSql``} + ${orderBy || safeSql`order by total_time desc`} limit 20`, }, slowestExecutionTime: { queryType: 'db', - sql: (_params, where, orderBy, runIndexAdvisor = false, _filterIndexAdvisor = false) => ` + safeSql: ( + _params, + where, + orderBy, + runIndexAdvisor = false, + _filterIndexAdvisor = false + ) => safeSql` -- reports-query-performance-slowest-execution-time set search_path to public, extensions; @@ -506,7 +529,7 @@ select -- mean_time, coalesce(statements.rows::numeric / nullif(statements.calls, 0), 0) as avg_rows${ runIndexAdvisor - ? `, + ? safeSql`, case when (lower(statements.query) like 'select%' or lower(statements.query) like 'with pgrst%') then ( @@ -522,18 +545,18 @@ select ) else null end as index_advisor_result` - : '' + : safeSql`` } from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid -- skip queries that were never actually executed - WHERE statements.calls > 0 ${where ? where.replace(/^WHERE/, 'AND') : ''} - ${orderBy || 'order by max_time desc'} + WHERE statements.calls > 0 ${where ? rewriteWhereToAnd(where) : safeSql``} + ${orderBy || safeSql`order by max_time desc`} limit 20`, }, queryHitRate: { queryType: 'db', - sql: (_params) => `-- reports-query-performance-cache-and-index-hit-rate + safeSql: (_params) => safeSql`-- reports-query-performance-cache-and-index-hit-rate select 'index hit rate' as name, (sum(idx_blks_hit)) / nullif(sum(idx_blks_hit + idx_blks_read),0) as ratio @@ -546,7 +569,7 @@ select }, unified: { queryType: 'db', - sql: ( + safeSql: ( _params, where, orderBy, @@ -565,7 +588,7 @@ select const baseCteLimit = runIndexAdvisor ? Math.min(baseScanTarget, INDEX_ADVISOR_SCAN_CAP) : baseScanTarget - const baseQuery = ` + const baseQuery = safeSql` -- reports-query-performance-unified set search_path to public, extensions; @@ -602,15 +625,15 @@ select from pg_stat_statements as statements inner join pg_authid as auth on statements.userid = auth.oid -- skip queries that were never actually executed - WHERE statements.calls > 0 ${where ? where.replace(/^WHERE/, 'AND') : ''} - ${orderBy || 'order by total_time desc'} - ${baseCteLimit !== null ? `limit ${baseCteLimit}` : ''} + WHERE statements.calls > 0 ${where ? rewriteWhereToAnd(where) : safeSql``} + ${orderBy || safeSql`order by total_time desc`} + ${baseCteLimit !== null ? safeSql`limit ${literal(baseCteLimit)}` : safeSql``} ), query_results as ( select base.*${ runIndexAdvisor - ? `, + ? safeSql`, case when (lower(base.query) like 'select%' or lower(base.query) like 'with pgrst%') then ( @@ -626,22 +649,22 @@ select ) else null end as index_advisor_result` - : '' + : safeSql`` } from base ) select * from query_results - ${filterIndexAdvisor && runIndexAdvisor ? `where (index_advisor_result->>'has_suggestion')::boolean = true` : ''} - ${orderBy || 'order by total_time desc'} - limit ${pageSize} offset ${offset}` + ${filterIndexAdvisor && runIndexAdvisor ? safeSql`where (index_advisor_result->>'has_suggestion')::boolean = true` : safeSql``} + ${orderBy || safeSql`order by total_time desc`} + limit ${literal(pageSize)} offset ${literal(offset)}` return baseQuery }, }, slowQueriesCount: { queryType: 'db', - sql: () => ` + safeSql: () => safeSql` -- reports-query-performance-slow-queries-count set search_path to public, extensions; @@ -654,7 +677,13 @@ select }, queryMetrics: { queryType: 'db', - sql: (_params, where, orderBy, _runIndexAdvisor = false, _filterIndexAdvisor = false) => ` + safeSql: ( + _params, + where, + orderBy, + _runIndexAdvisor = false, + _filterIndexAdvisor = false + ) => safeSql` -- reports-query-performance-metrics set search_path to public, extensions; @@ -670,8 +699,8 @@ select ) || '%' as cache_hit_rate FROM pg_stat_statements as statements -- skip queries that were never actually executed - WHERE statements.calls > 0 ${where ? where.replace(/^WHERE/, 'AND') : ''} - ${orderBy || ''}`, + WHERE statements.calls > 0 ${where ? rewriteWhereToAnd(where) : safeSql``} + ${orderBy || safeSql``}`, }, }, }, @@ -680,7 +709,7 @@ select queries: { largeObjects: { queryType: 'db', - sql: (_) => `-- reports-database-large-objects + safeSql: (_) => safeSql`-- reports-database-large-objects SELECT SCHEMA_NAME, relname, diff --git a/apps/studio/components/interfaces/Reports/Reports.queryPerformance.test.ts b/apps/studio/components/interfaces/Reports/Reports.queryPerformance.test.ts index 4e5df19a8ef..48836556f2c 100644 --- a/apps/studio/components/interfaces/Reports/Reports.queryPerformance.test.ts +++ b/apps/studio/components/interfaces/Reports/Reports.queryPerformance.test.ts @@ -5,7 +5,7 @@ import { Presets } from './Reports.types' const queries = PRESET_CONFIG[Presets.QUERY_PERFORMANCE].queries as Record< string, - { sql: (...args: any[]) => string } + { safeSql: (...args: any[]) => string } > const queryNames = [ @@ -20,12 +20,12 @@ const queryNames = [ describe('QUERY_PERFORMANCE SQL queries', () => { describe('calls > 0 base filter', () => { it.each(queryNames)('%s includes calls > 0 without user filters', (name) => { - const sql = queries[name].sql([], undefined, undefined) + const sql = queries[name].safeSql([], undefined, undefined) expect(sql).toContain('calls > 0') }) it.each(queryNames)('%s still includes calls > 0 when user filters are provided', (name) => { - const sql = queries[name].sql([], "WHERE auth.rolname in ('postgres')", undefined) + const sql = queries[name].safeSql([], "WHERE auth.rolname in ('postgres')", undefined) expect(sql).toContain('calls > 0') }) }) @@ -39,7 +39,7 @@ describe('QUERY_PERFORMANCE SQL queries', () => { 'slowestExecutionTime', 'unified', ] as const)('%s: user filters appended with AND (no duplicate WHERE)', (name) => { - const sql = queries[name].sql([], userWhere, undefined) + const sql = queries[name].safeSql([], userWhere, undefined) // Should not have two WHERE keywords in a row / duplicate WHERE expect(sql).not.toMatch(/WHERE\s+.*WHERE/s) // User filter condition should be present @@ -49,7 +49,7 @@ describe('QUERY_PERFORMANCE SQL queries', () => { }) it('queryMetrics: user filters appended with AND (no duplicate WHERE in FROM clause)', () => { - const sql = queries.queryMetrics.sql([], userWhere, undefined) + const sql = queries.queryMetrics.safeSql([], userWhere, undefined) // queryMetrics uses COUNT(*) FILTER (WHERE ...) which is valid SQL and not a duplicate // Just verify the base filter + user filter are correctly composed expect(sql).toContain("auth.rolname in ('postgres')") @@ -65,7 +65,7 @@ describe('QUERY_PERFORMANCE SQL queries', () => { 'unified', 'queryMetrics', ] as const)('%s: no trailing junk when no user filters', (name) => { - const sql = queries[name].sql([], undefined, undefined) + const sql = queries[name].safeSql([], undefined, undefined) // Should not have a dangling undefined or 'WHERE' with nothing after the base filter expect(sql).not.toContain('undefined') expect(sql).not.toMatch(/calls > 0\s+AND\s+(ORDER|LIMIT|$)/im) @@ -74,31 +74,31 @@ describe('QUERY_PERFORMANCE SQL queries', () => { describe('slowQueriesCount bug fix', () => { it('uses table alias "statements"', () => { - const sql = queries.slowQueriesCount.sql() + const sql = queries.slowQueriesCount.safeSql() expect(sql).toContain('pg_stat_statements as statements') }) it('filters by mean_exec_time using the alias', () => { - const sql = queries.slowQueriesCount.sql() + const sql = queries.slowQueriesCount.safeSql() expect(sql).toContain('statements.mean_exec_time > 1000') }) }) describe('window function elimination', () => { it('unified uses grand_total CTE instead of OVER()', () => { - const sql = queries.unified.sql([], undefined, undefined) + const sql = queries.unified.safeSql([], undefined, undefined) expect(sql).toContain('grand_total') expect(sql).not.toContain('OVER()') }) it('mostTimeConsuming uses grand_total CTE instead of OVER()', () => { - const sql = queries.mostTimeConsuming.sql([], undefined, undefined) + const sql = queries.mostTimeConsuming.safeSql([], undefined, undefined) expect(sql).toContain('grand_total') expect(sql).not.toContain('OVER()') }) it('grand_total CTE references calls > 0', () => { - const sql = queries.unified.sql([], undefined, undefined) + const sql = queries.unified.safeSql([], undefined, undefined) expect(sql).toMatch(/grand_total[\s\S]*calls > 0/) }) }) @@ -106,7 +106,7 @@ describe('QUERY_PERFORMANCE SQL queries', () => { describe('multiple user filters', () => { it('handles multiple user filter conditions', () => { const multiWhere = "WHERE auth.rolname in ('postgres') AND statements.calls >= 10" - const sql = queries.mostFrequentlyInvoked.sql([], multiWhere, undefined) + const sql = queries.mostFrequentlyInvoked.safeSql([], multiWhere, undefined) expect(sql).toContain('calls > 0') expect(sql).toContain("auth.rolname in ('postgres')") expect(sql).toContain('statements.calls >= 10') diff --git a/apps/studio/components/interfaces/Reports/Reports.types.ts b/apps/studio/components/interfaces/Reports/Reports.types.ts index 19424c1e071..6eb68e2aab4 100644 --- a/apps/studio/components/interfaces/Reports/Reports.types.ts +++ b/apps/studio/components/interfaces/Reports/Reports.types.ts @@ -1,3 +1,5 @@ +import type { SafeSqlFragment } from '@supabase/pg-meta' + import type { ResponseError } from '@/types' export enum Presets { @@ -19,8 +21,8 @@ export interface PresetConfig { } export type BaseQueries = Record -export interface ReportQuery { - queryType: ReportQueryType +export interface ReportQueryLogs { + queryType: 'logs' sql: ( filters: ReportFilterItem[], where?: string, @@ -32,7 +34,22 @@ export interface ReportQuery { ) => string } -export type ReportQueryType = 'db' | 'logs' +export interface ReportQueryDb { + queryType: 'db' + safeSql: ( + filters: ReportFilterItem[], + where?: SafeSqlFragment, + orderBy?: SafeSqlFragment, + runIndexAdvisor?: boolean, + filterIndexAdvisor?: boolean, + page?: number, + pageSize?: number + ) => SafeSqlFragment +} + +export type ReportQuery = ReportQueryLogs | ReportQueryDb + +export type ReportQueryType = ReportQuery['queryType'] export interface StatusCodesDatum { timestamp: number diff --git a/apps/studio/components/interfaces/Reports/Reports.utils.tsx b/apps/studio/components/interfaces/Reports/Reports.utils.tsx index 735786c9801..fa37349f582 100644 --- a/apps/studio/components/interfaces/Reports/Reports.utils.tsx +++ b/apps/studio/components/interfaces/Reports/Reports.utils.tsx @@ -1,6 +1,11 @@ import dayjs from 'dayjs' -import { type BaseQueries, type PresetConfig, type ReportQuery } from './Reports.types' +import { + type BaseQueries, + type PresetConfig, + type ReportFilterItem, + type ReportQuery, +} from './Reports.types' import { isUnixMicro, unixMicroToIsoTimestamp, @@ -28,25 +33,29 @@ export const queriesFactory = ( queries: BaseQueries, projectRef: string ): PresetHooks => { - const hooks: PresetHooks = Object.entries(queries).reduce( - (acc, [k, { sql, queryType }]) => { - if (queryType === 'db') { - return { - ...acc, - [k]: () => useDbQuery({ sql }), - } - } else { - return { - ...acc, - [k]: () => useLogsQuery(projectRef), - } + const hooks: PresetHooks = Object.entries(queries).reduce((acc, [k, query]) => { + if (query.queryType === 'db') { + return { + ...acc, + [k]: () => useDbQuery({ sql: query.safeSql }), } - }, - {} - ) + } else { + return { + ...acc, + [k]: () => useLogsQuery(projectRef), + } + } + }, {}) return hooks } +export function getLogsSql(query: ReportQuery, filters: ReportFilterItem[]): string { + if (query.queryType !== 'logs') { + throw new Error(`Expected logs query, got ${query.queryType}`) + } + return query.sql(filters) +} + /** * Formats a timestamp to a human readable format in UTC * diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index d4dfbd78056..70948cb1bed 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -19,6 +19,10 @@ import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.ut import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow' import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection' import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal' +import type { + PostgresPolicyCreatePayload, + PostgresPolicyUpdatePayload, +} from '@/components/interfaces/Auth/Policies/Policies.types' import { PolicyEditorModal } from '@/components/interfaces/Auth/Policies/PolicyEditorModal' import type { Policy } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' @@ -155,7 +159,7 @@ export const StoragePolicies = () => { Functions that involve the CRUD for policies For each API call within the Promise.all, return true if an error occurred, else return false */ - const onCreatePolicies = async (payloads: any[]) => { + const onCreatePolicies = async (payloads: PostgresPolicyCreatePayload[]) => { if (!project) { console.error('Project is required') return true @@ -181,7 +185,7 @@ export const StoragePolicies = () => { } } - const onCreatePolicy = async (payload: any) => { + const onCreatePolicy = async (payload: PostgresPolicyCreatePayload) => { if (!project) { console.error('Project is required') return true @@ -200,7 +204,7 @@ export const StoragePolicies = () => { } } - const onUpdatePolicy = async (payload: any) => { + const onUpdatePolicy = async (payload: PostgresPolicyUpdatePayload) => { if (!project) { console.error('Project is required') 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 780d71b486a..f2ed735c293 100644 --- a/apps/studio/data/database/retrieve-index-advisor-result-query.ts +++ b/apps/studio/data/database/retrieve-index-advisor-result-query.ts @@ -1,3 +1,4 @@ +import { literal, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { useQuery } from '@tanstack/react-query' import { z } from 'zod' @@ -33,7 +34,15 @@ const IndexAdvisorResultSchema = z.object({ .transform((v) => v ?? 0), }) -export type GetIndexAdvisorResultResponse = z.infer +export type GetIndexAdvisorResultResponse = z.infer & { + index_statements: SafeSqlFragment[] +} + +function markDatabaseSqlSafe( + indexAdvisorResult: z.infer +): GetIndexAdvisorResultResponse { + return indexAdvisorResult as GetIndexAdvisorResultResponse +} export async function getIndexAdvisorResult({ projectRef, @@ -42,12 +51,10 @@ export async function getIndexAdvisorResult({ }: GetIndexAdvisorResultVariables) { if (!projectRef) throw new Error('Project ref is required') - const escapedQuery = query.replace(/'/g, "''") - const { result: results } = await executeSql({ projectRef, connectionString, - sql: `set search_path to public, extensions; select * from index_advisor('${escapedQuery}');`, + sql: safeSql`set search_path to public, extensions; select * from index_advisor(${literal(query)});`, }) if (!results || results.length === 0) { @@ -65,7 +72,7 @@ export async function getIndexAdvisorResult({ return null } - return filterProtectedSchemaIndexAdvisorResult(parsed.data) + return filterProtectedSchemaIndexAdvisorResult(markDatabaseSqlSafe(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 823a8b6a8f7..74e0139addf 100644 --- a/apps/studio/data/database/table-index-advisor-query.ts +++ b/apps/studio/data/database/table-index-advisor-query.ts @@ -18,7 +18,7 @@ export type IndexAdvisorSuggestion = { calls: number total_time: number mean_time: number - index_statements: string[] + index_statements: SafeSqlFragment[] startup_cost_before: number startup_cost_after: number total_cost_before: number @@ -66,6 +66,18 @@ function extractColumnsFromIndexStatements(indexStatements: string[]): string[] return Array.from(columns) } +interface GetTableIndexAdvisorSuggestionsResponse { + query: SafeSqlFragment + calls: number + total_time: number + mean_time: number + index_statements: SafeSqlFragment[] + startup_cost_before: number + startup_cost_after: number + total_cost_before: number + total_cost_after: number +} + export async function getTableIndexAdvisorSuggestions({ projectRef, connectionString, @@ -78,19 +90,7 @@ export async function getTableIndexAdvisorSuggestions({ const sql = getTableIndexAdvisorSql(schema, table) - const { result } = await executeSql< - Array<{ - query: string - calls: number - total_time: number - mean_time: number - index_statements: string[] - startup_cost_before: number - startup_cost_after: number - total_cost_before: number - total_cost_after: number - }> - >({ + const { result } = await executeSql>({ projectRef, connectionString, sql, @@ -113,7 +113,7 @@ export async function getTableIndexAdvisorSuggestions({ : 0 return { - query: row.query as SafeSqlFragment, + query: row.query, calls: row.calls, total_time: row.total_time, mean_time: row.mean_time, diff --git a/apps/studio/data/privileges/table-api-access-mutation.ts b/apps/studio/data/privileges/table-api-access-mutation.ts index 7c242fd87fe..4bec9cc2f45 100644 --- a/apps/studio/data/privileges/table-api-access-mutation.ts +++ b/apps/studio/data/privileges/table-api-access-mutation.ts @@ -1,4 +1,5 @@ import pgMeta from '@supabase/pg-meta' +import { joinSqlFragments, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { useMutation, useQueryClient } from '@tanstack/react-query' import { toast } from 'sonner' @@ -25,7 +26,7 @@ export async function updateTableApiAccessPrivileges({ relationId, privileges, }: TableApiAccessPrivilegesVariables) { - const sqlStatements: string[] = [] + const sqlStatements: Array = [] for (const role of API_ACCESS_ROLES) { const rolePrivileges = privileges[role] @@ -41,7 +42,7 @@ export async function updateTableApiAccessPrivileges({ privilegeType, relationId, })) - const revokeSql = pgMeta.tablePrivileges.revoke(revokeGrants).sql.trim() + const revokeSql = pgMeta.tablePrivileges.revoke(revokeGrants).sql if (revokeSql) sqlStatements.push(revokeSql) } @@ -52,7 +53,7 @@ export async function updateTableApiAccessPrivileges({ privilegeType, relationId, })) - const grantSql = pgMeta.tablePrivileges.grant(grantGrants).sql.trim() + const grantSql = pgMeta.tablePrivileges.grant(grantGrants).sql if (grantSql) sqlStatements.push(grantSql) } } @@ -64,7 +65,7 @@ export async function updateTableApiAccessPrivileges({ const { result } = await executeSql<[]>({ projectRef, connectionString, - sql: sqlStatements.join('\n'), + sql: joinSqlFragments(sqlStatements, '\n'), queryKey: ['table-api-access', 'update-privileges'], }) diff --git a/apps/studio/data/privileges/update-exposed-entities-mutation.ts b/apps/studio/data/privileges/update-exposed-entities-mutation.ts index 7215d7a05de..7e12a31bcd5 100644 --- a/apps/studio/data/privileges/update-exposed-entities-mutation.ts +++ b/apps/studio/data/privileges/update-exposed-entities-mutation.ts @@ -1,4 +1,5 @@ import { buildFunctionPrivilegesSql, buildTablePrivilegesSql } from '@supabase/pg-meta' +import { joinSqlFragments, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { useMutation } from '@tanstack/react-query' import { toast } from 'sonner' @@ -23,7 +24,7 @@ export async function updateExposedEntities({ }: UpdateExposedEntitiesVariables): Promise { if (!projectRef) throw new Error('projectRef is required') - const sqlParts: string[] = [] + const sqlParts: Array = [] if (tableIdsToAdd.length > 0) { sqlParts.push(buildTablePrivilegesSql(tableIdsToAdd, 'grant')) @@ -46,7 +47,7 @@ export async function updateExposedEntities({ await executeSql({ projectRef, connectionString, - sql: sqlParts.join('\n'), + sql: joinSqlFragments(sqlParts, '\n'), queryKey: ['update-exposed-entities'], }) } diff --git a/apps/studio/data/reports/api-report-query.ts b/apps/studio/data/reports/api-report-query.ts index 102e9bcc467..4745aa5fca4 100644 --- a/apps/studio/data/reports/api-report-query.ts +++ b/apps/studio/data/reports/api-report-query.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { PRESET_CONFIG } from '@/components/interfaces/Reports/Reports.constants' import { ReportFilterItem } from '@/components/interfaces/Reports/Reports.types' -import { queriesFactory } from '@/components/interfaces/Reports/Reports.utils' +import { getLogsSql, queriesFactory } from '@/components/interfaces/Reports/Reports.utils' import type { LogsEndpointParams } from '@/components/interfaces/Settings/Logs/Logs.types' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -73,32 +73,42 @@ export const useApiReport = () => { useEffect(() => { // update sql for each query if (totalRequests.changeQuery) { - totalRequests.changeQuery(PRESET_CONFIG.api.queries.totalRequests.sql(formattedFilters)) + totalRequests.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.totalRequests, formattedFilters) + ) } if (topRoutes.changeQuery) { - topRoutes.changeQuery(PRESET_CONFIG.api.queries.topRoutes.sql(formattedFilters)) + topRoutes.changeQuery(getLogsSql(PRESET_CONFIG.api.queries.topRoutes, formattedFilters)) } if (errorCounts.changeQuery) { - errorCounts.changeQuery(PRESET_CONFIG.api.queries.errorCounts.sql(formattedFilters)) + errorCounts.changeQuery(getLogsSql(PRESET_CONFIG.api.queries.errorCounts, formattedFilters)) } if (topErrorRoutes.changeQuery) { - topErrorRoutes.changeQuery(PRESET_CONFIG.api.queries.topErrorRoutes.sql(formattedFilters)) + topErrorRoutes.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.topErrorRoutes, formattedFilters) + ) } if (responseSpeed.changeQuery) { - responseSpeed.changeQuery(PRESET_CONFIG.api.queries.responseSpeed.sql(formattedFilters)) + responseSpeed.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.responseSpeed, formattedFilters) + ) } if (topSlowRoutes.changeQuery) { - topSlowRoutes.changeQuery(PRESET_CONFIG.api.queries.topSlowRoutes.sql(formattedFilters)) + topSlowRoutes.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.topSlowRoutes, formattedFilters) + ) } if (networkTraffic.changeQuery) { - networkTraffic.changeQuery(PRESET_CONFIG.api.queries.networkTraffic.sql(formattedFilters)) + networkTraffic.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.networkTraffic, formattedFilters) + ) } if (requestsByCountry.changeQuery) { requestsByCountry.changeQuery( - PRESET_CONFIG.api.queries.requestsByCountry.sql(formattedFilters) + getLogsSql(PRESET_CONFIG.api.queries.requestsByCountry, formattedFilters) ) } }, [JSON.stringify(formattedFilters)]) diff --git a/apps/studio/data/reports/storage-report-query.ts b/apps/studio/data/reports/storage-report-query.ts index de2add16889..cd6d2433462 100644 --- a/apps/studio/data/reports/storage-report-query.ts +++ b/apps/studio/data/reports/storage-report-query.ts @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react' import { PRESET_CONFIG } from '@/components/interfaces/Reports/Reports.constants' import { ReportFilterItem } from '@/components/interfaces/Reports/Reports.types' -import { queriesFactory } from '@/components/interfaces/Reports/Reports.utils' +import { getLogsSql, queriesFactory } from '@/components/interfaces/Reports/Reports.utils' import type { LogsEndpointParams } from '@/components/interfaces/Settings/Logs/Logs.types' import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector' @@ -86,36 +86,46 @@ export const useStorageReport = () => { useEffect(() => { if (totalRequests.changeQuery) { - totalRequests.changeQuery(PRESET_CONFIG.api.queries.totalRequests.sql(formattedFilters)) + totalRequests.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.totalRequests, formattedFilters) + ) } if (topRoutes.changeQuery) { - topRoutes.changeQuery(PRESET_CONFIG.api.queries.topRoutes.sql(formattedFilters)) + topRoutes.changeQuery(getLogsSql(PRESET_CONFIG.api.queries.topRoutes, formattedFilters)) } if (errorCounts.changeQuery) { - errorCounts.changeQuery(PRESET_CONFIG.api.queries.errorCounts.sql(formattedFilters)) + errorCounts.changeQuery(getLogsSql(PRESET_CONFIG.api.queries.errorCounts, formattedFilters)) } if (topErrorRoutes.changeQuery) { - topErrorRoutes.changeQuery(PRESET_CONFIG.api.queries.topErrorRoutes.sql(formattedFilters)) + topErrorRoutes.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.topErrorRoutes, formattedFilters) + ) } if (responseSpeed.changeQuery) { - responseSpeed.changeQuery(PRESET_CONFIG.api.queries.responseSpeed.sql(formattedFilters)) + responseSpeed.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.responseSpeed, formattedFilters) + ) } if (topSlowRoutes.changeQuery) { - topSlowRoutes.changeQuery(PRESET_CONFIG.api.queries.topSlowRoutes.sql(formattedFilters)) + topSlowRoutes.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.topSlowRoutes, formattedFilters) + ) } if (networkTraffic.changeQuery) { - networkTraffic.changeQuery(PRESET_CONFIG.api.queries.networkTraffic.sql(formattedFilters)) + networkTraffic.changeQuery( + getLogsSql(PRESET_CONFIG.api.queries.networkTraffic, formattedFilters) + ) } if (cacheHitRate.changeQuery) { - cacheHitRate.changeQuery(PRESET_CONFIG.storage.queries.cacheHitRate.sql([])) + cacheHitRate.changeQuery(getLogsSql(PRESET_CONFIG.storage.queries.cacheHitRate, [])) } if (topCacheMisses.changeQuery) { - topCacheMisses.changeQuery(PRESET_CONFIG.storage.queries.topCacheMisses.sql([])) + topCacheMisses.changeQuery(getLogsSql(PRESET_CONFIG.storage.queries.topCacheMisses, [])) } }, [JSON.stringify(formattedFilters)]) diff --git a/apps/studio/hooks/analytics/useDbQuery.tsx b/apps/studio/hooks/analytics/useDbQuery.tsx index e37fe78bb68..c080a115758 100644 --- a/apps/studio/hooks/analytics/useDbQuery.tsx +++ b/apps/studio/hooks/analytics/useDbQuery.tsx @@ -1,10 +1,11 @@ +import { type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { useQuery } from '@tanstack/react-query' import { DEFAULT_QUERY_PARAMS } from '@/components/interfaces/Reports/Reports.constants' import { BaseReportParams, MetaQueryResponse, - ReportQuery, + ReportQueryDb, } from '@/components/interfaces/Reports/Reports.types' import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' import { executeSql } from '@/data/sql/execute-sql-query' @@ -31,7 +32,7 @@ const useDbQuery = ({ where, orderBy, }: { - sql: ReportQuery['sql'] | string + sql: ReportQueryDb['safeSql'] | SafeSqlFragment params?: BaseReportParams where?: string orderBy?: string