mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
1 parent
d47747477d
commit
ec64135f9d
8 files changed
+410
-52
No files matched your search
@@ -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 }
|
||||
|
||||
@@ -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<typeof pgMetaColumnPrivilegesList.zod>
|
||||
|
||||
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 = <TData = ColumnPrivilegesData>(
|
||||
...options
|
||||
}: UseCustomQueryOptions<ColumnPrivilegesData, ColumnPrivilegesError, TData> = {}
|
||||
) => {
|
||||
const { projectRef, schema } = vars
|
||||
const { projectRef, schema, table } = vars
|
||||
return useQuery<ColumnPrivilegesData, ColumnPrivilegesError, TData>({
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "<attrelid>.<attnum>" 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), ',')})`)
|
||||
}
|
||||
|
||||
@@ -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<string>
|
||||
excludedSchemas?: Array<string>
|
||||
/** Restricts to a single relation by name. Pair with `includedSchemas`. */
|
||||
relationName?: string
|
||||
/** Restricts to specific relations by oid. */
|
||||
relationIds?: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<SafeSqlFragment> = []
|
||||
|
||||
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
|
||||
`
|
||||
}
|
||||
@@ -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 = <T extends { column_id: string; privileges: Array<unknown> }>(rows: Array<T>) =>
|
||||
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)))
|
||||
)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user