Files
supabase/apps/studio/components/interfaces/Database/Hooks/EditHookPanel.tsx
Andrew ValleteauandClaude Fable 5 768ea1001b fix(studio): scope table editor introspection CTEs to target table OID (#47894)
## 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?

Bug fix (performance), plus a regression-guard test suite and docs.

## What is the current behavior?

Studio's introspection queries in `@supabase/pg-meta` do `O(catalog)`
work for per-table requests. On databases with very large catalogs
(hundreds of thousands of relations/constraints — real deployments reach
this) they take tens of seconds per dashboard interaction, trip
`statement_timeout`, and create heavy CPU/memory pressure when several
tabs open concurrently. Two instances of the same bug class:

**1. Table Editor query (`getTableEditorSql`)** — fetches metadata for
ONE table by OID, but five catalog scans are unscoped and only filtered
at the top-level join:

- `primary_keys` CTE — scans all of `pg_index` (`where i.indisprimary`)
- `index_cols` CTE — scans all unique indexes
- `relationships` CTE — scans every FK in `pg_constraint` (and is
scanned twice by the two subplans)
- `uniques` subquery (inside `columns`) — scans all single-column unique
constraints
- `check_constraints` subquery (inside `columns`) — scans all
single-column check constraints

The planner cannot push the outer join qual into grouped / `distinct on`
subqueries, so each is computed over the full catalog and thrown away.
`tables-paginated.ts` was previously rewritten to avoid exactly this
pattern; the single-table query never got the same treatment.

**2. Entity definitions (`getTableDefinitionSql` /
`getEntityDefinitionsSql`)** — the vendored `pg_get_tabledef` plpgsql
function scans the entire `information_schema.columns` view once **per
column** (plus `information_schema.tables` once per call) just to decide
whether a name needs double-quoting — a pure string property of a name
it already holds — and its per-index partial-index lookup casts
`relnamespace::regnamespace::text` across every `pg_class` row. On a
12K-table catalog this makes a single entity's DDL cost ~3.7s and a
default 100-entity definitions page ~6 minutes.

## What is the new behavior?

**Fix 1 — scope the Table Editor CTEs to the requested OID** (`id` is
validated non-null and interpolated via `literal()`, same as the
existing `base_table_info` filter):

- `primary_keys` / `index_cols`: `and i.indrelid = <id>`
- `relationships`: `and (c.conrelid = <id> or c.confrelid = <id>)`
- `uniques` / `check_constraints`: `and conrelid = <id>`

Semantics are unchanged: the top-level select already filtered every CTE
to the target table, so rows for other tables were computed and
discarded. The `pg_index`/`pg_constraint` lookups become index scans
returning a handful of rows. One residual scan is structural: PostgreSQL
has no index on `pg_constraint.confrelid`, so the incoming-FK half of
`relationships` is a single filtered seq scan of `pg_constraint` — still
one cheap pass instead of materializing every FK row twice.

**Fix 2 — remove the O(catalog) scans inside `pg_get_tabledef`**: the
information_schema uppercase checks are replaced with direct regex tests
on the name in hand (preserving the original's `quote_ident` behavior
for schemas that need quoting), and the partial-index lookup is scoped
by the already-resolved table OID. Original statements are kept as
comments, matching the vendored file's convention.

**Regression guard** — so this bug class stays out:

- `test/db/stress-catalog.ts` builds a synthetic catalog (default 2,000
tables with PKs, unique + check constraints, FK chains and an FK hub;
`PG_META_STRESS_TABLES` scales it to incident size).
- `test/db/plan-guard.ts` provides `EXPLAIN (ANALYZE, FORMAT
JSON)`-based budget assertions: a query's plan may only seq-scan a
scaling catalog if its budget entry carries a written structural
justification (e.g. no index on `pg_constraint.confrelid`; no index on
`pg_class.relnamespace` for per-schema listings), plus a per-query time
bound (the only guard available for opaque plpgsql internals like
`pg_get_tabledef`).
- `test/sql/studio/catalog-plan-guard.test.ts` applies budgets to the
hot-path studio queries: table editor, constraints, FK listing, entity
types, tables-paginated, columns, indexes, table/entity definitions,
views. Reverting either fix makes the suite fail immediately with the
offending scans listed.
- `test/sql/studio/table-editor.test.ts` (new — none existed) asserts
the Table Editor query's semantics: primary keys, unique indexes, both
FK directions, `is_unique`, check definitions, column comments.
- A new package `README.md` documents the plan-guard budget entry as a
requirement for any new introspection query.

### Validation (synthetic 12,000-table catalog, PostgreSQL 17.6)

- **Output equivalence, fix 1:** for 12 relation types (regular,
composite PK, partitioned parent + partition, view, materialized view,
constraint-free table, FK hub/chain/tail, and a fixture with
enums/domains/generated/identity columns and duplicate check
constraints), the `entity` jsonb from the old and new query is
byte-identical.
- **Output equivalence, fix 2:** byte-identical DDL across 13 fixture
combinations (serial/identity/generated/array columns, case-sensitive
and keyword names, mixed-case schemas, partitions, unlogged +
reloptions, partial/expression indexes, external PK/FK/comments/trigger
variants).
- **Performance, fix 1:** Table Editor query `EXPLAIN ANALYZE` ~1,630ms
→ ~30ms (~50×); the gap grows with catalog size since the old query is
O(catalog) per call.
- **Performance, fix 2:** single entity definition 3,672ms → 63ms; a
100-entity definitions page ~6min → 0.87s. The plan-guard bound for
`getEntityDefinitionsSql` tightens accordingly from 15s/25 entities to
3s/100 entities (330ms measured at default test scale).

Verified locally: `catalog-plan-guard` (12 tests), `table-editor`,
`tables-paginated` (16 tests) pass; `typecheck` clean.

### Rollout

Per review, the new behavior ships **dark** behind the
`pgMetaScopedIntrospection` ConfigCat flag (default off = legacy SQL,
kept as full duplicated templates in pg-meta and verified byte-identical
to the pre-PR queries). Studio reads the flag in the query hooks and
threads it through (flag state is part of the React Query keys). The
rollout is staged in the ConfigCat dashboard via user-email targeting
(like every other ConfigCat flag): target the reporting user's email
first, then a percentage rollout, then 100%. Server-side AI callers of
`getEntityDefinitionsSql` stay on the legacy path. Once fully rolled
out, delete the legacy templates + flag in a cleanup PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Closes: PGMETA-122

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

- **Bug Fixes**
- Improved table editor SQL to correctly scope primary keys, indexes,
uniques, checks, and relationships to the selected table.
- Optimized table definition SQL to reduce unnecessary catalog scanning
for uppercase-name detection and partial-index detection.

- **Tests**
- Added SQL generator tests for table editor metadata (keys, indexes,
relationships, comments, and constraints).
- Added catalog query plan guard coverage with a stress catalog and
EXPLAIN-based scoping/performance budgets.

- **Documentation**
- Expanded documentation on catalog query plan safeguards and how to
keep new introspection queries properly scoped.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:16:11 +02:00

356 lines
11 KiB
TypeScript

import { zodResolver } from '@hookform/resolvers/zod'
import { keyword } from '@supabase/pg-meta'
import type { PGTrigger, PGTriggerCreate } from '@supabase/pg-meta'
import { useQueryClient } from '@tanstack/react-query'
import { useFlag, useParams } from 'common'
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { Button, Form, SidePanel } from 'ui'
import { FormSchema, WebhookFormValues } from './EditHookPanel.constants'
import { FormContents } from './FormContents'
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
import { useDatabaseTriggerCreateMutation } from '@/data/database-triggers/database-trigger-create-mutation'
import { useDatabaseTriggerUpdateMutation } from '@/data/database-triggers/database-trigger-update-transaction-mutation'
import { useDatabaseHooksQuery } from '@/data/database-triggers/database-triggers-query'
import {
PG_META_SCOPED_INTROSPECTION_FLAG,
tableEditorQueryOptions,
} from '@/data/table-editor/table-editor-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
import { uuidv4 } from '@/lib/helpers'
export type HTTPArgument = { id: string; name: string; value: string }
export const isEdgeFunction = ({
ref,
restUrlTld,
url,
}: {
ref?: string
restUrlTld?: string
url: string
}) =>
url.includes(`https://${ref}.functions.supabase.${restUrlTld}/`) ||
url.includes(`https://${ref}.supabase.${restUrlTld}/functions/`)
const FORM_ID = 'edit-hook-panel-form'
const parseHeaders = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: 'Content-type', value: 'application/json' }]
}
const [, , headers] = selectedHook.function_args
let parsedHeaders: Record<string, string> = {}
try {
parsedHeaders = JSON.parse(headers.replace(/\\"/g, '"'))
} catch (e) {
parsedHeaders = {}
}
return Object.entries(parsedHeaders).map(([name, value]) => ({
id: uuidv4(),
name,
value,
}))
}
const parseParameters = (selectedHook?: PGTrigger): HTTPArgument[] => {
if (typeof selectedHook === 'undefined') {
return [{ id: uuidv4(), name: '', value: '' }]
}
const [, , , parameters] = selectedHook.function_args
let parsedParameters: Record<string, string> = {}
try {
parsedParameters = JSON.parse(parameters.replace(/\\"/g, '"'))
} catch (e) {
parsedParameters = {}
}
return Object.entries(parsedParameters).map(([name, value]) => ({
id: uuidv4(),
name,
value,
}))
}
export const EditHookPanel = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
const [isLoadingTable, setIsLoadingTable] = useState(false)
const scoped = !!useFlag(PG_META_SCOPED_INTROSPECTION_FLAG)
const { data: hooks = [], isSuccess } = useDatabaseHooksQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [showCreateHookForm, setShowCreateHookForm] = useQueryState(
'new',
parseAsBoolean.withDefault(false)
)
const [selectedHookIdToEdit, setSelectedHookIdToEdit] = useQueryState(
'edit',
parseAsString.withDefault('')
)
const selectedHook = hooks.find((hook) => hook.id.toString() === selectedHookIdToEdit)
// Webhook IDs aren't stable across edits because the update mutation drops and recreates the
// trigger, assigning a new ID. This causes a brief window where the old selectedHookIdToEdit
// no longer matches any hook, incorrectly triggering the "Webhook not found" toast. Since this
// is an edge case, we use an ad-hoc ref to suppress the toast when the panel is closing rather
// than a more involved solution
const isClosingRef = useRef(false)
const visible = showCreateHookForm || !!selectedHook
const onClose = () => {
isClosingRef.current = true
setShowCreateHookForm(false)
setSelectedHookIdToEdit(null)
}
const { mutate: createDatabaseTrigger, isPending: isCreating } = useDatabaseTriggerCreateMutation(
{
onSuccess: (_, variables) => {
toast.success(`Successfully created new webhook "${variables.payload.name}"`)
onClose()
},
onError: (error) => {
toast.error(`Failed to create webhook: ${error.message}`)
},
}
)
const { mutate: updateDatabaseTrigger, isPending: isUpdating } = useDatabaseTriggerUpdateMutation(
{
onSuccess: (res) => {
toast.success(`Successfully updated webhook "${res.name}"`)
onClose()
},
onError: (error) => {
toast.error(`Failed to update webhook: ${error.message}`)
},
}
)
const isSubmitting = isCreating || isUpdating || isLoadingTable
const restUrl = project?.restUrl
const restUrlTld = restUrl ? new URL(restUrl).hostname.split('.').pop() : 'co'
const form = useForm<WebhookFormValues>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
events: selectedHook?.events ?? [],
httpHeaders: parseHeaders(selectedHook),
httpParameters: parseParameters(selectedHook),
},
})
useEffect(() => {
if (isSuccess && !!selectedHookIdToEdit && !selectedHook && !isClosingRef.current) {
toast('Webhook not found')
setSelectedHookIdToEdit(null)
}
}, [isSuccess, selectedHook, selectedHookIdToEdit, setSelectedHookIdToEdit])
// Reset the closing ref when the panel fully closes
useEffect(() => {
if (!visible) {
isClosingRef.current = false
}
}, [visible])
// Reset form when panel opens with new selectedHook
useEffect(() => {
if (visible) {
form.reset({
name: selectedHook?.name ?? '',
table_id: selectedHook?.table_id?.toString() ?? '',
http_url: selectedHook?.function_args?.[0] ?? '',
http_method: (selectedHook?.function_args?.[1] as 'GET' | 'POST') ?? 'POST',
function_type: isEdgeFunction({
ref,
restUrlTld,
url: selectedHook?.function_args?.[0] ?? '',
})
? 'supabase_function'
: 'http_request',
timeout_ms: Number(selectedHook?.function_args?.[4] ?? 5000),
events: selectedHook?.events ?? [],
httpHeaders: parseHeaders(selectedHook),
httpParameters: parseParameters(selectedHook),
})
}
}, [visible, selectedHook, ref, restUrlTld, form])
const queryClient = useQueryClient()
const onSubmit: SubmitHandler<WebhookFormValues> = async (values) => {
if (!project?.ref) {
return console.error('Project ref is required')
}
try {
setIsLoadingTable(true)
const selectedTable = await queryClient.fetchQuery(
tableEditorQueryOptions({
id: Number(values.table_id),
projectRef: project?.ref,
connectionString: project?.connectionString,
scoped,
})
)
if (!selectedTable) {
return toast.error('Unable to find selected table')
}
const headers = values.httpHeaders
.filter((header) => header.name && header.value)
.reduce(
(a, b) => {
a[b.name] = b.value
return a
},
{} as Record<string, string>
)
const parameters = values.httpParameters
.filter((param) => param.name && param.value)
.reduce(
(a, b) => {
a[b.name] = b.value
return a
},
{} as Record<string, string>
)
// replacer function with JSON.stringify to handle quotes properly
const stringifiedParameters = JSON.stringify(parameters, (_key, value) => {
if (typeof value === 'string') {
// Return the raw string without any additional escaping
return value
}
return value
})
const payload: PGTriggerCreate = {
events: values.events,
activation: 'AFTER',
orientation: 'ROW',
name: values.name,
table: selectedTable.name,
schema: selectedTable.schema,
function_name: 'http_request',
function_schema: 'supabase_functions',
function_args: [
values.http_url,
values.http_method,
JSON.stringify(headers),
stringifiedParameters,
values.timeout_ms.toString(),
],
}
if (selectedHook === undefined) {
createDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
payload,
})
} else {
updateDatabaseTrigger({
projectRef: project?.ref,
connectionString: project?.connectionString,
originalTrigger: selectedHook,
updatedTrigger: {
...payload,
enabled_mode: 'ORIGIN',
events: payload.events.map(keyword),
},
})
}
} catch (error) {
console.error('Failed to get table editor:', error)
toast.error('Failed to get table editor')
} finally {
setIsLoadingTable(false)
}
}
// This is intentionally kept outside of the useConfirmOnClose hook to force RHF to update the isDirty state.
const isDirty = form.formState.isDirty
const { confirmOnClose, modalProps } = useConfirmOnClose({
checkIsDirty: () => isDirty,
onClose: () => onClose(),
})
return (
<>
<SidePanel
size="xlarge"
visible={visible}
header={
selectedHook === undefined ? (
'Create a new database webhook'
) : (
<>
Update webhook <code className="text-sm">{selectedHook.name}</code>
</>
)
}
className="hooks-sidepanel mr-0 transform transition-all duration-300 ease-in-out"
onConfirm={() => {}}
onCancel={confirmOnClose}
customFooter={
<div className="flex w-full justify-end space-x-3 border-t border-default px-3 py-4">
<Button
size="tiny"
variant="default"
type="button"
onClick={confirmOnClose}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
size="tiny"
variant="primary"
type="submit"
form={FORM_ID}
disabled={isSubmitting}
loading={isSubmitting}
>
{selectedHook === undefined ? 'Create webhook' : 'Update webhook'}
</Button>
</div>
}
>
<Form {...form}>
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<FormContents form={form} selectedHook={selectedHook} />
</form>
</Form>
</SidePanel>
<DiscardChangesConfirmationDialog {...modalProps} />
</>
)
}