security: migrate more files to safeSql (#44727)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Refactor**
* Overhauled SQL generation across the Studio: queries and helpers now
use safer, parameterized SQL construction for more robust and consistent
behavior.
* **Bug Fixes**
* Improved trigger update flow so event values are normalized before
saving, reducing errors when modifying webhooks/triggers.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Charis authored and GitHub committed 2026-04-16 15:02:02 +00:00
1 parent f415fb9f5e
commit b1531545fb
29 files changed
+346 -315

No files matched your search

@@ -1,4 +1,5 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { keyword } from '@supabase/pg-meta'
import { PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import type { PostgresTrigger } from '@supabase/postgres-meta'
import { useQueryClient } from '@tanstack/react-query'
@@ -275,7 +276,11 @@ export const EditHookPanel = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedHook,
updatedTrigger: { ...payload, enabled_mode: 'ORIGIN' },
updatedTrigger: {
...payload,
enabled_mode: 'ORIGIN',
events: payload.events.map(keyword),
},
})
}
} catch (error) {
@@ -1,4 +1,4 @@
import { getDatabaseTriggerUpdateSQL } from '@supabase/pg-meta'
import { getDatabaseTriggerUpdateSQL, type SafeSqlFragment } from '@supabase/pg-meta'
import { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta/src/pg-meta-triggers'
import { PostgresTrigger } from '@supabase/postgres-meta'
import { useMutation, useQueryClient } from '@tanstack/react-query'
@@ -16,7 +16,8 @@ export type DatabaseTriggerUpdateVariables = {
projectRef: string
connectionString?: string | null
originalTrigger: PostgresTrigger
updatedTrigger: PGTriggerCreate & Pick<PGTrigger, 'enabled_mode'>
updatedTrigger: Omit<PGTriggerCreate, 'events'> &
Pick<PGTrigger, 'enabled_mode'> & { events: Array<SafeSqlFragment> }
}
export async function updateDatabaseTrigger({
@@ -1,15 +1,18 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
/**
* Generates SQL to find top 5 SELECT queries involving a table and run them through index_advisor
*/
export function getTableIndexAdvisorSql(schema: string, table: string): string {
const escapedSchema = schema.replace(/'/g, "''")
const escapedTable = table.replace(/'/g, "''")
export function getTableIndexAdvisorSql(schema: string, table: string): SafeSqlFragment {
// Escape regex metacharacters so schema/table names are matched literally in PostgreSQL regex
const regexSchema = schema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
const regexTable = table.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
// Escape regex metacharacters so schema/table names are matched literally
const regexSchema = escapedSchema.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
const regexTable = escapedTable.toLowerCase().replace(/[.+*?^${}()|[\]\\]/g, '\\$&')
const tablePattern = literal(`(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])`)
const fromPattern = literal(`(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])`)
const joinPattern = literal(`(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])`)
return /* SQL */ `
return safeSql`
-- Get top 5 SELECT queries involving this table and run through index_advisor
set search_path to public, extensions;
@@ -27,9 +30,9 @@ with top_queries as (
-- Filter for queries involving our table. Use regex word boundaries so that e.g.
-- looking for table "orders" does not match queries on "orders_items".
and (
lower(statements.query) ~ '(^|[^a-z0-9_$])${regexSchema}[.]${regexTable}($|[^a-z0-9_$])'
or lower(statements.query) ~ '(^|[^a-z0-9_$])from[[:space:]]+${regexTable}($|[^a-z0-9_$])'
or lower(statements.query) ~ '(^|[^a-z0-9_$])join[[:space:]]+${regexTable}($|[^a-z0-9_$])'
lower(statements.query) ~ ${tablePattern}
or lower(statements.query) ~ ${fromPattern}
or lower(statements.query) ~ ${joinPattern}
)
-- Exclude system queries
and statements.query not like '%pg_catalog%'
@@ -50,6 +53,5 @@ select
from top_queries tq
left join lateral (
select * from index_advisor(tq.query)
) ia on true;
`.trim()
) ia on true;`
}
@@ -1,4 +1,4 @@
import { literal } from '../../../pg-format'
import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const USER_SEARCH_INDEXES = [
'idx_users_email',
@@ -10,11 +10,12 @@ export const USER_SEARCH_INDEXES = [
'users_phone_key',
]
export const getIndexStatusesSQL = () => {
return `SELECT c.relname as index_name, i.indisvalid as is_valid, i.indisready as is_ready
export const getIndexStatusesSQL = (): SafeSqlFragment => {
const indexNames = joinSqlFragments(USER_SEARCH_INDEXES.map(literal), ', ')
return safeSql`SELECT c.relname as index_name, i.indisvalid as is_valid, i.indisready as is_ready
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'auth'
AND c.relname IN (${USER_SEARCH_INDEXES.map(literal).join(', ')});`
AND c.relname IN (${indexNames});`
}
@@ -1,11 +1,13 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
// Checks pg_locks to determine if the index worker advisory lock is currently held
const INDEX_WORKER_ADVISORY_LOCK_KEY = 'auth_index_worker'
export const getIndexWorkerStatusSQL = () => {
return `SELECT EXISTS (
export const getIndexWorkerStatusSQL = (): SafeSqlFragment => {
return safeSql`SELECT EXISTS (
SELECT 1 FROM pg_locks
WHERE locktype = 'advisory'
AND (classid::bigint << 32 | objid::bigint) = hashtext('${INDEX_WORKER_ADVISORY_LOCK_KEY}')::bigint
AND (classid::bigint << 32 | objid::bigint) = hashtext(${literal(INDEX_WORKER_ADVISORY_LOCK_KEY)})::bigint
) as is_in_progress;`
}
@@ -1,5 +1,7 @@
export const getUserSQL = (userId: string) => {
const sql = /* SQL */ `
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getUserSQL = (userId: string): SafeSqlFragment => {
return safeSql`
select
auth.users.id,
auth.users.email,
@@ -28,8 +30,5 @@ select
) as providers
from
auth.users
where id = '${userId}';
`.trim()
return sql
where id = ${literal(userId)};`
}
@@ -1,13 +1,19 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
/**
* Given a schema name, list all the tables in that schema which have access
* granted to either the "anon" or "authenticated" role
*/
export const getTablesWithAnonAuthenticatedAccessSQL = ({ schema }: { schema: string }) =>
/* SQL */ `
export const getTablesWithAnonAuthenticatedAccessSQL = ({
schema,
}: {
schema: string
}): SafeSqlFragment =>
safeSql`
SELECT c.relname AS table_name
FROM pg_catalog.pg_class AS c
JOIN pg_catalog.pg_namespace AS n ON n.oid = c.relnamespace
WHERE n.nspname = '${schema}'
WHERE n.nspname = ${literal(schema)}
AND c.relkind IN ('r','p') -- table, partitioned table
AND EXISTS (
SELECT 1
@@ -16,4 +22,4 @@ WHERE n.nspname = '${schema}'
WHERE r.rolname IN ('anon','authenticated')
)
;
`.trim()
`
@@ -1,4 +1,4 @@
import { literal } from '../../../pg-format'
import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
/**
* Builds a SQL query that returns entities exposed through the Data API that
@@ -12,10 +12,14 @@ import { literal } from '../../../pg-format'
* Checks against the _target_ schemas rather than the currently active
* PostgREST config, so it works correctly when enabling the Data API.
*/
export const getUnsafeEntitiesInApiSql = ({ schemas }: { schemas: Array<string> }) => {
const schemaList = schemas.map(literal).join(', ')
export const getUnsafeEntitiesInApiSql = ({
schemas,
}: {
schemas: Array<string>
}): SafeSqlFragment => {
const schemaList = joinSqlFragments(schemas.map(literal), ', ')
return /* SQL */ `
return safeSql`
select
n.nspname as schema,
c.relname as name,
@@ -1,16 +1,25 @@
export const getTableColumnsSql = ({ table, schema }: { table?: string; schema?: string }) => {
const conditions = []
import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getTableColumnsSql = ({
table,
schema,
}: {
table?: string
schema?: string
}): SafeSqlFragment => {
const conditions: Array<SafeSqlFragment> = []
if (table) {
conditions.push(`tablename = '${table}'`)
conditions.push(safeSql`tablename = ${literal(table)}`)
}
if (schema) {
conditions.push(`schemaname = '${schema}'`)
conditions.push(safeSql`schemaname = ${literal(schema)}`)
}
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''
const whereClause =
conditions.length > 0 ? safeSql`WHERE ${joinSqlFragments(conditions, ' AND ')}` : safeSql``
return safeSql`
const sql = /* SQL */ `
SELECT
tbl.schemaname,
tbl.tablename,
@@ -68,7 +77,5 @@ export const getTableColumnsSql = ({ table, schema }: { table?: string; schema?:
)
${whereClause}
GROUP BY schemaname, tablename, quoted_name, is_table;
`.trim()
return sql
`
}
@@ -1,4 +1,4 @@
import { literal } from '../../../pg-format'
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getCronJobsMinimalSql = ({
searchTerm,
@@ -8,21 +8,24 @@ export const getCronJobsMinimalSql = ({
searchTerm?: string
page: number
limit: number
}) =>
`
SELECT
}): SafeSqlFragment => {
const searchClause = searchTerm
? safeSql`WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}`
: safeSql``
return safeSql`
SELECT
job.jobid,
job.jobname,
job.schedule,
job.command,
job.active
FROM
FROM
cron.job job
${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''}
${searchClause}
ORDER BY job.jobid
LIMIT ${limit}
OFFSET ${page * limit};
`.trim()
LIMIT ${literal(limit)}
OFFSET ${literal(page * limit)};`
}
export const getCronJobsSql = ({
searchTerm,
@@ -32,28 +35,31 @@ export const getCronJobsSql = ({
searchTerm?: string
page: number
limit: number
}) =>
`
}): SafeSqlFragment => {
const searchClause = searchTerm
? safeSql`WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}`
: safeSql``
return safeSql`
WITH latest_runs AS (
SELECT
SELECT
jobid,
status,
MAX(start_time) AS latest_run
FROM cron.job_run_details
GROUP BY jobid, status
), most_recent_runs AS (
SELECT
jobid,
status,
SELECT
jobid,
status,
latest_run
FROM latest_runs lr1
WHERE latest_run = (
SELECT MAX(latest_run)
FROM latest_runs lr2
SELECT MAX(latest_run)
FROM latest_runs lr2
WHERE lr2.jobid = lr1.jobid
)
)
SELECT
SELECT
job.jobid,
job.jobname,
job.schedule,
@@ -61,14 +67,14 @@ SELECT
job.active,
mr.latest_run,
mr.status
FROM
FROM
cron.job job
LEFT JOIN most_recent_runs mr ON job.jobid = mr.jobid
${!!searchTerm ? `WHERE job.jobname ILIKE ${literal(`%${searchTerm}%`)}` : ''}
${searchClause}
ORDER BY job.jobid
LIMIT ${limit}
OFFSET ${page * limit};
`.trim()
LIMIT ${literal(limit)}
OFFSET ${literal(page * limit)};`
}
/**
* Delete old cron job run details using ctid range filtering.
@@ -86,13 +92,11 @@ export const getDeleteOldCronJobRunDetailsByCtidSql = (
interval: string,
startPage: number,
endPage: number
) => {
// After validation, these are guaranteed to be safe integers
// Using literal() on the string representation ensures proper escaping
): SafeSqlFragment => {
const safeCtidStart = literal(`(${startPage},0)`)
const safeCtidEnd = literal(`(${endPage},0)`)
return `
return safeSql`
WITH deleted AS (
DELETE FROM cron.job_run_details
WHERE ctid >= ${safeCtidStart}::tid
@@ -100,30 +104,30 @@ WITH deleted AS (
AND end_time < now() - interval ${literal(interval)}
RETURNING 1
)
SELECT count(*) as deleted_count FROM deleted;
`.trim()
SELECT count(*) as deleted_count FROM deleted;`
}
const CRON_CLEANUP_SCHEDULE_NAME = 'delete-job-run-details'
const CRON_CLEANUP_SCHEDULE_EXPRESSION = '0 12 * * *'
export const getScheduleDeleteCronJobRunDetailsSql = (interval: string) =>
`
export const getScheduleDeleteCronJobRunDetailsSql = (interval: string): SafeSqlFragment => {
const command = safeSql`DELETE FROM cron.job_run_details WHERE end_time < now() - interval ${literal(interval)};`
return safeSql`
SELECT cron.schedule(
${literal(CRON_CLEANUP_SCHEDULE_NAME)},
${literal(CRON_CLEANUP_SCHEDULE_EXPRESSION)},
$$DELETE FROM cron.job_run_details WHERE end_time < now() - interval ${literal(interval)}$$
);
`.trim()
${literal(command)}
);`
}
/**
* Get the total number of pages in the job_run_details table.
* This is used to iterate through the table in batches using ctid ranges.
*/
export const getJobRunDetailsPageCountSql = () =>
`
export const getJobRunDetailsPageCountSql = (): SafeSqlFragment =>
safeSql`
SELECT pg_relation_size(oid) / current_setting('block_size')::int8 AS num_pages
FROM pg_class
WHERE relname = 'job_run_details'
AND relnamespace = 'cron'::regnamespace;
`.trim()
AND relnamespace = 'cron'::regnamespace;`
@@ -1,8 +1,8 @@
import { ident } from '../../../pg-format'
import { ident, safeSql, type SafeSqlFragment } from '../../../pg-format'
import pgMetaExtensions from '../../../pg-meta-extensions'
export const getDatabaseExtensionsSQL = () =>
`
export const getDatabaseExtensionsSQL = (): SafeSqlFragment =>
safeSql`
SELECT
e.name,
n.nspname AS schema,
@@ -16,7 +16,7 @@ FROM
LEFT JOIN pg_namespace n ON x.extnamespace = n.oid
LEFT JOIN pg_available_extension_versions ev
ON ev.name = e.name AND ev.version = e.default_version;
`.trim()
`
export const getEnableDatabaseExtensionSQL = ({
schema,
@@ -30,12 +30,10 @@ export const getEnableDatabaseExtensionSQL = ({
version: string
cascade?: boolean
createSchema?: boolean
}) => {
}): SafeSqlFragment => {
const { sql } = pgMetaExtensions.create({ schema, name, version, cascade })
return createSchema
? `
CREATE SCHEMA IF NOT EXISTS ${ident(schema)};
${sql}
`.trim()
: sql.trim()
? safeSql`CREATE SCHEMA IF NOT EXISTS ${ident(schema)};
${sql}`
: sql
}
@@ -1,6 +1,8 @@
import { safeSql, type SafeSqlFragment } from '../../../pg-format'
export const THRESHOLD_COUNT = 50000
export const COUNT_ESTIMATE_SQL = /* SQL */ `
export const COUNT_ESTIMATE_SQL: SafeSqlFragment = safeSql`
CREATE OR REPLACE FUNCTION pg_temp.count_estimate(
query text
) RETURNS integer LANGUAGE plpgsql AS $$
@@ -11,4 +13,4 @@ BEGIN
RETURN plan->0->'Plan'->'Plan Rows';
END;
$$;
`.trim()
`
@@ -1,5 +1,7 @@
export const getIndexesSQL = ({ schema }: { schema: string }) => {
const sql = /* SQL */ `
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getIndexesSQL = ({ schema }: { schema: string }): SafeSqlFragment => {
return safeSql`
SELECT
n.nspname AS schema,
t.relname AS "table",
@@ -17,7 +19,7 @@ JOIN LATERAL unnest(idx.indkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE
LEFT JOIN pg_attribute a
ON a.attrelid = t.oid
AND a.attnum = k.attnum
WHERE n.nspname = '${schema}'
WHERE n.nspname = ${literal(schema)}
GROUP BY
n.nspname,
t.relname,
@@ -25,7 +27,5 @@ GROUP BY
idx.indexrelid
ORDER BY
schema, "table", name;
`.trim()
return sql
`
}
@@ -1,39 +1,28 @@
import { literal } from '../../../pg-format'
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getDatabaseSizeSql = () => {
const sql = /* SQL */ `
select sum(pg_database_size(pg_database.datname))::bigint as db_size from pg_database;
`.trim()
return sql
export const getDatabaseSizeSql = (): SafeSqlFragment => {
return safeSql`select sum(pg_database_size(pg_database.datname))::bigint as db_size from pg_database;`
}
export const getLiveTupleEstimate = (table: string, schema: string = 'public') => {
const sql = /* SQL */ `
export const getLiveTupleEstimate = (table: string, schema: string = 'public'): SafeSqlFragment => {
return safeSql`
SELECT n_live_tup AS live_tuple_estimate
FROM pg_stat_user_tables
WHERE schemaname = ${literal(schema)}
AND relname = ${literal(table)};
`.trim()
return sql
AND relname = ${literal(table)};`
}
export const getMaxConnectionsSql = () => {
const sql = /* SQL */ `show max_connections`
return sql
export const getMaxConnectionsSql = (): SafeSqlFragment => {
return safeSql`show max_connections`
}
export const replicationLagSql = () => {
const sql = /* SQL */ `
select
export const replicationLagSql = (): SafeSqlFragment => {
return safeSql`
select
case
when (select count(*) from pg_stat_wal_receiver) = 1 and pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
then 0
else coalesce(extract(epoch from now() - pg_last_xact_replay_timestamp()),0)
end as physical_replica_lag_second
`
return sql
}
@@ -1,4 +1,11 @@
import { literal } from '../../../pg-format'
import {
ident,
joinSqlFragments,
keyword,
literal,
safeSql,
type SafeSqlFragment,
} from '../../../pg-format'
import { PGTrigger, PGTriggerCreate } from '../../../pg-meta-triggers'
// [Joshen] Writing this query within FE as the PATCH endpoint from pg-meta only supports updating
@@ -18,21 +25,25 @@ export function getDatabaseTriggerUpdateSQL({
condition: string | null
orientation: 'ROW' | 'STATEMENT'
activation: 'BEFORE' | 'AFTER' | 'INSTEAD OF'
events: string[]
events: Array<string>
function_schema: string
function_name: string
function_args: string[]
function_args: Array<string>
}
updatedTrigger: PGTriggerCreate & Pick<PGTrigger, 'enabled_mode'>
}) {
updatedTrigger: Omit<PGTriggerCreate, 'events'> &
Pick<PGTrigger, 'enabled_mode'> & { events: Array<SafeSqlFragment> }
}): SafeSqlFragment {
const { name, activation, events, schema, table, function_schema, function_name, function_args } =
updatedTrigger
return /* SQL */ `
BEGIN;
DROP TRIGGER "${originalTrigger.name}" ON "${originalTrigger.schema}"."${originalTrigger.table}";
CREATE TRIGGER "${name}" ${activation} ${events.join(' OR ')} ON "${schema}"."${table}"
FOR EACH ROW EXECUTE FUNCTION
"${function_schema}"."${function_name}"(${function_args?.map(literal).join(',') ?? ''});
COMMIT;
`.trim()
const eventsList = joinSqlFragments(events, ' OR ')
const argsList =
function_args && function_args.length > 0
? joinSqlFragments(function_args.map(literal), ',')
: safeSql``
return safeSql`BEGIN;
DROP TRIGGER ${ident(originalTrigger.name)} ON ${ident(originalTrigger.schema)}.${ident(originalTrigger.table)};
CREATE TRIGGER ${ident(name)} ${keyword(activation)} ${eventsList} ON ${ident(schema)}.${ident(table)}
FOR EACH ROW EXECUTE FUNCTION
${ident(function_schema)}.${ident(function_name)}(${argsList});
COMMIT;`
}
@@ -1,4 +1,4 @@
import { literal } from '../../../pg-format'
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getCreateVaultSecretSQL = ({
secret,
@@ -8,16 +8,14 @@ export const getCreateVaultSecretSQL = ({
secret: string
name?: string
description?: string
}) => {
const sql = /* SQL */ `
select vault.create_secret(
new_secret := ${literal(secret)}
${name ? `, new_name := ${literal(name)}` : ''}
${description ? `, new_description := ${literal(description)}` : ''}
)
`.trim()
return sql
}): SafeSqlFragment => {
const namePart = name ? safeSql`, new_name := ${literal(name)}` : safeSql``
const descriptionPart = description
? safeSql`, new_description := ${literal(description)}`
: safeSql``
return safeSql`select vault.create_secret(
new_secret := ${literal(secret)}${namePart}${descriptionPart}
)`
}
export const getUpdateVaultSecretSQL = ({
@@ -30,14 +28,13 @@ export const getUpdateVaultSecretSQL = ({
secret?: string
name?: string
description?: string
}) => {
const sql = /* SQL */ `
select vault.update_secret(
secret_id := ${literal(id)}
${secret ? `, new_secret := ${literal(secret)}` : ''}
${name ? `, new_name := ${literal(name)}` : ''}
${description ? `, new_description := ${literal(description)}` : ''}
)
`.trim()
return sql
}): SafeSqlFragment => {
const secretPart = secret ? safeSql`, new_secret := ${literal(secret)}` : safeSql``
const namePart = name ? safeSql`, new_name := ${literal(name)}` : safeSql``
const descriptionPart = description
? safeSql`, new_description := ${literal(description)}`
: safeSql``
return safeSql`select vault.update_secret(
secret_id := ${literal(id)}${secretPart}${namePart}${descriptionPart}
)`
}
@@ -1,16 +1,18 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getViewDefinitionSql = ({
id,
includeCreateStatement = false,
}: {
id: number
includeCreateStatement?: boolean
}) => {
}): SafeSqlFragment => {
if (!id) {
throw new Error('id is required')
}
const definitionSql = includeCreateStatement
? /* SQL */ `
? safeSql`
concat(
case t.relkind
when 'm' then 'create materialized view '
@@ -27,12 +29,12 @@ export const getViewDefinitionSql = ({
E' as\n',
pg_get_viewdef(t.regclass, true)
)
`.trim()
: 'pg_get_viewdef(t.regclass, true)'
`
: safeSql`pg_get_viewdef(t.regclass, true)`
const sql = /* SQL */ `
return safeSql`
with table_info as (
select
select
c.relkind,
n.nspname::text as schema,
c.relname::text as name,
@@ -40,11 +42,9 @@ export const getViewDefinitionSql = ({
to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.oid = ${id}
where c.oid = ${literal(id)}
)
select ${definitionSql} as definition
from table_info t
`.trim()
return sql
`
}
@@ -1,17 +1,19 @@
// Copied over from Management API here:
// https://github.com/supabase/infrastructure/blob/develop/api/apps/mgmt-api/src/common/projects/project-database-webhooks.service.ts#L58
export const getCheckWebhooksEnabledSQL = () =>
`
import { safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getCheckWebhooksEnabledSQL = (): SafeSqlFragment =>
safeSql`
SELECT EXISTS (
SELECT 1
FROM information_schema.schemata
WHERE schema_name = 'supabase_functions'
) AS schema_exists;
`.trim()
`
export const getEnableWebhooksSQL = () =>
`
export const getEnableWebhooksSQL = (): SafeSqlFragment =>
safeSql`
BEGIN;
DO
$$
@@ -258,4 +260,4 @@ BEGIN;
GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role;
COMMIT;
`.trim()
`
@@ -1,3 +1,5 @@
export const getAbortQuerySQL = ({ pid }: { pid: number }) => {
return `select pg_terminate_backend(${pid})`.trim()
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getAbortQuerySQL = ({ pid }: { pid: number }): SafeSqlFragment => {
return safeSql`select pg_terminate_backend(${literal(pid)})`
}
@@ -1,7 +1,5 @@
export const getKeywordsSql = () => {
const sql = /* SQL */ `
SELECT word FROM pg_get_keywords();
`.trim()
import { safeSql, type SafeSqlFragment } from '../../../pg-format'
return sql
export const getKeywordsSql = (): SafeSqlFragment => {
return safeSql`SELECT word FROM pg_get_keywords();`
}
@@ -1,7 +1,5 @@
export const getOngoingQueriesSql = () => {
const sql = /* SQL */ `
select pid, query, query_start from pg_stat_activity where state = 'active' and datname = 'postgres';
`.trim()
import { safeSql, type SafeSqlFragment } from '../../../pg-format'
return sql
export const getOngoingQueriesSql = (): SafeSqlFragment => {
return safeSql`select pid, query, query_start from pg_stat_activity where state = 'active' and datname = 'postgres';`
}
@@ -1,12 +1,11 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getDeleteBucketPrefixSQL = ({
bucketId,
prefix,
}: {
bucketId: string
prefix: string
}) => {
const sql = /* SQL */ `
select storage.delete_prefix('${bucketId}', '${prefix}');
`.trim()
return sql
}): SafeSqlFragment => {
return safeSql`select storage.delete_prefix(${literal(bucketId)}, ${literal(prefix)});`
}
@@ -1,3 +1,5 @@
import { literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const LARGEST_SIZE_LIMIT_BUCKETS_COUNT = 50
/**
@@ -6,10 +8,10 @@ export const LARGEST_SIZE_LIMIT_BUCKETS_COUNT = 50
* This query is unoptimized and should not be automatically called because
* there is no index on `file_size_limit` in the `storage.buckets` table.
*/
export const getLargestSizeLimitBucketsSqlUnoptimized = /* SQL */ `
export const getLargestSizeLimitBucketsSqlUnoptimized: SafeSqlFragment = safeSql`
SELECT id, name, file_size_limit
FROM storage.buckets
WHERE file_size_limit IS NOT NULL
ORDER BY file_size_limit DESC
LIMIT ${LARGEST_SIZE_LIMIT_BUCKETS_COUNT + 1};
`.trim()
LIMIT ${literal(LARGEST_SIZE_LIMIT_BUCKETS_COUNT + 1)};
`
@@ -1,17 +1,17 @@
import { ident } from '../../../pg-format'
import { ident, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getTableConstraintsSql = ({ id }: { id: number }) => {
const sql = /* SQL */ `
export const getTableConstraintsSql = ({ id }: { id: number }): SafeSqlFragment => {
return safeSql`
with table_info as (
select
select
n.nspname::text as schema,
c.relname::text as name,
to_regclass(concat('"', n.nspname, '"."', c.relname, '"')) as regclass
from pg_class c
join pg_namespace n on n.oid = c.relnamespace
where c.oid = ${id}
where c.oid = ${literal(id)}
)
select
select
con.oid as id,
con.conname as name,
con.contype as type
@@ -21,11 +21,9 @@ inner join pg_catalog.pg_class rel
inner join pg_catalog.pg_namespace nsp
on nsp.oid = connamespace
inner join table_info ti
on ti.schema = nsp.nspname
on ti.schema = nsp.nspname
and ti.name = rel.relname;
`.trim()
return sql
`
}
export const getDropConstraintSQL = ({
@@ -36,4 +34,5 @@ export const getDropConstraintSQL = ({
schema: string
table: string
name: string
}) => `ALTER TABLE ${ident(schema)}.${ident(table)} DROP CONSTRAINT ${ident(name)}`
}): SafeSqlFragment =>
safeSql`ALTER TABLE ${ident(schema)}.${ident(table)} DROP CONSTRAINT ${ident(name)}`
@@ -1,3 +1,5 @@
import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getEntityTypesSQL = ({
schemas,
search,
@@ -6,17 +8,23 @@ export const getEntityTypesSQL = ({
limit,
page,
}: {
schemas: string[]
schemas: Array<string>
search?: string
sort: 'alphabetical' | 'grouped-alphabetical'
filterTypes: string[]
filterTypes: Array<string>
limit: number
page: number
}) => {
const innerOrderBy = sort === 'alphabetical' ? `c.relname asc` : `"type_sort" asc, c.relname asc`
const outerOrderBy = sort === 'alphabetical' ? `r.name asc` : `r.type_sort asc, r.name asc`
}): SafeSqlFragment => {
const innerOrderBy =
sort === 'alphabetical' ? safeSql`c.relname asc` : safeSql`"type_sort" asc, c.relname asc`
const outerOrderBy =
sort === 'alphabetical' ? safeSql`r.name asc` : safeSql`r.type_sort asc, r.name asc`
const sql = /* SQL */ `
const typeList = joinSqlFragments(filterTypes.map(literal), ', ')
const schemaList = joinSqlFragments(schemas.map(literal), ', ')
const searchClause = search ? safeSql`and c.relname ilike ${literal(`%${search}%`)}` : safeSql``
return safeSql`
with records as (
select
c.oid::int8 as "id",
@@ -37,7 +45,7 @@ export const getEntityTypesSQL = ({
pg_namespace nc
join pg_class c on nc.oid = c.relnamespace
where
c.relkind in (${filterTypes.map((x) => `'${x}'`).join(', ')})
c.relkind in (${typeList})
and not pg_is_other_temp_schema(nc.oid)
and (
pg_has_role(c.relowner, 'USAGE')
@@ -47,11 +55,11 @@ export const getEntityTypesSQL = ({
)
or has_any_column_privilege(c.oid, 'SELECT, INSERT, UPDATE, REFERENCES')
)
and nc.nspname in (${schemas.map((x) => `'${x}'`)})
${search ? `and c.relname ilike '%${search}%'` : ''}
and nc.nspname in (${schemaList})
${searchClause}
order by ${innerOrderBy}
limit ${limit}
offset ${page * limit}
limit ${literal(limit)}
offset ${literal(page * limit)}
)
select
jsonb_build_object(
@@ -69,7 +77,5 @@ export const getEntityTypesSQL = ({
'count', coalesce(min(r.count), 0)
) "data"
from records r;
`.trim()
return sql
`
}
@@ -1,46 +1,46 @@
import { ident } from '../../../pg-format'
import { ident, joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getAddForeignKeySQL = ({
table,
foreignKeys,
}: {
table: { schema: string; name: string }
foreignKeys: ForeignKey[]
}) => {
const getOnDeleteSql = (action: string) =>
foreignKeys: Array<ForeignKey>
}): SafeSqlFragment => {
const getOnDeleteSql = (action: string): SafeSqlFragment =>
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
? 'ON DELETE CASCADE'
? safeSql`ON DELETE CASCADE`
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
? 'ON DELETE RESTRICT'
? safeSql`ON DELETE RESTRICT`
: action === FOREIGN_KEY_CASCADE_ACTION.SET_DEFAULT
? 'ON DELETE SET DEFAULT'
? safeSql`ON DELETE SET DEFAULT`
: action === FOREIGN_KEY_CASCADE_ACTION.SET_NULL
? 'ON DELETE SET NULL'
: ''
const getOnUpdateSql = (action: string) =>
? safeSql`ON DELETE SET NULL`
: safeSql``
const getOnUpdateSql = (action: string): SafeSqlFragment =>
action === FOREIGN_KEY_CASCADE_ACTION.CASCADE
? 'ON UPDATE CASCADE'
? safeSql`ON UPDATE CASCADE`
: action === FOREIGN_KEY_CASCADE_ACTION.RESTRICT
? 'ON UPDATE RESTRICT'
: ''
return (
foreignKeys
.map((relation) => {
const { deletionAction, updateAction } = relation
const onDeleteSql = getOnDeleteSql(deletionAction)
const onUpdateSql = getOnUpdateSql(updateAction)
return `
ALTER TABLE ${ident(table.schema)}.${ident(table.name)}
ADD FOREIGN KEY (${relation.columns.map((column) => ident(column.source)).join(', ')})
REFERENCES ${ident(relation.schema)}.${ident(relation.table)} (${relation.columns.map((column) => ident(column.target)).join(', ')})
${onUpdateSql}
${onDeleteSql}
`
.replace(/\s+/g, ' ')
.trim()
})
.join(';') + ';'
)
? safeSql`ON UPDATE RESTRICT`
: safeSql``
const stmts = foreignKeys.map((relation) => {
const { deletionAction, updateAction } = relation
const onDeleteSql = getOnDeleteSql(deletionAction)
const onUpdateSql = getOnUpdateSql(updateAction)
const sourceCols = joinSqlFragments(
relation.columns.map((column) => ident(column.source)),
', '
)
const targetCols = joinSqlFragments(
relation.columns.map((column) => ident(column.target)),
', '
)
return safeSql`ALTER TABLE ${ident(table.schema)}.${ident(table.name)} ADD FOREIGN KEY (${sourceCols}) REFERENCES ${ident(relation.schema)}.${ident(relation.table)} (${targetCols}) ${onUpdateSql} ${onDeleteSql}`
})
return safeSql`${joinSqlFragments(stmts, ';\n')};`
}
export const getRemoveForeignKeySQL = ({
@@ -48,75 +48,66 @@ export const getRemoveForeignKeySQL = ({
foreignKeys,
}: {
table: { schema: string; name: string }
foreignKeys: ForeignKey[]
}) => {
return (
foreignKeys
.map((relation) =>
`
ALTER TABLE IF EXISTS ${ident(table.schema)}.${ident(table.name)}
DROP CONSTRAINT IF EXISTS ${ident(relation.name)}
`
.replace(/\s+/g, ' ')
.trim()
)
.join(';') + ';'
foreignKeys: Array<ForeignKey>
}): SafeSqlFragment => {
const stmts = foreignKeys.map(
(relation) =>
safeSql`ALTER TABLE IF EXISTS ${ident(table.schema)}.${ident(table.name)} DROP CONSTRAINT IF EXISTS ${ident(relation.name)}`
)
return safeSql`${joinSqlFragments(stmts, ';\n')};`
}
export const getForeignKeyConstraintsSql = ({ schema }: { schema: string }) => {
export const getForeignKeyConstraintsSql = ({ schema }: { schema: string }): SafeSqlFragment => {
if (!schema) throw new Error('schema is required')
const sql = /* SQL */ `
SELECT
con.oid as id,
con.conname as constraint_name,
return safeSql`
SELECT
con.oid as id,
con.conname as constraint_name,
con.confdeltype as deletion_action,
con.confupdtype as update_action,
rel.oid as source_id,
nsp.nspname as source_schema,
rel.relname as source_table,
nsp.nspname as source_schema,
rel.relname as source_table,
(
SELECT
SELECT
array_agg(
att.attname
ORDER BY
att.attname
ORDER BY
un.ord
)
FROM
unnest(con.conkey) WITH ORDINALITY un (attnum, ord)
INNER JOIN pg_attribute att ON att.attnum = un.attnum
WHERE
)
FROM
unnest(con.conkey) WITH ORDINALITY un (attnum, ord)
INNER JOIN pg_attribute att ON att.attnum = un.attnum
WHERE
att.attrelid = rel.oid
) source_columns,
) source_columns,
frel.oid as target_id,
fnsp.nspname as target_schema,
frel.relname as target_table,
fnsp.nspname as target_schema,
frel.relname as target_table,
(
SELECT
SELECT
array_agg(
att.attname
ORDER BY
att.attname
ORDER BY
un.ord
)
FROM
unnest(con.confkey) WITH ORDINALITY un (attnum, ord)
INNER JOIN pg_attribute att ON att.attnum = un.attnum
WHERE
)
FROM
unnest(con.confkey) WITH ORDINALITY un (attnum, ord)
INNER JOIN pg_attribute att ON att.attnum = un.attnum
WHERE
att.attrelid = frel.oid
) target_columns
FROM
pg_constraint con
INNER JOIN pg_class rel ON rel.oid = con.conrelid
INNER JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
INNER JOIN pg_class frel ON frel.oid = con.confrelid
INNER JOIN pg_namespace fnsp ON fnsp.oid = frel.relnamespace
WHERE
) target_columns
FROM
pg_constraint con
INNER JOIN pg_class rel ON rel.oid = con.conrelid
INNER JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
INNER JOIN pg_class frel ON frel.oid = con.confrelid
INNER JOIN pg_namespace fnsp ON fnsp.oid = frel.relnamespace
WHERE
con.contype = 'f'
AND nsp.nspname = '${schema}'
`.trim()
return sql
AND nsp.nspname = ${literal(schema)}
`
}
export interface ForeignKey {
@@ -126,7 +117,7 @@ export interface ForeignKey {
schema: string
table: string
columns: { source: string; sourceType?: string; target: string; targetType?: string }[]
columns: Array<{ source: string; sourceType?: string; target: string; targetType?: string }>
deletionAction: string
updateAction: string
toRemove?: boolean
@@ -1,4 +1,4 @@
import { ident } from '../../../pg-format'
import { ident, literal, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getUpdateIdentitySequenceSQL = ({
schema,
@@ -8,8 +8,8 @@ export const getUpdateIdentitySequenceSQL = ({
schema: string
table: string
column: string
}) => {
return `SELECT setval('${ident(schema)}.${ident(`${table}_${column}_seq`)}', (SELECT COALESCE(MAX(${ident(column)}), 1) FROM ${ident(schema)}.${ident(table)}))`
}): SafeSqlFragment => {
return safeSql`SELECT setval(${literal(`${ident(schema)}.${ident(`${table}_${column}_seq`)}`)}::regclass, (SELECT COALESCE(MAX(${ident(column)}), 1) FROM ${ident(schema)}.${ident(table)}))`
}
export const getDuplicateIdentitySequenceSQL = ({
@@ -22,6 +22,6 @@ export const getDuplicateIdentitySequenceSQL = ({
duplicatedTableName: string
sourceTableName: string
sourceTableSchema: string
}) => {
return `SELECT setval('${ident(sourceTableSchema)}.${ident(`${duplicatedTableName}_${columnName}_seq`)}', (SELECT COALESCE(MAX(${ident(columnName)}), 1) FROM ${ident(sourceTableSchema)}.${ident(sourceTableName)}));`
}): SafeSqlFragment => {
return safeSql`SELECT setval(${literal(`${ident(sourceTableSchema)}.${ident(`${duplicatedTableName}_${columnName}_seq`)}`)}::regclass, (SELECT COALESCE(MAX(${ident(columnName)}), 1) FROM ${ident(sourceTableSchema)}.${ident(sourceTableName)}));`
}
@@ -1,4 +1,4 @@
import { ident } from '../../../pg-format'
import { ident, joinSqlFragments, safeSql, type SafeSqlFragment } from '../../../pg-format'
/**
* The functions below are basically just queries but may be supported directly
@@ -11,8 +11,8 @@ export const getAddPrimaryKeySQL = ({
}: {
schema: string
table: string
columns: string[]
}) => {
const primaryKeyColumns = columns.map((col) => ident(col)).join(', ')
return `ALTER TABLE ${ident(schema)}.${ident(table)} ADD PRIMARY KEY (${primaryKeyColumns})`
columns: Array<string>
}): SafeSqlFragment => {
const primaryKeyColumns = joinSqlFragments(columns.map(ident), ', ')
return safeSql`ALTER TABLE ${ident(schema)}.${ident(table)} ADD PRIMARY KEY (${primaryKeyColumns})`
}
@@ -1,5 +1,11 @@
import { ident } from '../../../pg-format'
import { ident, safeSql, type SafeSqlFragment } from '../../../pg-format'
export const getEnableRLSSQL = ({ schema, table }: { schema: string; table: string }) => {
return `ALTER TABLE ${ident(schema)}.${ident(table)} ENABLE ROW LEVEL SECURITY`
export const getEnableRLSSQL = ({
schema,
table,
}: {
schema: string
table: string
}): SafeSqlFragment => {
return safeSql`ALTER TABLE ${ident(schema)}.${ident(table)} ENABLE ROW LEVEL SECURITY`
}