From ec64135f9df2485b8174e322e5ff964c8315d9dc Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Fri, 31 Jul 2026 15:26:37 -0400 Subject: [PATCH] perf(pg-meta): scope column privileges query to a single table (#48553) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Performance improvement ## What is the current behavior? The column privileges page in Studio only ever renders one table, but the underlying query still `aclexplode`s every column in the whole schema and filters the result client-side. ## What is the new behavior? Adds a scoped SQL path that prunes `pg_class`/`pg_namespace` to the requested schema+table before exploding ACLs, gated behind the `pgMetaScopedIntrospection` flag, with a plan-guard test asserting `pg_class`/`pg_attribute` stay index-driven. Studio's query hook and cache keys now thread the selected table through so column-privilege invalidation and cold-load races are scoped correctly, and the page fetches per-table instead of per-schema. ## Additional context ## Summary by CodeRabbit * **Improvements** * Column privileges are now scoped to the selected schema and table for more accurate results. * Changing schemas automatically updates the table selection and refreshes the displayed privileges. * Privilege updates now refresh only the relevant schema, table, and column data. * Loading states are handled more accurately when no table is selected. * **Bug Fixes** * Improved consistency between scoped and unscoped column privilege results, including table-, column-, and grant-option privileges. --- .../Database/Privileges/Privileges.utils.ts | 20 ++- .../privileges/column-privileges-query.ts | 37 +++-- apps/studio/data/privileges/keys.ts | 11 +- .../[ref]/database/column-privileges.tsx | 63 ++++---- .../pg-meta/src/pg-meta-column-privileges.ts | 47 +++++- packages/pg-meta/src/sql/column-privileges.ts | 150 +++++++++++++++++- .../pg-meta/test/column-privileges.test.ts | 77 +++++++++ .../sql/studio/catalog-plan-guard.test.ts | 57 +++++++ 8 files changed, 410 insertions(+), 52 deletions(-) diff --git a/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts b/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts index 7bb3f0fe04a..0b98f97fbd2 100644 --- a/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts +++ b/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts @@ -265,7 +265,15 @@ export function usePrivilegesState({ } } -export function useApplyPrivilegeOperations(callback?: () => void) { +export function useApplyPrivilegeOperations({ + schema, + table, + onSuccess, +}: { + schema: string + table?: string + onSuccess?: () => void +}) { const { data: project } = useSelectedProjectQuery() const queryClient = useQueryClient() @@ -343,17 +351,19 @@ export function useApplyPrivilegeOperations(callback?: () => void) { } await Promise.all([ - queryClient.invalidateQueries({ queryKey: privilegeKeys.tablePrivilegesList(project.ref) }), queryClient.invalidateQueries({ - queryKey: privilegeKeys.columnPrivilegesList(project.ref), + queryKey: privilegeKeys.tablePrivilegesList(project.ref, [schema]), + }), + queryClient.invalidateQueries({ + queryKey: privilegeKeys.columnPrivilegesList(project.ref, schema, table), }), ]) setIsLoading(false) - callback?.() + onSuccess?.() }, - [callback, project, queryClient] + [onSuccess, project, queryClient, schema, table] ) return { apply, isLoading } diff --git a/apps/studio/data/privileges/column-privileges-query.ts b/apps/studio/data/privileges/column-privileges-query.ts index c5ef827d744..716cf8a0ad8 100644 --- a/apps/studio/data/privileges/column-privileges-query.ts +++ b/apps/studio/data/privileges/column-privileges-query.ts @@ -5,19 +5,24 @@ import { z } from 'zod' import { executeSql } from '../sql/execute-sql-mutation' import { privilegeKeys } from './keys' import type { components } from '@/data/api' +import { isScopedIntrospection, scopedIntrospectionReady } from '@/data/scoped-introspection' import type { ResponseError, UseCustomQueryOptions } from '@/types' export type ColumnPrivilegesVariables = { projectRef?: string connectionString?: string | null /** - * [Joshen] Specifically requiring schema to prevent a heavy query on the DB - * The only UI using this is the column privileges UI atm, so opting to be strict here - * Ideally we'd be able to also filter based on table to be even more prudent, but - * leaving out for now as it needs update to pg-meta + might not be worth the over-optimization - * given this UI isn't a primary tool + * Required to keep this off the whole-catalog path: without it the query + * aclexplodes every relation in the database across every one of its columns. */ schema: string + /** + * Restricts to a single relation. The UI only ever renders one table at a + * time, so this keeps the query scoped to what's on screen -- without it the + * whole schema's columns are fetched and all but one table discarded. The + * query stays disabled until a table is selected. + */ + table?: string } export type ColumnPrivilege = components['schemas']['PostgresColumnPrivileges'] @@ -26,13 +31,19 @@ const pgMetaColumnPrivilegesList = pgMeta.columnPrivileges.list() type ColumnPrivilegesData = z.infer export async function getColumnPrivileges( - { projectRef, connectionString, schema }: ColumnPrivilegesVariables, + { projectRef, connectionString, schema, table }: ColumnPrivilegesVariables, signal?: AbortSignal ) { if (!projectRef) throw new Error('projectRef is required') - const sql = pgMeta.columnPrivileges.list({ includedSchemas: [schema] }).sql - const queryKey = ['column-privileges', schema] + // Cold-load race guard -- see the module comment on scoped-introspection.ts. + await scopedIntrospectionReady() + const sql = pgMeta.columnPrivileges.list({ + includedSchemas: [schema], + relationName: table, + scoped: isScopedIntrospection(), + }).sql + const queryKey = ['column-privileges', schema, table] const { result } = await executeSql({ projectRef, connectionString, sql, queryKey }, signal) return result as ColumnPrivilegesData } @@ -46,11 +57,15 @@ export const useColumnPrivilegesQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const { projectRef, schema } = vars + const { projectRef, schema, table } = vars return useQuery({ - queryKey: privilegeKeys.columnPrivilegesList(projectRef, schema), + queryKey: privilegeKeys.columnPrivilegesList(projectRef, schema, table), queryFn: ({ signal }) => getColumnPrivileges(vars, signal), - enabled: enabled && typeof projectRef !== 'undefined' && typeof schema !== 'undefined', + enabled: + enabled && + typeof projectRef !== 'undefined' && + typeof schema !== 'undefined' && + typeof table !== 'undefined', ...options, }) } diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 006f7a3e7a4..6e8bf5776c6 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -1,8 +1,15 @@ export const privilegeKeys = { tablePrivilegesList: (projectRef: string | undefined, includedSchemas?: string[]) => ['projects', projectRef, 'database', 'table-privileges', includedSchemas].filter(Boolean), - columnPrivilegesList: (projectRef: string | undefined, schema?: string | undefined) => - ['projects', projectRef, 'database', 'column-privileges', schema].filter(Boolean), + columnPrivilegesList: ( + projectRef: string | undefined, + schema?: string | undefined, + table?: string | undefined + ) => { + const base = ['projects', projectRef, 'database', 'column-privileges'].filter(Boolean) + if (table === undefined) return schema === undefined ? base : [...base, schema] + return [...base, schema ?? null, table] + }, exposedTablesInfinite: (projectRef: string | undefined, search?: string) => [ 'projects', diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index 841c79d275a..52c19b1c475 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -55,26 +55,26 @@ const PrivilegesPage: NextPageWithLayout = () => { } = useTablesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, + schema: selectedSchema, }) + // Default to (or fall back to) the first table of the selected schema. The + // fallback matters because the schema can also change via the URL, which + // leaves `selectedTable` pointing at a table from the previous schema. useEffect(() => { if (!isSuccessTables) return - const tables = tableList - .filter((table) => table.schema === selectedSchema) - .map((table) => table.name) - if (tables[0] && selectedTable === undefined) { - setSelectedTable(tables[0]) + const tableNames = tableList.map((table) => table.name) + if (selectedTable === undefined || !tableNames.includes(selectedTable)) { + setSelectedTable(tableNames[0]) } - }, [isSuccessTables, tableList, selectedSchema, selectedTable]) + }, [isSuccessTables, tableList, selectedTable]) const { data: allRoles, isPending: isLoadingRoles } = useDatabaseRolesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) - const tables = tableList - ?.filter((table) => table.schema === selectedSchema) - .map((table) => table.name) + const tables = tableList?.map((table) => table.name) const { data: allTablePrivileges, @@ -104,38 +104,34 @@ const PrivilegesPage: NextPageWithLayout = () => { const { data: allColumnPrivileges, - isPending: isLoadingColumnPrivileges, + isPending: isPendingColumnPrivileges, isError: isErrorColumnPrivileges, error: errorColumnPrivileges, } = useColumnPrivilegesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, schema: selectedSchema, + table: selectedTable, }) + // The query is disabled until a table is selected, and a disabled query stays + // `isPending` forever -- so don't report it as loading in that state, or a + // schema with no tables never gets past the skeleton. + const isLoadingColumnPrivileges = selectedTable !== undefined && isPendingColumnPrivileges + const columnPrivileges = useMemo( () => - allColumnPrivileges - ?.filter( - (privilege) => - privilege.relation_schema === selectedSchema && - privilege.relation_name === selectedTable - ) - .map((privilege) => ({ - ...privilege, - privileges: privilege.privileges.filter( - (privilege) => privilege.grantee === selectedRole - ), - })) ?? [], - [allColumnPrivileges, selectedRole, selectedSchema, selectedTable] + allColumnPrivileges?.map((privilege) => ({ + ...privilege, + privileges: privilege.privileges.filter((privilege) => privilege.grantee === selectedRole), + })) ?? [], + [allColumnPrivileges, selectedRole] ) const rolesList = allRoles?.filter((role: PgRole) => EDITABLE_ROLES.includes(role.name)) ?? [] const roles = rolesList.map((role: PgRole) => role.name) - const table = tableList?.find( - (table) => table.schema === selectedSchema && table.name === selectedTable - ) + const table = tableList?.find((table) => table.name === selectedTable) const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const { @@ -170,9 +166,10 @@ const PrivilegesPage: NextPageWithLayout = () => { } } - const newTable = tableList?.find((table) => table.schema === schema)?.name setSelectedSchema(schema) - setSelectedTable(newTable) + // The table list is scoped to the schema, so the incoming schema's tables + // aren't loaded yet -- the effect above picks the first one once they are. + setSelectedTable(undefined) } const handleChangeTable = (table: string) => { @@ -192,15 +189,17 @@ const PrivilegesPage: NextPageWithLayout = () => { } const { apply: applyColumnPrivileges, isLoading: isApplyingChanges } = - useApplyPrivilegeOperations( - useCallback(() => { + useApplyPrivilegeOperations({ + schema: selectedSchema, + table: selectedTable, + onSuccess: useCallback(() => { toast.success( `Successfully updated privileges on ${selectedSchema}.${selectedTable} for ${selectedRole}`, { duration: 6000 } ) resetOperations() - }, [resetOperations, selectedRole, selectedSchema, selectedTable]) - ) + }, [resetOperations, selectedRole, selectedSchema, selectedTable]), + }) function applyChanges() { applyColumnPrivileges(operations) diff --git a/packages/pg-meta/src/pg-meta-column-privileges.ts b/packages/pg-meta/src/pg-meta-column-privileges.ts index ffddcc87f34..0e9a2f47322 100644 --- a/packages/pg-meta/src/pg-meta-column-privileges.ts +++ b/packages/pg-meta/src/pg-meta-column-privileges.ts @@ -10,7 +10,7 @@ import { safeSql, type SafeSqlFragment, } from './pg-format' -import { COLUMN_PRIVILEGES_SQL } from './sql/column-privileges' +import { COLUMN_PRIVILEGES_SQL, getScopedColumnPrivilegesSql } from './sql/column-privileges' const pgColumnPrivilegeGrant = z.object({ grantor: z.string(), @@ -50,19 +50,60 @@ function list({ includedSchemas, excludedSchemas, columnIds, + relationName, limit, offset, + scoped = false, }: { includeSystemSchemas?: boolean includedSchemas?: string[] excludedSchemas?: string[] columnIds?: string[] + /** Restricts to a single relation by name. Pair with `includedSchemas`. */ + relationName?: string limit?: number offset?: number + scoped?: boolean } = {}): { sql: SafeSqlFragment zod: typeof pgColumnPrivilegesArrayZod } { + // Scoped path: the base query prunes pg_class to the requested relations + // before exploding ACLs across their columns, instead of exploding the whole + // catalog and filtering the aggregate. + if (scoped) { + const base = getScopedColumnPrivilegesSql({ + includeSystemSchemas, + includedSchemas, + excludedSchemas, + relationName, + // `columnIds` are "." pairs. Their relation half narrows + // the base scan; the exact attnum half stays an outer predicate below. + relationIds: columnIds?.length + ? [...new Set(columnIds.map((columnId) => columnId.split('.')[0]))] + : undefined, + }) + + let sql = safeSql` + with column_privileges as (${base}) + select * + from column_privileges + ` + if (columnIds?.length) { + sql = safeSql`${sql} where column_id in (${joinSqlFragments(columnIds.map(literal), ',')})` + } + if (limit) { + sql = safeSql`${sql} limit ${literal(limit)}` + } + if (offset) { + sql = safeSql`${sql} offset ${literal(offset)}` + } + return { + sql, + zod: pgColumnPrivilegesArrayZod, + } + } + let sql = safeSql` with column_privileges as (${COLUMN_PRIVILEGES_SQL}) select * @@ -80,6 +121,10 @@ function list({ conditions.push(safeSql`relation_schema ${filter}`) } + if (relationName) { + conditions.push(safeSql`relation_name = ${literal(relationName)}`) + } + if (columnIds?.length) { conditions.push(safeSql`column_id in (${joinSqlFragments(columnIds.map(literal), ',')})`) } diff --git a/packages/pg-meta/src/sql/column-privileges.ts b/packages/pg-meta/src/sql/column-privileges.ts index def165c2675..fd6b3e2813d 100644 --- a/packages/pg-meta/src/sql/column-privileges.ts +++ b/packages/pg-meta/src/sql/column-privileges.ts @@ -1,6 +1,12 @@ -import { safeSql } from '../pg-format' +import { DEFAULT_SYSTEM_SCHEMAS } from '../constants' +import { filterByList } from '../helpers' +import { joinSqlFragments, literal, safeSql, type SafeSqlFragment } from '../pg-format' export const COLUMN_PRIVILEGES_SQL = /* SQL */ safeSql` +-- FROZEN legacy path: served while the pgMetaScopedIntrospection flag is off. +-- Do not edit -- it must keep matching production behavior until the flag +-- cleanup deletes it. getScopedColumnPrivilegesSql is the replacement. +-- -- Lists each column's privileges in the form of: -- -- [ @@ -147,3 +153,145 @@ group by column_id, x.relname, x.attname ` + +export type ColumnPrivilegesScope = { + /** Defaults to false, which excludes {@link DEFAULT_SYSTEM_SCHEMAS}. */ + includeSystemSchemas?: boolean + includedSchemas?: Array + excludedSchemas?: Array + /** Restricts to a single relation by name. Pair with `includedSchemas`. */ + relationName?: string + /** Restricts to specific relations by oid. */ + relationIds?: Array +} + +/** + * Scoped variant of {@link COLUMN_PRIVILEGES_SQL}: prunes pg_class/pg_namespace + * FIRST (in the `rel` CTE) so the scope predicate can drive an index scan, + * instead of aclexploding the whole catalog and filtering the aggregated result. + */ +export const getScopedColumnPrivilegesSql = ({ + includeSystemSchemas = false, + includedSchemas, + excludedSchemas, + relationName, + relationIds, +}: ColumnPrivilegesScope = {}): SafeSqlFragment => { + const conditions: Array = [] + + const schemaFilter = filterByList( + includedSchemas, + excludedSchemas, + !includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined + ) + if (schemaFilter) { + conditions.push(safeSql`and nc.nspname ${schemaFilter}`) + } + if (relationName) { + conditions.push(safeSql`and c.relname = ${literal(relationName)}`) + } + if (relationIds?.length) { + conditions.push(safeSql`and c.oid in (${joinSqlFragments(relationIds.map(literal), ',')})`) + } + const scopeFilter = joinSqlFragments(conditions, '\n') + + return safeSql` +with rel as ( + select + c.oid, + c.relname, + c.relowner, + c.relacl, + nc.nspname + from pg_class c + join pg_namespace nc + on nc.oid = c.relnamespace + where c.relkind = any (array['r', 'v', 'm', 'f', 'p']) + ${scopeFilter} +), +roles as ( + select + r.oid, + r.rolname, + pg_has_role(r.oid, 'USAGE') as is_member + from pg_authid r +), +grantees as ( + select oid, rolname, is_member, false as is_public from roles + union all + select (0)::oid as oid, 'PUBLIC', false, true +), +priv as ( + -- Table-level ACLs apply to every live column of the relation. + select + a.attrelid, + a.attnum, + a.attname, + r.relname, + r.nspname, + p.grantor, + p.grantee, + p.privilege_type as prtype, + p.is_grantable as grantable + from rel r + cross join lateral aclexplode(coalesce(r.relacl, acldefault('r', r.relowner))) p + join pg_attribute a + on a.attrelid = r.oid + and a.attnum > 0 + and not a.attisdropped + where p.privilege_type = any (array['INSERT', 'SELECT', 'UPDATE', 'REFERENCES']) + + union + + -- Column-level ACLs. + select + a.attrelid, + a.attnum, + a.attname, + r.relname, + r.nspname, + p.grantor, + p.grantee, + p.privilege_type, + p.is_grantable + from rel r + join pg_attribute a + on a.attrelid = r.oid + and a.attnum > 0 + and not a.attisdropped + cross join lateral aclexplode(coalesce(a.attacl, acldefault('c', r.relowner))) p + where a.attacl is not null + and p.privilege_type = any (array['INSERT', 'SELECT', 'UPDATE', 'REFERENCES']) +) +select + (p.attrelid || '.' || p.attnum) as column_id, + p.nspname as relation_schema, + p.relname as relation_name, + p.attname as column_name, + coalesce( + jsonb_agg( + jsonb_build_object( + 'grantor', grantor.rolname, + 'grantee', grantee.rolname, + 'privilege_type', p.prtype, + 'is_grantable', p.grantable + ) + ), + '[]' + ) as privileges +from priv p +join roles grantor + on grantor.oid = p.grantor +join grantees grantee + on grantee.oid = p.grantee +where grantor.is_member + or grantee.is_member + or grantee.is_public +group by + p.attrelid, + p.attnum, + p.nspname, + p.relname, + p.attname +` +} diff --git a/packages/pg-meta/test/column-privileges.test.ts b/packages/pg-meta/test/column-privileges.test.ts index 9eb5ef2c93e..bf8ca0db941 100644 --- a/packages/pg-meta/test/column-privileges.test.ts +++ b/packages/pg-meta/test/column-privileges.test.ts @@ -355,3 +355,80 @@ withTestDatabase( ) } ) + +withTestDatabase('scoped list matches the legacy path row-for-row', async ({ executeQuery }) => { + await executeQuery(` + drop role if exists col_grantee_a; + drop role if exists col_grantee_b; + create role col_grantee_a; + create role col_grantee_b; + create table public.col_priv_demo (id int primary key, data text, extra text); + grant select, insert on public.col_priv_demo to col_grantee_a; + grant update (data) on public.col_priv_demo to col_grantee_b with grant option; + grant references (extra) on public.col_priv_demo to col_grantee_a; + grant select on public.col_priv_demo to public; + create view public.col_priv_view as select id, data from public.col_priv_demo; + grant select (id) on public.col_priv_view to col_grantee_b; + `) + + // Neither path orders rows or the privileges array (see the note on the first + // test in this file), and the two plans emit them in different orders, so + // compare on normalized copies. + const normalize = }>(rows: Array) => + rows + .map((row) => ({ + ...row, + privileges: [...row.privileges].sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)) + ), + })) + .sort((a, b) => a.column_id.localeCompare(b.column_id)) + + for (const options of [ + {}, + { includedSchemas: ['public'] }, + { excludedSchemas: ['public'] }, + { includedSchemas: ['public'], relationName: 'col_priv_demo' }, + { includedSchemas: ['public'], relationName: 'col_priv_view' }, + { includedSchemas: ['public'], relationName: 'does_not_exist' }, + { includeSystemSchemas: true, includedSchemas: ['public'] }, + ]) { + const legacy = pgMeta.columnPrivileges.list(options) + const scoped = pgMeta.columnPrivileges.list({ ...options, scoped: true }) + const legacyRes = legacy.zod.parse(await executeQuery(legacy.sql)) + const scopedRes = scoped.zod.parse(await executeQuery(scoped.sql)) + expect(normalize(scopedRes), `list options: ${JSON.stringify(options)}`).toEqual( + normalize(legacyRes) + ) + // Row counts must match exactly, independent of the normalization above. + expect(scopedRes.length, `row count for options: ${JSON.stringify(options)}`).toBe( + legacyRes.length + ) + } + + // The scoped path must surface the PUBLIC grantee, and the column-level grants + // that only the second UNION arm can produce. + const { sql, zod } = pgMeta.columnPrivileges.list({ + includedSchemas: ['public'], + relationName: 'col_priv_demo', + scoped: true, + }) + const rows = zod.parse(await executeQuery(sql)) + const dataColumn = rows.find((r) => r.column_name === 'data')! + expect(rows.map((r) => r.column_name).sort()).toEqual(['data', 'extra', 'id']) + expect(dataColumn.privileges.some((p) => p.grantee === 'PUBLIC')).toBe(true) + expect( + dataColumn.privileges.some( + (p) => p.grantee === 'col_grantee_b' && p.privilege_type === 'UPDATE' && p.is_grantable + ) + ).toBe(true) + + // columnIds: the scoped path prunes pg_class by relation oid and keeps the + // exact attnum predicate outside, so it must still match legacy exactly. + const columnIds = rows.map((r) => r.column_id).slice(0, 2) + const legacyByIds = pgMeta.columnPrivileges.list({ columnIds }) + const scopedByIds = pgMeta.columnPrivileges.list({ columnIds, scoped: true }) + expect(normalize(scopedByIds.zod.parse(await executeQuery(scopedByIds.sql)))).toEqual( + normalize(legacyByIds.zod.parse(await executeQuery(legacyByIds.sql))) + ) +}) diff --git a/packages/pg-meta/test/sql/studio/catalog-plan-guard.test.ts b/packages/pg-meta/test/sql/studio/catalog-plan-guard.test.ts index dab67cf19fa..beeb163d76b 100644 --- a/packages/pg-meta/test/sql/studio/catalog-plan-guard.test.ts +++ b/packages/pg-meta/test/sql/studio/catalog-plan-guard.test.ts @@ -13,6 +13,7 @@ import { getTablesPaginatedSql, getViewDefinitionSql, } from '../../../src' +import columnPrivileges from '../../../src/pg-meta-column-privileges' import tablePrivileges from '../../../src/pg-meta-table-privileges' import * as tables from '../../../src/pg-meta-tables' import * as types from '../../../src/pg-meta-types' @@ -374,6 +375,62 @@ test('tablePrivileges.retrieve: scoped plan stays scoped for a single relation', assertPlanWithinBudget(result, TABLE_PRIVILEGES_BUDGET) }, 60_000) +// ── columnPrivileges.list (sql/column-privileges.ts) — per-table ───────────── +// The Studio hot path: the column-level privileges page renders exactly one +// table, so the query is scoped to schema+relname. Scoped prunes pg_class in the +// `rel` CTE before the aclexplode laterals / UNION / GROUP BY, so pg_class is +// reached via its (relname, relnamespace) index and pg_attribute via +// pg_attribute_relid_attnum_index. +// +// Only pg_authid is seq-scanned, once: the `roles` CTE is referenced by both the +// grantor join and the `grantees` UNION, so it materializes and pg_has_role() is +// evaluated once per role instead of twice per privilege row. (Table privileges +// needs two scans here because it joins the pg_roles view twice.) +const COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET = { + allowedSeqScans: { + pg_authid: { + max: 1, + reason: + 'the `roles` CTE materializes once and feeds both the grantor join and the grantee-with-PUBLIC union; pg_authid scales with role count, not schema size', + }, + }, +} + +test('columnPrivileges.list: scoped plan stays scoped for a single table', async () => { + const result = await explainAnalyze( + db, + columnPrivileges.list({ + includedSchemas: ['stress'], + relationName: 't_1000', + scoped: true, + }).sql + ) + assertPlanWithinBudget(result, COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET) +}, 60_000) + +// ── columnPrivileges.list (sql/column-privileges.ts) — per-schema listing ──── +// Studio always passes a relation (above); a schema-wide call is still supported +// for pg-meta consumers. Here one filtered pg_class seq scan is the right plan +// rather than a regression: the whole `stress` schema is most of the catalog. +const COLUMN_PRIVILEGES_SCHEMA_SCOPED_BUDGET = { + allowedSeqScans: { + ...COLUMN_PRIVILEGES_TABLE_SCOPED_BUDGET.allowedSeqScans, + pg_class: { + max: 1, + reason: + 'schema-wide listing selects nearly every relation in the schema, so one filtered pg_class scan beats per-relation index lookups; pg_attribute stays index-driven', + }, + }, +} + +test('columnPrivileges.list: scoped plan stays scoped for a schema', async () => { + const result = await explainAnalyze( + db, + columnPrivileges.list({ includedSchemas: ['stress'], scoped: true }).sql + ) + assertPlanWithinBudget(result, COLUMN_PRIVILEGES_SCHEMA_SCOPED_BUDGET) +}, 60_000) + // ── tables.retrieve (pg-meta-tables.ts / sql/tables.ts) — single table ─────── // Scoped pushes the target OID (a literal, or an initplan scalar subquery // resolved via pg_class's (relname, relnamespace) index) into the base scan and