Files
Andrew ValleteauandClaude Fable 5 6c6a721cb7 fix(pg-meta): scope remaining O(catalog) introspection queries behind pgMetaScopedIntrospection (#48148)
## 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), follow-up to #47894, plus regression-guard tests.

## What is the current behavior?

#47894 scoped the Table Editor and entity-definition introspection
queries, but four more `@supabase/pg-meta` query families still do
O(catalog) work per request. On a production project with a very large
catalog (hundreds of schemas, ~465K `pg_constraint` rows) they run 5 to
55 seconds each, trip the 58s `statement_timeout`, and spill sorts to
temp files. During a recent "DB CPU > 85%" incident on such a project,
24 of 27 active backends were running these queries concurrently.

1. **`tables.retrieve()` (single-table lookup by name+schema or id)**:
the `tables`/`columns` CTEs scan the whole catalog (`pg_class`,
`pg_constraint`, `pg_index`, all of `pg_attribute`, per-table sizes) and
the one-table predicate is applied only on the outer select. Same bug
class #47894 fixed for the OID-based table editor query; this sibling
path never got the treatment. It accounted for 94 of the 96
statement-timeout cancellations in the incident.
2. **Types listing**: the `t_enums` and `t_attributes` subqueries
aggregate the entire `pg_enum` and every composite relation before the
wrapper's schema filter applies.
3. **Table privileges**: `aclexplode` + double `pg_roles` join + GROUP
BY over every relation in the database; schema/OID filters applied only
after aggregation, in both `list()` and `retrieve()`.
4. **Row counts**: `getTableRowsCountSql` treats `reltuples = -1`
(never-analyzed table) as "small table, run exact count(*)". A freshly
bulk-loaded multi-million-row table times out on every Table Editor
pagination render.

Two Studio-side amplifiers turned one slow query into a sustained load
storm:

- `useTableQuery` (behind `tables.retrieve()`) mounts once per visible
foreign-key grid cell via `ForeignKeyFormatter`, so a single Table
Editor view fires ~20 concurrent copies against the FK target table. A
timed-out query caches nothing, and TanStack retries errored no-data
queries on every observer mount by default, so scrolling kept re-issuing
the 58s scan.
- `useTableApiAccessQuery` fetched table privileges for the entire
database and filtered down to one schema client-side.

## What is the new behavior?

**pg-meta (all behind the existing `pgMetaScopedIntrospection` flag,
same rollout mechanism as #47894; `scoped: false` keeps serving the
current SQL):**

- `tables.retrieve()`: the identifier is resolved to a scalar
`targetOid` init-plan and pushed into the base scan, primary-key,
relationships (both FK directions kept: `conrelid` or `confrelid`) and
columns CTEs. A materialized `target` CTE was deliberately avoided: it
acts as an optimization barrier and forces the very seq scans being
removed.
- Types: filter `pg_type`/`pg_namespace` first, then compute
enums/attributes per surviving row via correlated index-scan subqueries
(`pg_enum(enumtypid, enumsortorder)`, `pg_attribute(attrelid, attnum)`).
- Table privileges: schema/OID predicates injected into the base WHERE
before `aclexplode`/GROUP BY for `list()` and `retrieve()`.
- Row counts: `reltuples = -1` is treated as "unknown" and gated on
physical size via `pg_relation_size` (a cheap stat call; `relpages` is
equally stale pre-vacuum). At or below `THRESHOLD_ESTIMATE_BYTES`
(~10MB, derived from `THRESHOLD_COUNT` at a conservative ~200 bytes/row)
the exact count runs as before: fast by construction, and it avoids
bogus estimates since Postgres floors never-vacuumed heaps at 10 pages,
so an empty table would otherwise report ~2K estimated rows. Above the
gate the count routes through the EXPLAIN-based
`pg_temp.count_estimate`, or returns `-1`/`is_estimate = true` in
read-only contexts where the temp function cannot be created. The scoped
branch embeds the estimated select via `literal()` instead of legacy's
apostrophe-only escaping, so it stays correct under
`standard_conforming_strings = off`. `enforceExactCount` unchanged.

**Studio:**

- The flag decision is contained in the data layer instead of
prop-drilled: a small imperative accessor
(`apps/studio/data/scoped-introspection.ts`) is hydrated from `useFlag`
via a one-line `useSyncScopedIntrospection()` call in `DefaultLayout`,
and the query functions read it internally when building the pg-meta
SQL. `DefaultLayout` is shared by both the Next and TanStack router
trees; hydrating from `_app.tsx` alone would leave TanStack-served pages
permanently unscoped since `routes/__root.tsx` mounts its own flag
provider. Cold loads cannot race the flag: the query functions await a
readiness promise that resolves only after the sync hook has hydrated
the accessor with a loaded flag store (immediately on self-hosted where
flags are disabled; a 5s safety net armed lazily on the first `ready()`
call - not at module import, which would let the timer expire before a
project page ever mounts - bounds genuine ConfigCat outages). No
component threading, no query-key changes (remaining tradeoff,
documented in the module: a mid-session flag flip can serve stale-keyed
caches until refetch, fine for a session-stable rollout flag). #47894's
existing threading is left as-is and gets deleted together with the flag
in the cleanup PR. Also fixes the previously-missing `scoped`
pass-through in `getTableRowsCount`.
- Flag-independent hardening: `useTableQuery` now sets `retryOnMount:
false`, `refetchOnWindowFocus: false` and `staleTime: 5min`. Errored
(timed-out) queries no longer refire on every grid cell remount, while
stale successful metadata still revalidates on mount after `staleTime`.
- `useTableApiAccessQuery` now passes `includedSchemas: [schemaName]`;
the client-side filter stays as a safety net.
- The rows-count query is `enabled`-gated on the permission check
settling, so a transiently-false `canSQLAdminWrite` can no longer cache
a read-only `-1` count for a writable user (read replicas short-circuit
synchronously as before).

**Regression guards (extending the #47894 infrastructure):**

- Execution-based scoped-vs-legacy equivalence tests for all four
queries: both variants run against the test database and are compared
with raw `toEqual` - no normalization, ids included (types across 6
option combos, privileges incl. multi-grantee + PUBLIC,
`tables.retrieve` for both identifier branches, row counts for every
case where the two paths must agree). Two documented exceptions where
only the LEGACY side is sorted, because a de-normalized diagnostic run
proved legacy emits genuinely plan-dependent order there (an
adversarial-FK fixture shows it is neither oid, name, nor creation
order): the `types.list` outer row order (scoped adds `order by t.oid`;
legacy has no ORDER BY) and the `tables.retrieve` relationships array
(scoped orders by `constraint_name` + column-name tie-breakers - a
composite two-column FK expands to 4 entries sharing one
constraint_name). Everything else (privileges via `aclexplode` over the
same relacl, columns by `ordinal_position`, primary keys by `indkey`
order, enums by `enumsortorder`) is byte-identical between the two paths
with no test-side help. The one intentional value divergence,
never-analyzed tables above the size gate where legacy's exact count is
the timeout bug itself, is asserted explicitly as a divergence.
- Plan-guard budgets for every scoped query against the stress catalog
(extended with 200 enums + 200 composite types). Residual seq scans are
justified in-budget: `pg_constraint` max 2 (no index on `confrelid`),
`pg_attrdef` max 1, `pg_authid` max 2 (scales with role count, not
schema count).
- Legacy templates carry a FROZEN do-not-edit marker (they must keep
matching production behavior until the flag cleanup deletes them); the
ordinary test suite runs against the legacy default, so behavioral drift
there fails regular tests.

### Validation

- pg-meta: typecheck clean; the affected suites (types,
table-privileges, tables, rows-count, catalog-plan-guard) pass in full.
- Cross-version: the scoped-vs-legacy equivalence and rows-count
behavioral suites were validated on PostgreSQL 14, 15, and 17 (identical
results on all three). Two version-marginal planner choices surfaced on
17 (`pg_type` / `pg_class` seq scan vs full-index bitmap for per-schema
listings, both structurally unavoidable without an index leading on the
namespace column) and are carried as justified plan-guard budget
entries. A full 468-test suite run sequentially: 452 passed, 16 failures
verified environmental (13 timeouts in an untouched file that passes
27/27 in isolation on the marathon-run cluster, 3 cluster-global role
collisions from container reuse).
- Studio: `pnpm --filter studio typecheck` clean; 39/39 tests across the
touched data hooks; eslint clean on touched files.

### Rollout

Same staged ConfigCat rollout as #47894 via `pgMetaScopedIntrospection`
(user-email targeting first, then percentage, then 100%). The
`useTableQuery` hardening and the API-access schema scoping ship
unflagged (behavior-safe). Gate before percentage rollout: functionally
verify the FK popover/selector UX under the new
`staleTime`/`retryOnMount` settings (a just-edited FK target must not
look stale anywhere Studio does not already refetch on save). Once fully
rolled out, the legacy templates and flag get deleted together with
#47894's in one cleanup PR.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 08:07:08 +02:00

1656 lines
49 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterAll, beforeAll, expect, test } from 'vitest'
import pgMeta from '../src/index'
import { cleanupRoot, createTestDatabase } from './db/utils'
beforeAll(async () => {
// Any global setup if needed
})
afterAll(async () => {
await cleanupRoot()
})
const cleanNondet = (x: any) => {
const { columns, primary_keys, relationships, ...rest2 } = x
return {
columns: columns.map(({ id, table_id, ...rest }: any) => rest),
primary_keys: primary_keys.map(({ table_id, ...rest }: any) => rest),
relationships: relationships.map(({ id, ...rest }: any) => rest),
...rest2,
}
}
type TestDb = Awaited<ReturnType<typeof createTestDatabase>>
const withTestDatabase = (name: string, fn: (db: TestDb) => Promise<void>) => {
test(name, async () => {
const db = await createTestDatabase()
try {
await fn(db)
} finally {
await db.cleanup()
}
})
}
// Legacy relationships come out in plan-dependent order (frozen TABLES_SQL has
// no ORDER BY; proven by the adversarial-FK test below), so canonicalize ONLY
// the legacy side to the scoped ORDER BY: constraint_name, then the column names
// (a composite FK expands to one entry per source×target column pair).
const sortRels = (rels: any[]) =>
[...rels].sort(
(a, b) =>
a.constraint_name.localeCompare(b.constraint_name) ||
a.source_column_name.localeCompare(b.source_column_name) ||
a.target_column_name.localeCompare(b.target_column_name)
)
withTestDatabase(
'scoped tables.retrieve matches legacy (FKs both directions, PK, comment, enums)',
async ({ executeQuery }) => {
await executeQuery(`
create type mood as enum ('sad', 'ok', 'happy');
create table public.parent (id int primary key, label text);
create table public.child (
id int primary key,
parent_id int references public.parent(id),
feeling mood,
self_ref int references public.child(id)
);
comment on table public.child is 'a child table';
`)
const [{ parent_id }] = await executeQuery<{ parent_id: number }[]>(
`select 'public.parent'::regclass::oid::int8 as parent_id;`
)
const [{ child_id }] = await executeQuery<{ child_id: number }[]>(
`select 'public.child'::regclass::oid::int8 as child_id;`
)
// parent: incoming FK (child.parent_id -> parent.id). child: outgoing FK to
// parent + a self-referential FK. Cover the id and name+schema branches.
const cases = [
{
label: 'parent by id',
legacy: { id: Number(parent_id) },
scoped: { id: Number(parent_id), scoped: true },
},
{
label: 'child by name+schema',
legacy: { name: 'child', schema: 'public' },
scoped: { name: 'child', schema: 'public', scoped: true },
},
{
label: 'child by id',
legacy: { id: Number(child_id) },
scoped: { id: Number(child_id), scoped: true },
},
] as const
for (const c of cases) {
const legacy = pgMeta.tables.retrieve(c.legacy as any)
const scoped = pgMeta.tables.retrieve(c.scoped as any)
const legacyRow: any = legacy.zod.parse((await executeQuery(legacy.sql))[0])
const scopedRow: any = scoped.zod.parse((await executeQuery(scoped.sql))[0])
// Everything raw except the legacy relationships array, canonicalized to
// the scoped ORDER BY (legacy order is plan-dependent, see above).
legacyRow.relationships = sortRels(legacyRow.relationships)
expect(scopedRow, c.label).toEqual(legacyRow)
// Scoped's array is already in that order raw (no scoped-side sort).
expect(scopedRow.relationships, c.label).toEqual(sortRels(scopedRow.relationships))
}
// Sanity: the scoped parent retrieve surfaces the INCOMING FK from child.
const scopedParent = pgMeta.tables.retrieve({ id: Number(parent_id), scoped: true })
const parentRow: any = scopedParent.zod.parse((await executeQuery(scopedParent.sql))[0])
expect(
parentRow.relationships.some(
(r: any) => r.source_table_name === 'child' && r.target_table_name === 'parent'
)
).toBe(true)
}
)
// Regression proof for the relationships exception: FK creation order (zzz, aaa,
// mmm) differs from constraint_name order, so legacy's plan order cannot match
// scoped's without the legacy-side sort, while scoped is deterministic by name.
withTestDatabase(
'scoped tables.retrieve orders relationships by constraint_name (adversarial)',
async ({ executeQuery }) => {
await executeQuery(`
create table public.ref (id int primary key);
create table public.multi (id int primary key, a int, b int, c int);
alter table public.multi add constraint zzz_fk foreign key (a) references public.ref (id);
alter table public.multi add constraint aaa_fk foreign key (b) references public.ref (id);
alter table public.multi add constraint mmm_fk foreign key (c) references public.ref (id);
`)
const legacy = pgMeta.tables.retrieve({ name: 'multi', schema: 'public' })
const scoped = pgMeta.tables.retrieve({ name: 'multi', schema: 'public', scoped: true })
const legacyRow: any = legacy.zod.parse((await executeQuery(legacy.sql))[0])
const scopedRow: any = scoped.zod.parse((await executeQuery(scoped.sql))[0])
// Scoped is deterministically constraint_name-ordered, raw.
expect(scopedRow.relationships.map((r: any) => r.constraint_name)).toEqual([
'aaa_fk',
'mmm_fk',
'zzz_fk',
])
// Equal only after canonicalizing the (plan-dependent) legacy side.
legacyRow.relationships = sortRels(legacyRow.relationships)
expect(scopedRow).toEqual(legacyRow)
}
)
// Composite (multi-column) FK: the relationships subquery expands it to one
// entry per source×target column pair, all sharing constraint_name, so the
// scoped ORDER BY tie-breaks on the column names to stay deterministic.
withTestDatabase(
'scoped tables.retrieve orders composite-FK relationship entries deterministically',
async ({ executeQuery }) => {
await executeQuery(`
create table public.ctgt (x int, y int, primary key (x, y));
create table public.csrc (a int, b int, foreign key (a, b) references public.ctgt (x, y));
`)
const scoped = pgMeta.tables.retrieve({ name: 'csrc', schema: 'public', scoped: true })
const legacy = pgMeta.tables.retrieve({ name: 'csrc', schema: 'public' })
const scopedRow: any = scoped.zod.parse((await executeQuery(scoped.sql))[0])
const legacyRow: any = legacy.zod.parse((await executeQuery(legacy.sql))[0])
// Four entries (2 source cols × 2 target cols), all one constraint_name.
expect(scopedRow.relationships).toHaveLength(4)
expect(new Set(scopedRow.relationships.map((r: any) => r.constraint_name)).size).toBe(1)
// Scoped is already ordered by (name, source col, target col), raw.
expect(
scopedRow.relationships.map((r: any) => [r.source_column_name, r.target_column_name])
).toEqual([
['a', 'x'],
['a', 'y'],
['b', 'x'],
['b', 'y'],
])
legacyRow.relationships = sortRels(legacyRow.relationships)
expect(scopedRow).toEqual(legacyRow)
}
)
withTestDatabase(
'scoped tables.retrieve preserves composite primary-key column order (indkey, not sorted)',
async ({ executeQuery }) => {
// PK declared (b, a) -- index column order is the reverse of alphabetical, so
// an accidental name-sort would be caught here.
await executeQuery(
`create table public.composite_pk (a int not null, b int not null, c int, primary key (b, a));`
)
const scoped = pgMeta.tables.retrieve({ name: 'composite_pk', schema: 'public', scoped: true })
const scopedRow: any = scoped.zod.parse((await executeQuery(scoped.sql))[0])
// Emitted in index (indkey) order (b, a), the semantically meaningful order.
expect(scopedRow.primary_keys.map((pk: any) => pk.name)).toEqual(['b', 'a'])
// Legacy emits the same PK order (the PK aggregate is ordered identically in
// both renderings), so primary_keys match without any normalization.
const legacy = pgMeta.tables.retrieve({ name: 'composite_pk', schema: 'public' })
const legacyRow: any = legacy.zod.parse((await executeQuery(legacy.sql))[0])
expect(scopedRow.primary_keys).toEqual(legacyRow.primary_keys)
}
)
/** Original tests ported from postgres-meta */
withTestDatabase('list tables', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.list()
const res = zod.parse(await executeQuery(sql))
const { columns, primary_keys, relationships, ...rest } = res.find(
({ name }) => name === 'users'
)!
expect({
columns: columns!.map(({ id, table_id, ...rest }) => rest),
primary_keys: primary_keys.map(({ table_id, ...rest }) => rest),
relationships: relationships.map(({ id, ...rest }) => rest),
...rest,
}).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [
{
"check": null,
"comment": null,
"data_type": "bigint",
"default_value": null,
"enums": [],
"format": "int8",
"format_schema": "pg_catalog",
"identity_generation": "BY DEFAULT",
"is_generated": false,
"is_identity": true,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "id",
"ordinal_position": 1,
"schema": "public",
"table": "users",
},
{
"check": null,
"comment": null,
"data_type": "text",
"default_value": null,
"enums": [],
"format": "text",
"format_schema": "pg_catalog",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "name",
"ordinal_position": 2,
"schema": "public",
"table": "users",
},
{
"check": null,
"comment": null,
"data_type": "USER-DEFINED",
"default_value": "'ACTIVE'::user_status",
"enums": [
"ACTIVE",
"INACTIVE",
],
"format": "user_status",
"format_schema": "public",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "status",
"ordinal_position": 3,
"schema": "public",
"table": "users",
},
],
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "users",
"primary_keys": [
{
"name": "id",
"schema": "public",
"table_name": "users",
},
],
"relationships": [
{
"constraint_name": "todos_user-id_fkey",
"source_column_name": "user-id",
"source_schema": "public",
"source_table_name": "todos",
"target_column_name": "id",
"target_table_name": "users",
"target_table_schema": "public",
},
{
"constraint_name": "user_details_user_id_fkey",
"source_column_name": "user_id",
"source_schema": "public",
"source_table_name": "user_details",
"target_column_name": "id",
"target_table_name": "users",
"target_table_schema": "public",
},
],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase('list tables without columns', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.list({ includeColumns: false })
const res = zod.parse(await executeQuery(sql))
//@ts-expect-error columns doesn't exist at type level if includeColumns is false
const { columns, primary_keys, relationships, ...rest } = res.find(
({ name }) => name === 'users'
)!
expect({
primary_keys: primary_keys.map(({ table_id, ...rest }) => rest),
relationships: relationships.map(({ id, ...rest }) => rest),
...rest,
}).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "users",
"primary_keys": [
{
"name": "id",
"schema": "public",
"table_name": "users",
},
],
"relationships": [
{
"constraint_name": "todos_user-id_fkey",
"source_column_name": "user-id",
"source_schema": "public",
"source_table_name": "todos",
"target_column_name": "id",
"target_table_name": "users",
"target_table_schema": "public",
},
{
"constraint_name": "user_details_user_id_fkey",
"source_column_name": "user_id",
"source_schema": "public",
"source_table_name": "user_details",
"target_column_name": "id",
"target_table_name": "users",
"target_table_schema": "public",
},
],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase('list tables with included schemas', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.list({
includedSchemas: ['public'],
})
const res = zod.parse(await executeQuery(sql))
expect(res.length).toBeGreaterThan(0)
res.forEach((table) => {
expect(table.schema).toBe('public')
})
})
withTestDatabase('list tables with excluded schemas', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.list({
excludedSchemas: ['public'],
})
const res = zod.parse(await executeQuery(sql))
res.forEach((table) => {
expect(table.schema).not.toBe('public')
})
})
withTestDatabase(
'list tables with excluded schemas and include System Schemas',
async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.list({
excludedSchemas: ['public'],
includeSystemSchemas: true,
})
const res = zod.parse(await executeQuery(sql))
expect(res.length).toBeGreaterThan(0)
res.forEach((table) => {
expect(table.schema).not.toBe('public')
})
}
)
withTestDatabase('create, retrieve, update, and delete table', async ({ executeQuery }) => {
// Create table
const { sql: createSql } = await pgMeta.tables.create({
name: 'test',
comment: 'foo',
})
await executeQuery(createSql)
// Retrieve the created table
const { sql: retrieveSql, zod: retrieveZod } = await pgMeta.tables.retrieve({
name: 'test',
schema: 'public',
})
const retrieveRes = retrieveZod.parse((await executeQuery(retrieveSql))[0])
expect(retrieveRes).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [],
"comment": "foo",
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "test",
"primary_keys": [],
"relationships": [],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
// Update table
const { sql: updateSql } = await pgMeta.tables.update(retrieveRes!, {
name: 'test a',
rls_enabled: true,
rls_forced: true,
replica_identity: 'NOTHING',
comment: 'foo',
})
await executeQuery(updateSql)
// Retrieve the updated table
const { sql: retrieveUpdatedSql, zod: retrieveUpdatedZod } = await pgMeta.tables.retrieve({
name: 'test a',
schema: 'public',
})
const updateRes = retrieveUpdatedZod.parse((await executeQuery(retrieveUpdatedSql))[0])
expect(updateRes).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [],
"comment": "foo",
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "test a",
"primary_keys": [],
"relationships": [],
"replica_identity": "NOTHING",
"rls_enabled": true,
"rls_forced": true,
"schema": "public",
"size": Any<String>,
}
`
)
// Remove table
const { sql: removeSql } = await pgMeta.tables.remove(updateRes!)
await executeQuery(removeSql)
// Verify table is deleted
const { sql: verifyDeleteSql } = await pgMeta.tables.retrieve(updateRes!)
const verifyDeleteRes = await executeQuery(verifyDeleteSql)
expect(verifyDeleteRes).toHaveLength(0)
})
withTestDatabase('update with name unchanged', async ({ executeQuery }) => {
// Create table
const { sql: createSql } = await pgMeta.tables.create({ name: 't' })
await executeQuery(createSql)
// Get the created table
const { sql: retrieveSql, zod: retrieveZod } = await pgMeta.tables.retrieve({
name: 't',
schema: 'public',
})
const table = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update table with same name
const { sql: updateSql } = await pgMeta.tables.update(table!, { name: 't' })
await executeQuery(updateSql)
// Verify update
const { sql: verifySQL, zod: verifyZod } = await pgMeta.tables.retrieve({
name: 't',
schema: 'public',
})
const res = verifyZod.parse((await executeQuery(verifySQL))[0])
expect(res).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [],
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "t",
"primary_keys": [],
"relationships": [],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase("allow ' in comments", async ({ executeQuery }) => {
// Create table with single quote in comment
const { sql: createSql } = await pgMeta.tables.create({
name: 't',
comment: "'",
})
await executeQuery(createSql)
// Verify creation
const { sql: retrieveSql, zod: retrieveZod } = await pgMeta.tables.retrieve({
name: 't',
schema: 'public',
})
const res = retrieveZod.parse((await executeQuery(retrieveSql))[0])
expect(res).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [],
"comment": "'",
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "t",
"primary_keys": [],
"relationships": [],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase('primary keys', async ({ executeQuery }) => {
// Create table with columns
const { sql: createSql } = await pgMeta.tables.create({ name: 't' })
await executeQuery(createSql)
await executeQuery(`
ALTER TABLE t
ADD COLUMN c bigint,
ADD COLUMN cc text
`)
// Get the created table
const { sql: retrieveSql, zod: retrieveZod } = await pgMeta.tables.retrieve({
name: 't',
schema: 'public',
})
const table = retrieveZod.parse((await executeQuery(retrieveSql))[0])
// Update table with primary keys
const { sql: updateSql } = await pgMeta.tables.update(table!, {
primary_keys: [{ name: 'c' }, { name: 'cc' }],
})
await executeQuery(updateSql)
// Verify update
const { sql: verifySQL, zod: verifyZod } = await pgMeta.tables.retrieve({
name: 't',
schema: 'public',
})
const res = verifyZod.parse((await executeQuery(verifySQL))[0])
expect(cleanNondet(res)).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [
{
"check": null,
"comment": null,
"data_type": "bigint",
"default_value": null,
"enums": [],
"format": "int8",
"format_schema": "pg_catalog",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "c",
"ordinal_position": 1,
"schema": "public",
"table": "t",
},
{
"check": null,
"comment": null,
"data_type": "text",
"default_value": null,
"enums": [],
"format": "text",
"format_schema": "pg_catalog",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "cc",
"ordinal_position": 2,
"schema": "public",
"table": "t",
},
],
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "t",
"primary_keys": [
{
"name": "c",
"schema": "public",
"table_name": "t",
},
{
"name": "cc",
"schema": "public",
"table_name": "t",
},
],
"relationships": [],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
// /** Additional tests */
withTestDatabase('retrieve table by id', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.retrieve({ id: 16510 })
const res = zod.parse((await executeQuery(sql))[0])
expect(res).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [
{
"check": null,
"comment": null,
"data_type": "text",
"default_value": null,
"enums": [],
"format": "text",
"format_schema": "pg_catalog",
"id": "16510.2",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "name",
"ordinal_position": 2,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "integer",
"default_value": null,
"enums": [],
"format": "int4",
"format_schema": "pg_catalog",
"id": "16510.3",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "category",
"ordinal_position": 3,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "jsonb",
"default_value": null,
"enums": [],
"format": "jsonb",
"format_schema": "pg_catalog",
"id": "16510.4",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "metadata",
"ordinal_position": 4,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "timestamp without time zone",
"default_value": null,
"enums": [],
"format": "timestamp",
"format_schema": "pg_catalog",
"id": "16510.5",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "created_at",
"ordinal_position": 5,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "integer",
"default_value": "nextval('memes_id_seq'::regclass)",
"enums": [],
"format": "int4",
"format_schema": "pg_catalog",
"id": "16510.1",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "id",
"ordinal_position": 1,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "USER-DEFINED",
"default_value": "'old'::meme_status",
"enums": [
"new",
"old",
"retired",
],
"format": "meme_status",
"format_schema": "public",
"id": "16510.6",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "status",
"ordinal_position": 6,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
],
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "memes",
"primary_keys": [
{
"name": "id",
"schema": "public",
"table_id": 16510,
"table_name": "memes",
},
],
"relationships": [
{
"constraint_name": "memes_category_fkey",
"id": 16519,
"source_column_name": "category",
"source_schema": "public",
"source_table_name": "memes",
"target_column_name": "id",
"target_table_name": "category",
"target_table_schema": "public",
},
],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase('retrieve table by name and schema', async ({ executeQuery }) => {
const { sql, zod } = await pgMeta.tables.retrieve({ name: 'memes', schema: 'public' })
const res = zod.parse((await executeQuery(sql))[0])
expect(res).toMatchInlineSnapshot(
{
bytes: expect.any(Number),
dead_rows_estimate: expect.any(Number),
id: expect.any(Number),
live_rows_estimate: expect.any(Number),
size: expect.any(String),
},
`
{
"bytes": Any<Number>,
"columns": [
{
"check": null,
"comment": null,
"data_type": "text",
"default_value": null,
"enums": [],
"format": "text",
"format_schema": "pg_catalog",
"id": "16510.2",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "name",
"ordinal_position": 2,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "integer",
"default_value": null,
"enums": [],
"format": "int4",
"format_schema": "pg_catalog",
"id": "16510.3",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "category",
"ordinal_position": 3,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "jsonb",
"default_value": null,
"enums": [],
"format": "jsonb",
"format_schema": "pg_catalog",
"id": "16510.4",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "metadata",
"ordinal_position": 4,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "timestamp without time zone",
"default_value": null,
"enums": [],
"format": "timestamp",
"format_schema": "pg_catalog",
"id": "16510.5",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "created_at",
"ordinal_position": 5,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "integer",
"default_value": "nextval('memes_id_seq'::regclass)",
"enums": [],
"format": "int4",
"format_schema": "pg_catalog",
"id": "16510.1",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": false,
"is_unique": false,
"is_updatable": true,
"name": "id",
"ordinal_position": 1,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
{
"check": null,
"comment": null,
"data_type": "USER-DEFINED",
"default_value": "'old'::meme_status",
"enums": [
"new",
"old",
"retired",
],
"format": "meme_status",
"format_schema": "public",
"id": "16510.6",
"identity_generation": null,
"is_generated": false,
"is_identity": false,
"is_nullable": true,
"is_unique": false,
"is_updatable": true,
"name": "status",
"ordinal_position": 6,
"schema": "public",
"table": "memes",
"table_id": 16510,
},
],
"comment": null,
"dead_rows_estimate": Any<Number>,
"id": Any<Number>,
"live_rows_estimate": Any<Number>,
"name": "memes",
"primary_keys": [
{
"name": "id",
"schema": "public",
"table_id": 16510,
"table_name": "memes",
},
],
"relationships": [
{
"constraint_name": "memes_category_fkey",
"id": 16519,
"source_column_name": "category",
"source_schema": "public",
"source_table_name": "memes",
"target_column_name": "id",
"target_table_name": "category",
"target_table_schema": "public",
},
],
"replica_identity": "DEFAULT",
"rls_enabled": false,
"rls_forced": false,
"schema": "public",
"size": Any<String>,
}
`
)
})
withTestDatabase('retrieve error if missing identifiers', async ({}) => {
await expect(async () => {
//@ts-expect-error use with missing params
await pgMeta.tables.retrieve({ name: 'memes' })
}).rejects.toThrow('Must provide either id or name and schema')
})
withTestDatabase('remove table by id', async ({ executeQuery }) => {
// First create a test table
await executeQuery(`
CREATE TABLE test_remove_table (
id SERIAL PRIMARY KEY,
name TEXT
);
`)
// Get the table's id
const { sql: listSql, zod: listZod } = await pgMeta.tables.list()
const tables = listZod.parse(await executeQuery(listSql))
const tableId = tables.find((t) => t.name === 'test_remove_table')!
// Remove the table
const { sql } = await pgMeta.tables.remove(tableId)
await executeQuery(sql)
// Verify the table is gone
const tablesAfter = listZod.parse(await executeQuery(listSql))
expect(tablesAfter.find((t) => t.name === 'test_remove_table')).toBeUndefined()
})
withTestDatabase('remove table by name and schema', async ({ executeQuery }) => {
// First create a test table
await executeQuery(`
CREATE TABLE test_remove_table_2 (
id SERIAL PRIMARY KEY,
name TEXT
);
`)
// Remove the table
const { sql } = await pgMeta.tables.remove({
name: 'test_remove_table_2',
schema: 'public',
})
await executeQuery(sql)
// Verify the table is gone
const { sql: listSql, zod: listZod } = await pgMeta.tables.list()
const tables = listZod.parse(await executeQuery(listSql))
expect(tables.find((t) => t.name === 'test_remove_table_2')).toBeUndefined()
})
withTestDatabase('remove throws error for non-existent table', async ({ executeQuery }) => {
const { sql } = await pgMeta.tables.remove({
name: 'non_existent_table',
schema: 'public',
})
// With schema and name
await expect(executeQuery(sql)).rejects.toThrow(
`Failed to execute query: table "non_existent_table" does not exist`
)
})
withTestDatabase('remove throws error with missing identifiers', async ({}) => {
await expect(async () => {
//@ts-expect-error use with missing params
await pgMeta.tables.remove({ name: 'some_table' })
}).rejects.toThrow('SQL identifier cannot be null or undefined')
})
withTestDatabase('update table - rename', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_rename' })
await executeQuery(createSql)
// Update table name
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_rename', schema: 'public' },
{ name: 'test_renamed' }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_renamed',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.name).toBe('test_renamed')
})
withTestDatabase('update table - change schema', async ({ executeQuery }) => {
// Create test schema and table
await executeQuery('CREATE SCHEMA test_schema')
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_schema_move' })
await executeQuery(createSql)
// Move table to new schema
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_schema_move', schema: 'public' },
{ schema: 'test_schema' }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_schema_move',
schema: 'test_schema',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.schema).toBe('test_schema')
})
withTestDatabase('update table - row level security', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_rls' })
await executeQuery(createSql)
// Enable RLS
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_rls', schema: 'public' },
{ rls_enabled: true, rls_forced: true }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_rls',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.rls_enabled).toBe(true)
expect(res!.rls_forced).toBe(true)
})
withTestDatabase('update table - replica identity', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_replica' })
await executeQuery(createSql)
// Change replica identity
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_replica', schema: 'public' },
{ replica_identity: 'NOTHING' }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_replica',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.replica_identity).toBe('NOTHING')
})
withTestDatabase('update table - replica identity INDEX requires index name', async () => {
expect(() =>
pgMeta.tables.update({ id: 1, name: 'test', schema: 'public' }, { replica_identity: 'INDEX' })
).toThrow('replica_identity_index is required')
})
withTestDatabase('update table - primary keys', async ({ executeQuery }) => {
// Create test table with a column
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_pk' })
await executeQuery(createSql)
await executeQuery('ALTER TABLE test_pk ADD COLUMN id INT')
// Add primary key
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_pk', schema: 'public' },
{ primary_keys: [{ name: 'id' }] }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_pk',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.primary_keys).toHaveLength(1)
expect(res!.primary_keys[0].name).toBe('id')
})
withTestDatabase('update table - remove primary keys', async ({ executeQuery }) => {
// Create test table with primary key
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_pk_remove' })
await executeQuery(createSql)
await executeQuery('ALTER TABLE test_pk_remove ADD COLUMN id INT PRIMARY KEY')
const { sql: tableSql, zod: tableZod } = await pgMeta.tables.retrieve({
name: 'test_pk_remove',
schema: 'public',
})
const table = tableZod.parse((await executeQuery(tableSql))[0])
// Remove primary key
const { sql: updateSql } = await pgMeta.tables.update(table!, { primary_keys: [] })
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_pk_remove',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.primary_keys).toHaveLength(0)
})
withTestDatabase('update table - comment', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_comment' })
await executeQuery(createSql)
// Add comment
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_comment', schema: 'public' },
{ comment: 'Test comment' }
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_comment',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.comment).toBe('Test comment')
})
withTestDatabase('update table - multiple changes', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_multiple' })
await executeQuery(createSql)
await executeQuery('ALTER TABLE test_multiple ADD COLUMN id INT')
// Make multiple changes
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_multiple', schema: 'public' },
{
name: 'test_multiple_updated',
comment: 'Updated table',
rls_enabled: true,
primary_keys: [{ name: 'id' }],
replica_identity: 'FULL',
}
)
await executeQuery(updateSql)
// Verify all updates
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_multiple_updated',
schema: 'public',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res).toMatchObject({
name: 'test_multiple_updated',
comment: 'Updated table',
rls_enabled: true,
replica_identity: 'FULL',
primary_keys: [expect.objectContaining({ name: 'id' })],
})
})
withTestDatabase('update table - by id', async ({ executeQuery }) => {
// Create test table
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_by_id' })
await executeQuery(createSql)
// Get table id
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_by_id',
schema: 'public',
})
const table = zod.parse((await executeQuery(retrieveSql))[0])
// Update by id
const { sql: updateSql } = await pgMeta.tables.update(table!, { name: 'test_by_id_updated' })
await executeQuery(updateSql)
// Verify update
const { sql: verifySQL, zod: verifyZod } = await pgMeta.tables.retrieve({
name: 'test_by_id_updated',
schema: 'public',
})
const res = verifyZod.parse((await executeQuery(verifySQL))[0])
expect(res!.name).toBe('test_by_id_updated')
})
withTestDatabase('update table - error on non-existent table', async ({ executeQuery }) => {
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'non_existent', schema: 'public' },
{ name: 'new_name' }
)
await expect(executeQuery(updateSql)).rejects.toThrow()
})
withTestDatabase('update table - rename with schema change', async ({ executeQuery }) => {
// Create test schema and table
await executeQuery('CREATE SCHEMA test_schema')
const { sql: createSql } = await pgMeta.tables.create({ name: 'test_rename_schema' })
await executeQuery(createSql)
// Update both name and schema
const { sql: updateSql } = await pgMeta.tables.update(
{ id: 0, name: 'test_rename_schema', schema: 'public' },
{
name: 'test_renamed_schema',
schema: 'test_schema',
}
)
await executeQuery(updateSql)
// Verify update
const { sql: retrieveSql, zod } = await pgMeta.tables.retrieve({
name: 'test_renamed_schema',
schema: 'test_schema',
})
const res = zod.parse((await executeQuery(retrieveSql))[0])
expect(res!.name).toBe('test_renamed_schema')
expect(res!.schema).toBe('test_schema')
})
// Table creation test cases
const tableCreationTests = [
{
name: 'create table with default schema',
input: {
name: 'test_table_1',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with explicit public schema',
input: {
name: 'test_table_2',
schema: 'public',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with custom schema',
input: {
name: 'test_table_3',
schema: 'custom_schema',
},
expectedSchema: 'custom_schema',
expectedComment: null,
beforeTest: async (
executeQuery: Awaited<ReturnType<typeof createTestDatabase>>['executeQuery']
) => {
await executeQuery('CREATE SCHEMA IF NOT EXISTS custom_schema')
},
},
{
name: 'create table with comment',
input: {
name: 'test_table_4',
comment: 'Test comment',
},
expectedSchema: 'public',
expectedComment: 'Test comment',
},
{
name: 'create table with empty string comment',
input: {
name: 'test_table_5',
comment: '',
},
expectedSchema: 'public',
expectedComment: null, // PostgreSQL treats empty string comments as NULL
},
{
name: 'create table with null comment',
input: {
name: 'test_table_6',
comment: null,
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with uppercase name',
input: {
name: 'UPPERCASE_TABLE',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with mixed case name',
input: {
name: 'MixedCase_Table_Name',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with quoted name',
input: {
name: 'table "with" quotes',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with special characters',
input: {
name: 'table$with#special@chars',
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with name at maximum length',
input: {
name: 'a'.repeat(63), // PostgreSQL has a 63-byte limit for identifiers
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with schema containing special characters',
input: {
name: 'normal_table',
schema: 'Special.Schema$Name',
},
expectedSchema: 'Special.Schema$Name',
expectedComment: null,
beforeTest: async (executeQuery: TestDb['executeQuery']) => {
await executeQuery('CREATE SCHEMA IF NOT EXISTS "Special.Schema$Name"')
},
},
{
name: 'create table with very long name',
input: {
name: 'a'.repeat(63),
},
expectedSchema: 'public',
expectedComment: null,
},
{
name: 'create table with special characters in name',
input: {
name: 'table,name',
},
expectedSchema: 'public',
expectedComment: null,
},
]
// SQL injection test cases
const sqlInjectionTests = [
{
name: 'prevent SQL injection in table name',
input: {
name: "table_name'; DROP TABLE users; --",
},
},
{
name: 'prevent SQL injection in comment',
input: {
name: 'safe_table',
comment: "normal comment'; DROP TABLE users; --",
},
},
]
// Error test cases
const errorTests = [
{
name: 'fail on duplicate table name',
input: { name: 'duplicate_table' },
setup: async (executeQuery: TestDb['executeQuery']) => {
await executeQuery(pgMeta.tables.create({ name: 'duplicate_table' }).sql)
},
expectedError: /relation.*already exists/,
},
{
name: 'fail on invalid schema',
input: { name: 'test_table', schema: 'nonexistent_schema' },
expectedError: /schema.*does not exist/,
},
{
name: 'fail on empty table name',
input: {
name: '',
},
expectedError: /zero-length delimited identifier/,
},
{
name: 'fail on schema name exceeding maximum length',
input: {
name: 'table',
schema: 'a'.repeat(64),
},
expectedError: /schema.*does not exist/,
},
]
// Generate individual test cases for successful table creation
for (const testCase of tableCreationTests) {
withTestDatabase(testCase.name, async ({ executeQuery }) => {
if (testCase.beforeTest) {
await testCase.beforeTest(executeQuery)
}
const { sql } = pgMeta.tables.create(testCase.input)
await executeQuery(sql)
const { sql: listSql, zod: listZod } = await pgMeta.tables.list()
const tables = listZod.parse(await executeQuery(listSql))
const createdTable = tables.find((t) => t.name === testCase.input.name)
expect(createdTable).toBeDefined()
expect(createdTable?.schema).toBe(testCase.expectedSchema)
expect(createdTable?.comment).toBe(testCase.expectedComment)
})
}
// Generate individual test cases for SQL injection prevention
for (const testCase of sqlInjectionTests) {
withTestDatabase(`create - ${testCase.name}`, async ({ executeQuery }) => {
const { sql } = pgMeta.tables.create(testCase.input)
await executeQuery(sql)
// Verify table was created with correct name
const { sql: listSql, zod: listZod } = await pgMeta.tables.list()
const tables = listZod.parse(await executeQuery(listSql))
expect(tables.find((t) => t.name === testCase.input.name)).toBeDefined()
// Verify users table still exists (wasn't dropped)
expect(tables.find((t) => t.name === 'users')).toBeDefined()
})
}
// Generate individual test cases for error conditions
for (const testCase of errorTests) {
withTestDatabase(`create - ${testCase.name}`, async ({ executeQuery }) => {
if (testCase.setup) {
await testCase.setup(executeQuery)
}
const { sql } = pgMeta.tables.create(testCase.input)
await expect(executeQuery(sql)).rejects.toThrow(testCase.expectedError)
})
}