mirror of
https://github.com/supabase/supabase.git
synced 2026-06-08 02:25:04 +08:00
studio: SafeSql for reports, query performance, privileges (4/7) (#45998)
## Summary Part 4 of the SafeSql migration stack ([#45897](https://github.com/supabase/supabase/pull/45897), [#45903](https://github.com/supabase/supabase/pull/45903), [#45990](https://github.com/supabase/supabase/pull/45990), this PR, …). Converts the remaining reports, query performance, observability, index advisor, and privileges call sites of `executeSql` to produce `SafeSqlFragment` values. The `ReportQuery.sql` field flips from `string` to `SafeSqlFragment`, which cascades into every consumer — landed here atomically so each branch typechecks cleanly. Touched areas: - `interfaces/Reports/*` — `ReportQuery.sql: SafeSqlFragment`, plus all report definitions/utilities updated - `interfaces/QueryPerformance/useQueryPerformanceQuery.ts` - `interfaces/Database/IndexAdvisor/*` and `data/database/{table-index-advisor,retrieve-index-advisor-result}-query.ts` - `data/privileges/{table-api-access,update-exposed-entities}-mutation.ts` - `interfaces/Storage/StoragePolicies/StoragePolicies.tsx` - `hooks/analytics/useDbQuery.tsx` - `Observability/useSlowQueriesCount.ts` + `useQueryInsightsIssues.utils.test.ts` ## Test plan - [x] `pnpm typecheck` passes - [x] `useQueryInsightsIssues.utils.test.ts` passes - [x] Dev-server smoke test: reports pages, query performance, index advisor, storage policies <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Reworked SQL construction and typings across reporting, query performance, index advisor, and privilege features to use safer SQL fragments, improving reliability and preventing query composition issues. * **Types** * Reporting query types were split to distinguish database vs. logs queries, enabling correct handling and validation. * **Docs/Utils** * Added a helper to consistently generate logs SQL for report hooks. * **Tests** * Updated tests to exercise the new SQL-building API. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45998) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 []
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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<Keys extends string> = Record<Keys, ReportQuery>
|
||||
|
||||
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
|
||||
|
||||
@@ -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 = <T extends string>(
|
||||
queries: BaseQueries<T>,
|
||||
projectRef: string
|
||||
): PresetHooks => {
|
||||
const hooks: PresetHooks = Object.entries<ReportQuery>(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<ReportQuery>(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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<typeof IndexAdvisorResultSchema>
|
||||
export type GetIndexAdvisorResultResponse = z.infer<typeof IndexAdvisorResultSchema> & {
|
||||
index_statements: SafeSqlFragment[]
|
||||
}
|
||||
|
||||
function markDatabaseSqlSafe(
|
||||
indexAdvisorResult: z.infer<typeof IndexAdvisorResultSchema>
|
||||
): 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<ReturnType<typeof getIndexAdvisorResult>>
|
||||
|
||||
@@ -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<Array<GetTableIndexAdvisorSuggestionsResponse>>({
|
||||
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,
|
||||
|
||||
@@ -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<SafeSqlFragment> = []
|
||||
|
||||
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'],
|
||||
})
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
if (!projectRef) throw new Error('projectRef is required')
|
||||
|
||||
const sqlParts: string[] = []
|
||||
const sqlParts: Array<SafeSqlFragment> = []
|
||||
|
||||
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'],
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)])
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user