mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 10:29:14 +08:00
## 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>
370 lines
16 KiB
TypeScript
370 lines
16 KiB
TypeScript
import { afterAll, expect, test } from 'vitest'
|
|
|
|
import { getTableRowsCountSql } from '../../../src'
|
|
import type { Filter } from '../../../src/query'
|
|
import { cleanupRoot, createTestDatabase } from '../../db/utils'
|
|
|
|
type Db = Awaited<ReturnType<typeof createTestDatabase>>
|
|
type CountRow = { count: number; is_estimate: boolean }
|
|
type CountArgs = Parameters<typeof getTableRowsCountSql>[0]
|
|
|
|
afterAll(async () => {
|
|
await cleanupRoot()
|
|
})
|
|
|
|
const withTestDatabase = (name: string, fn: (db: Db) => Promise<void>) => {
|
|
test(name, async () => {
|
|
const db = await createTestDatabase()
|
|
try {
|
|
await fn(db)
|
|
} finally {
|
|
await db.cleanup()
|
|
}
|
|
})
|
|
}
|
|
|
|
const tableOf = async (db: Db, qualified: string, name: string, schema: string) => {
|
|
const [{ id }] = await db.executeQuery<{ id: number }[]>(
|
|
`select '${qualified}'::regclass::oid::int8 as id;`
|
|
)
|
|
return { id: Number(id), name, schema }
|
|
}
|
|
|
|
const reltuplesOf = async (db: Db, qualified: string) => {
|
|
const [{ reltuples }] = await db.executeQuery<{ reltuples: number }[]>(
|
|
`select reltuples::int8 as reltuples from pg_class where oid = '${qualified}'::regclass;`
|
|
)
|
|
return Number(reltuples)
|
|
}
|
|
|
|
const runCount = async (db: Db, args: CountArgs) => {
|
|
const [row] = await db.executeQuery<CountRow[]>(getTableRowsCountSql(args))
|
|
return { count: Number(row.count), is_estimate: row.is_estimate }
|
|
}
|
|
|
|
// Execute BOTH the scoped and legacy renderings of the same args against the DB
|
|
// and assert identical results -- the equivalence contract for every case where
|
|
// the two paths must agree (the ONLY intentional divergence is a never-analyzed
|
|
// table whose heap exceeds the byte gate; that asymmetry is the fix and is
|
|
// asserted separately below).
|
|
const assertScopedEqualsLegacy = async (db: Db, base: Omit<CountArgs, 'scoped'>) => {
|
|
const legacy = await runCount(db, { ...base, scoped: false })
|
|
const scoped = await runCount(db, { ...base, scoped: true })
|
|
expect(scoped, 'scoped must equal legacy for this case').toEqual(legacy)
|
|
return scoped
|
|
}
|
|
|
|
// A never-analyzed table has pg_class.reltuples = -1 -- true for a brand-new
|
|
// EMPTY table, a small one, AND a freshly bulk-loaded huge one. autovacuum is
|
|
// disabled on every fixture so reltuples cannot flip mid-test.
|
|
|
|
withTestDatabase(
|
|
'scoped: empty never-analyzed table -> exact count 0, is_estimate=false (both modes)',
|
|
async (db) => {
|
|
await db.executeQuery(
|
|
`create table public.empty_t (id int primary key) with (autovacuum_enabled = false);`
|
|
)
|
|
expect(await reltuplesOf(db, 'public.empty_t')).toBe(-1)
|
|
const table = await tableOf(db, 'public.empty_t', 'empty_t', 'public')
|
|
|
|
// Postgres estimates a never-vacuumed heap at a ~10-page minimum, so a naive
|
|
// reltuples=-1 -> estimate would report phantom rows here. The size gate
|
|
// routes an empty (0-byte) heap to an exact count instead.
|
|
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 0, is_estimate: false })
|
|
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
|
|
count: 0,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped: small never-analyzed table -> exact count, is_estimate=false (both modes)',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.small_unanalyzed (id int primary key, val text)
|
|
with (autovacuum_enabled = false);
|
|
insert into public.small_unanalyzed select g, 'r' || g from generate_series(1, 1000) g;
|
|
`)
|
|
expect(await reltuplesOf(db, 'public.small_unanalyzed')).toBe(-1)
|
|
const table = await tableOf(db, 'public.small_unanalyzed', 'small_unanalyzed', 'public')
|
|
|
|
// Heap is a few tens of KB -- well under the byte gate -> exact count.
|
|
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 1000, is_estimate: false })
|
|
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
|
|
count: 1000,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped INTENTIONALLY diverges from legacy: large never-analyzed table -> estimate',
|
|
async (db) => {
|
|
// Wide rows (~300-byte payload) so the heap clears the ~10MB byte gate with a
|
|
// modest, fast-to-insert row count (~19MB at 60k rows) -- the case the legacy
|
|
// path mishandles (treats reltuples=-1 as small, runs a timing-out count).
|
|
await db.executeQuery(`
|
|
create table public.bulk_unanalyzed (id int primary key, val text)
|
|
with (autovacuum_enabled = false);
|
|
insert into public.bulk_unanalyzed
|
|
select g, repeat('x', 300) from generate_series(1, 60000) g;
|
|
`)
|
|
expect(await reltuplesOf(db, 'public.bulk_unanalyzed')).toBe(-1)
|
|
const [{ bytes }] = await db.executeQuery<{ bytes: number }[]>(
|
|
`select pg_relation_size('public.bulk_unanalyzed'::regclass)::int8 as bytes;`
|
|
)
|
|
expect(Number(bytes)).toBeGreaterThan(10_000_000)
|
|
const table = await tableOf(db, 'public.bulk_unanalyzed', 'bulk_unanalyzed', 'public')
|
|
|
|
// Non-readonly scoped: EXPLAIN-based estimate (works without ANALYZE).
|
|
const scoped = await runCount(db, { table, scoped: true })
|
|
expect(scoped.is_estimate).toBe(true)
|
|
expect(scoped.count).toBeGreaterThan(1000)
|
|
expect(scoped.count).not.toBe(-1)
|
|
|
|
// Readonly scoped: cannot create the estimate function -> reports -1 as an
|
|
// estimate rather than a timing-out exact count.
|
|
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
|
|
count: -1,
|
|
is_estimate: true,
|
|
})
|
|
|
|
// The intentional divergence: legacy (scoped:false) still runs an exact count
|
|
// on the -1 table (the pre-fix behavior the scoped path corrects).
|
|
const legacy = await runCount(db, { table })
|
|
expect(legacy).toEqual({ count: 60000, is_estimate: false })
|
|
expect(legacy.is_estimate).not.toBe(scoped.is_estimate)
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped == legacy for an analyzed table below THRESHOLD_COUNT (default, filtered, enforceExactCount)',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.analyzed_small (id int primary key, status text);
|
|
insert into public.analyzed_small
|
|
select g, case when g % 2 = 0 then 'active' else 'inactive' end
|
|
from generate_series(1, 10) g;
|
|
analyze public.analyzed_small;
|
|
`)
|
|
const table = await tableOf(db, 'public.analyzed_small', 'analyzed_small', 'public')
|
|
const activeFilter: Filter[] = [{ column: 'status', operator: '=', value: 'active' }]
|
|
|
|
// Default count: both paths exact-count a small analyzed table.
|
|
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({ count: 10, is_estimate: false })
|
|
// Read-only default count agrees too.
|
|
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
|
|
count: 10,
|
|
is_estimate: false,
|
|
})
|
|
// Filtered count agrees.
|
|
expect(await assertScopedEqualsLegacy(db, { table, filters: activeFilter })).toEqual({
|
|
count: 5,
|
|
is_estimate: false,
|
|
})
|
|
// enforceExactCount ignores scoped entirely and agrees, with/without filters.
|
|
expect(await assertScopedEqualsLegacy(db, { table, enforceExactCount: true })).toEqual({
|
|
count: 10,
|
|
is_estimate: false,
|
|
})
|
|
expect(
|
|
await assertScopedEqualsLegacy(db, {
|
|
table,
|
|
enforceExactCount: true,
|
|
filters: activeFilter,
|
|
})
|
|
).toEqual({ count: 5, is_estimate: false })
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped == legacy for an analyzed table over THRESHOLD_COUNT (estimate path unchanged)',
|
|
async (db) => {
|
|
// reltuples > 50000 after analyze routes BOTH paths to the estimate branch
|
|
// (raw reltuples when unfiltered) -- identical output; the byte gate only
|
|
// affects the reltuples = -1 case, not this one.
|
|
await db.executeQuery(`
|
|
create table public.big_analyzed (id int primary key)
|
|
with (autovacuum_enabled = false);
|
|
insert into public.big_analyzed select generate_series(1, 60000);
|
|
analyze public.big_analyzed;
|
|
`)
|
|
expect(await reltuplesOf(db, 'public.big_analyzed')).toBeGreaterThan(50000)
|
|
const table = await tableOf(db, 'public.big_analyzed', 'big_analyzed', 'public')
|
|
|
|
// Non-readonly: both return the raw reltuples estimate.
|
|
const scoped = await assertScopedEqualsLegacy(db, { table })
|
|
expect(scoped.is_estimate).toBe(true)
|
|
expect(scoped.count).toBeGreaterThan(1000)
|
|
|
|
// Read-only: both report -1 as an estimate.
|
|
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
|
|
count: -1,
|
|
is_estimate: true,
|
|
})
|
|
|
|
// enforceExactCount over the threshold still runs a real count in both paths.
|
|
expect(await assertScopedEqualsLegacy(db, { table, enforceExactCount: true })).toEqual({
|
|
count: 60000,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
// A partitioned PARENT (relkind 'p') has no storage of its own, so
|
|
// pg_relation_size(parent) is 0. The size gate must use the whole partition tree
|
|
// or a large never-analyzed partitioned table would be misclassified as small
|
|
// and exact-counted across all partitions -- the exact timeout being fixed.
|
|
withTestDatabase(
|
|
'scoped: large never-analyzed PARTITIONED table -> estimate (partition-tree size gate)',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.part_big (id int, region text, val text) partition by list (region);
|
|
create table public.part_big_e partition of public.part_big for values in ('east')
|
|
with (autovacuum_enabled = false);
|
|
create table public.part_big_w partition of public.part_big for values in ('west')
|
|
with (autovacuum_enabled = false);
|
|
insert into public.part_big
|
|
select g, case when g % 2 = 0 then 'east' else 'west' end, repeat('x', 300)
|
|
from generate_series(1, 45000) g;
|
|
`)
|
|
// Parent is never-analyzed (reltuples = -1) and has zero own heap size...
|
|
expect(await reltuplesOf(db, 'public.part_big')).toBe(-1)
|
|
const [{ own, tree }] = await db.executeQuery<{ own: number; tree: number }[]>(`
|
|
select
|
|
pg_relation_size('public.part_big'::regclass)::int8 as own,
|
|
(select coalesce(sum(pg_relation_size(relid)), 0)
|
|
from pg_partition_tree('public.part_big'::regclass))::int8 as tree;
|
|
`)
|
|
expect(Number(own)).toBe(0) // ...so pg_relation_size alone would say "small"
|
|
expect(Number(tree)).toBeGreaterThan(10_000_000) // the tree sum clears the gate
|
|
const table = await tableOf(db, 'public.part_big', 'part_big', 'public')
|
|
|
|
const scoped = await runCount(db, { table, scoped: true })
|
|
expect(scoped.is_estimate).toBe(true)
|
|
expect(scoped.count).toBeGreaterThan(1000)
|
|
expect(scoped.count).not.toBe(-1)
|
|
|
|
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
|
|
count: -1,
|
|
is_estimate: true,
|
|
})
|
|
|
|
// Legacy still exact-counts across all partitions (the pre-fix behavior).
|
|
expect(await runCount(db, { table })).toEqual({ count: 45000, is_estimate: false })
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped: small never-analyzed PARTITIONED table -> exact count (both modes)',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.part_small (id int, region text) partition by list (region);
|
|
create table public.part_small_e partition of public.part_small for values in ('east')
|
|
with (autovacuum_enabled = false);
|
|
create table public.part_small_w partition of public.part_small for values in ('west')
|
|
with (autovacuum_enabled = false);
|
|
insert into public.part_small
|
|
select g, case when g % 2 = 0 then 'east' else 'west' end from generate_series(1, 100) g;
|
|
`)
|
|
expect(await reltuplesOf(db, 'public.part_small')).toBe(-1)
|
|
const table = await tableOf(db, 'public.part_small', 'part_small', 'public')
|
|
|
|
expect(await runCount(db, { table, scoped: true })).toEqual({ count: 100, is_estimate: false })
|
|
expect(await runCount(db, { table, scoped: true, isReadOnlyContext: true })).toEqual({
|
|
count: 100,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped == legacy for an ANALYZED partitioned table below THRESHOLD_COUNT',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.part_analyzed (id int, region text) partition by list (region);
|
|
create table public.part_analyzed_e partition of public.part_analyzed for values in ('east');
|
|
create table public.part_analyzed_w partition of public.part_analyzed for values in ('west');
|
|
insert into public.part_analyzed
|
|
select g, case when g % 2 = 0 then 'east' else 'west' end from generate_series(1, 100) g;
|
|
analyze public.part_analyzed;
|
|
`)
|
|
const table = await tableOf(db, 'public.part_analyzed', 'part_analyzed', 'public')
|
|
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({
|
|
count: 100,
|
|
is_estimate: false,
|
|
})
|
|
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
|
|
count: 100,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
withTestDatabase(
|
|
'scoped == legacy for a view flowing through the row-count builder',
|
|
async (db) => {
|
|
await db.executeQuery(`
|
|
create table public.view_src (id int primary key);
|
|
insert into public.view_src select generate_series(1, 7);
|
|
create view public.v_rows as select * from public.view_src;
|
|
`)
|
|
const table = await tableOf(db, 'public.v_rows', 'v_rows', 'public')
|
|
|
|
// A view has no heap (pg_relation_size 0, no partition tree) -> the gate keeps
|
|
// an exact count; scoped and legacy agree.
|
|
expect(await assertScopedEqualsLegacy(db, { table })).toEqual({ count: 7, is_estimate: false })
|
|
expect(await assertScopedEqualsLegacy(db, { table, isReadOnlyContext: true })).toEqual({
|
|
count: 7,
|
|
is_estimate: false,
|
|
})
|
|
}
|
|
)
|
|
|
|
// ── Fix #1: the embedded estimate select is quoted with literal(), so backslash
|
|
// identifiers survive regardless of the session's standard_conforming_strings.
|
|
withTestDatabase(
|
|
'scoped estimate path quotes the embedded select safely (backslash names, scs on & off)',
|
|
async (db) => {
|
|
// Names contain a backslash; in the JS template `\\` is one literal backslash.
|
|
await db.executeQuery(`
|
|
create table public."wei\\rd" ("col\\umn" int) with (autovacuum_enabled = false);
|
|
insert into public."wei\\rd" select g % 3 from generate_series(1, 60000) g;
|
|
analyze public."wei\\rd";
|
|
`)
|
|
const [{ id }] = await db.executeQuery<{ id: number }[]>(
|
|
`select 'public."wei\\rd"'::regclass::oid::int8 as id;`
|
|
)
|
|
// reltuples > THRESHOLD_COUNT + a filter -> the estimate (count_estimate)
|
|
// branch runs, embedding the filtered select (with the backslash names) as a
|
|
// literal inside the function call.
|
|
const table = { id: Number(id), name: 'wei\\rd', schema: 'public' }
|
|
const filters: Filter[] = [{ column: 'col\\umn', operator: '=', value: 1 }]
|
|
|
|
// Default standard_conforming_strings (on): both paths take the estimate
|
|
// branch and agree on the value.
|
|
const scopedOn = await runCount(db, { table, scoped: true, filters })
|
|
expect(scopedOn.is_estimate).toBe(true)
|
|
expect(Number.isFinite(scopedOn.count)).toBe(true)
|
|
expect(await assertScopedEqualsLegacy(db, { table, filters })).toEqual(scopedOn)
|
|
|
|
// standard_conforming_strings = off in the SAME connection: the SET, the
|
|
// CREATE FUNCTION, and the count must share one query (the test uses a pool).
|
|
const withScsOff = (sql: string) => `set standard_conforming_strings = off;\n${sql}`
|
|
const [scopedOff] = await db.executeQuery<CountRow[]>(
|
|
withScsOff(getTableRowsCountSql({ table, scoped: true, filters }))
|
|
)
|
|
// literal()/E'...' keeps the backslash identifiers intact under scs=off.
|
|
expect(scopedOff.is_estimate).toBe(true)
|
|
expect(Number.isFinite(Number(scopedOff.count))).toBe(true)
|
|
|
|
// The legacy apostrophe-only escaping mangles the backslashes under scs=off
|
|
// (the bug the scoped path fixes), so legacy errors there -- assert only the
|
|
// scoped behavior, per contract.
|
|
await expect(
|
|
db.executeQuery(withScsOff(getTableRowsCountSql({ table, filters })))
|
|
).rejects.toThrow()
|
|
}
|
|
)
|