From 0f5bf7173e6bc6c3cfd8362501911453987ce0ff Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 12 Nov 2026 10:33:00 +0100 Subject: [PATCH 1/2] fix(postgrest-typegen): infer nullability on PostgreSQL 14 PostgreSQL 14 names range table entries RTE in its node trees where later versions write RANGETBLENTRY, so the query tree walk proved nothing there and every view and RETURNS TABLE column came out nullable. The walk now accepts both, and the parity goldens run on PostgreSQL 14, the oldest version the Supabase CLI accepts, as well as on 15. --- packages/postgrest-typegen/CLAUDE.md | 7 +- packages/postgrest-typegen/README.md | 10 +++ .../src/introspection/view-nullability.ts | 4 +- .../function-result-nullability.test.ts | 2 +- .../introspection/view-nullability.test.ts | 11 +-- .../test/parity/parity.test.ts | 73 +++++++++++-------- 6 files changed, 67 insertions(+), 40 deletions(-) diff --git a/packages/postgrest-typegen/CLAUDE.md b/packages/postgrest-typegen/CLAUDE.md index 0fb01f5..1503d88 100644 --- a/packages/postgrest-typegen/CLAUDE.md +++ b/packages/postgrest-typegen/CLAUDE.md @@ -37,7 +37,8 @@ Hard split between **introspection** and **generation**: (`pg_proc.prosqlbody`) and reads the function comment's `@notNull` lines (see "View column nullability" and "`RETURNS TABLE` column nullability" in the README). It reads only the join tree and range table, never `varnullingrels`, - so one code path serves PostgreSQL 15 and later. + so one code path serves PostgreSQL 14 and later. PostgreSQL 14 names range + table entries `RTE` where later versions write `RANGETBLENTRY`. - `src/generation/` -- `generateTypescript` / `generateGo` / `generatePython`. Pure functions: `GeneratorMetadata` in, the complete source file (ending in a single newline) out. No database access. @@ -150,3 +151,7 @@ extend `sortGeneratorMetadata` instead. - Generation unit tests: pure, no Docker, fixture-builder + per-language inline snapshots (`src/` and `test/generation/`). - Introspection/parity tests: `bun:test` + testcontainers PostgreSQL. +- PostgreSQL 14 is the minimum supported version. The parity test runs on it and + on PostgreSQL 15 against the same goldens, so every introspection query has to + run on 14 and produce the same metadata there. The version-specific suites + (view and `RETURNS TABLE` nullability) include 14 as well. diff --git a/packages/postgrest-typegen/README.md b/packages/postgrest-typegen/README.md index de9b503..5ce44d0 100644 --- a/packages/postgrest-typegen/README.md +++ b/packages/postgrest-typegen/README.md @@ -287,6 +287,16 @@ npm install pg npm install oxfmt@0.66.0 ``` +## Supported PostgreSQL versions + +`introspect()` supports PostgreSQL 14 and later, the same versions the Supabase +CLI accepts as `db.major_version`. The parity test renders the full test fixture +on PostgreSQL 14 and on PostgreSQL 15 and compares both against the same golden +files, so a query that reads a catalog column added in a later version, or any +other difference in what 14 produces, fails every pull request. Raising the +minimum changes `MINIMUM_POSTGRES_VERSION` in `test/parity/parity.test.ts` and +this section together. + ## postgrest-js type tests Every pull request that touches this package also runs postgrest-js's type tests diff --git a/packages/postgrest-typegen/src/introspection/view-nullability.ts b/packages/postgrest-typegen/src/introspection/view-nullability.ts index f155731..4d4a92d 100644 --- a/packages/postgrest-typegen/src/introspection/view-nullability.ts +++ b/packages/postgrest-typegen/src/introspection/view-nullability.ts @@ -66,7 +66,7 @@ export interface QueryNullability { * joins, subqueries, non-recursive CTEs and `GROUP BY`, and nothing on the way * puts it on the nullable side of an outer join, in a set operation or under * grouping sets. The outer-join check walks the join tree instead of reading - * `varnullingrels`, which PostgreSQL 15 does not have. + * `varnullingrels`, which PostgreSQL 14 and 15 do not have. */ export function queryNullability( rows: ViewDefinitionRow[], @@ -152,7 +152,7 @@ export function queryNullability( return false; } const entry = list(query.fields.rtable)[varno - 1]; - if (!isNode(entry, "RANGETBLENTRY")) { + if (!(isNode(entry, "RANGETBLENTRY") || isNode(entry, "RTE"))) { return false; } switch (entry.fields.rtekind) { diff --git a/packages/postgrest-typegen/test/introspection/function-result-nullability.test.ts b/packages/postgrest-typegen/test/introspection/function-result-nullability.test.ts index d400542..5df36de 100644 --- a/packages/postgrest-typegen/test/introspection/function-result-nullability.test.ts +++ b/packages/postgrest-typegen/test/introspection/function-result-nullability.test.ts @@ -87,7 +87,7 @@ const resultNullability = (metadata: GeneratorMetadata, name: string) => ).map((column) => [column.name, column.is_nullable]), ); -describe.each(["15", "17", "18"])( +describe.each(["14", "15", "17", "18"])( "RETURNS TABLE column nullability (PostgreSQL %s)", (version) => { let container: StartedPostgreSqlContainer; diff --git a/packages/postgrest-typegen/test/introspection/view-nullability.test.ts b/packages/postgrest-typegen/test/introspection/view-nullability.test.ts index 8443b2e..5dac1bf 100644 --- a/packages/postgrest-typegen/test/introspection/view-nullability.test.ts +++ b/packages/postgrest-typegen/test/introspection/view-nullability.test.ts @@ -11,10 +11,11 @@ import { introspect } from "../../src/introspection/index.ts"; import type { GeneratorMetadata } from "../../src/types.ts"; /** - * Integration coverage for view column nullability. PostgreSQL 15 has no - * `varnullingrels`, PostgreSQL 18 routes grouped columns through a group range - * table entry, and PostgreSQL 17 is what Supabase projects run, so each of them - * must resolve the same columns as non-null. + * Integration coverage for view column nullability. PostgreSQL 14 names range + * table entries `RTE`, PostgreSQL 14 and 15 have no `varnullingrels`, + * PostgreSQL 18 routes grouped columns through a group range table entry, and + * PostgreSQL 17 is what Supabase projects run, so each of them must resolve the + * same columns as non-null. */ const SCHEMA = /* SQL */ ` create schema private; @@ -71,7 +72,7 @@ const nullability = (metadata: GeneratorMetadata, view: string) => .map((column) => [column.name, column.is_nullable]), ); -describe.each(["15", "17", "18"])( +describe.each(["14", "15", "17", "18"])( "view column nullability (PostgreSQL %s)", (version) => { let container: StartedPostgreSqlContainer; diff --git a/packages/postgrest-typegen/test/parity/parity.test.ts b/packages/postgrest-typegen/test/parity/parity.test.ts index 7810730..ccf8a9f 100644 --- a/packages/postgrest-typegen/test/parity/parity.test.ts +++ b/packages/postgrest-typegen/test/parity/parity.test.ts @@ -39,39 +39,50 @@ const FIXTURE_DIR = join(import.meta.dir, "..", "introspection", "fixtures"); const EXPECTED_DIR = join(import.meta.dir, "expected"); const golden = (name: string) => readFileSync(join(EXPECTED_DIR, name), "utf8"); -let container: StartedPostgreSqlContainer; -let pool: Pool; -let metadata: GeneratorMetadata; +/** + * PostgreSQL 14 is the oldest version this package supports, matching the + * oldest `db.major_version` the Supabase CLI accepts. The goldens hold on it + * as well, so an introspection query that reads a newer catalog column, or a + * query tree walk that misreads its node trees, fails here. + */ +const MINIMUM_POSTGRES_VERSION = "14"; + +describe.each([MINIMUM_POSTGRES_VERSION, "15"])( + "generator parity vs postgres-meta CLI (PostgreSQL %s)", + (version) => { + let container: StartedPostgreSqlContainer; + let pool: Pool; + let metadata: GeneratorMetadata; -beforeAll(async () => { - container = await new PostgreSqlContainer("postgres:15-alpine") - .withUsername("postgres") - .withPassword("postgres") - .withDatabase("postgres") - .withWaitStrategy(Wait.forHealthCheck()) - .withStartupTimeout(120_000) - .start(); - pool = new Pool({ connectionString: container.getConnectionUri() }); - await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8")); - await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8")); - metadata = sortGeneratorMetadata(await introspect(pool)); -}, 180_000); + beforeAll(async () => { + container = await new PostgreSqlContainer(`postgres:${version}-alpine`) + .withUsername("postgres") + .withPassword("postgres") + .withDatabase("postgres") + .withWaitStrategy(Wait.forHealthCheck()) + .withStartupTimeout(120_000) + .start(); + pool = new Pool({ connectionString: container.getConnectionUri() }); + await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8")); + await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8")); + metadata = sortGeneratorMetadata(await introspect(pool)); + }, 180_000); -afterAll(async () => { - await pool?.end(); - await container?.stop(); -}); + afterAll(async () => { + await pool?.end(); + await container?.stop(); + }); -describe("generator parity vs postgres-meta CLI", () => { - test("typescript", async () => { - expect(await generateTypescript(metadata)).toBe(golden("typescript.txt")); - }); + test("typescript", async () => { + expect(await generateTypescript(metadata)).toBe(golden("typescript.txt")); + }); - test("go", () => { - expect(generateGo(metadata)).toBe(golden("go.txt")); - }); + test("go", () => { + expect(generateGo(metadata)).toBe(golden("go.txt")); + }); - test("python", () => { - expect(generatePython(metadata)).toBe(golden("python.txt")); - }); -}); + test("python", () => { + expect(generatePython(metadata)).toBe(golden("python.txt")); + }); + }, +); From 375d7e1d961586e093540851382d4aa120ed4db3 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Thu, 12 Nov 2026 11:14:21 +0100 Subject: [PATCH 2/2] docs(postgrest-typegen): keep the supported versions section user-facing --- packages/postgrest-typegen/CLAUDE.md | 5 ++++- packages/postgrest-typegen/README.md | 8 ++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/postgrest-typegen/CLAUDE.md b/packages/postgrest-typegen/CLAUDE.md index 1503d88..373d56d 100644 --- a/packages/postgrest-typegen/CLAUDE.md +++ b/packages/postgrest-typegen/CLAUDE.md @@ -154,4 +154,7 @@ extend `sortGeneratorMetadata` instead. - PostgreSQL 14 is the minimum supported version. The parity test runs on it and on PostgreSQL 15 against the same goldens, so every introspection query has to run on 14 and produce the same metadata there. The version-specific suites - (view and `RETURNS TABLE` nullability) include 14 as well. + (view and `RETURNS TABLE` nullability) include 14 as well. Raising the minimum + changes `MINIMUM_POSTGRES_VERSION` in `test/parity/parity.test.ts`, the + version lists of those suites and "Supported PostgreSQL versions" in the + README together. diff --git a/packages/postgrest-typegen/README.md b/packages/postgrest-typegen/README.md index 5ce44d0..a198b48 100644 --- a/packages/postgrest-typegen/README.md +++ b/packages/postgrest-typegen/README.md @@ -290,12 +290,8 @@ npm install oxfmt@0.66.0 ## Supported PostgreSQL versions `introspect()` supports PostgreSQL 14 and later, the same versions the Supabase -CLI accepts as `db.major_version`. The parity test renders the full test fixture -on PostgreSQL 14 and on PostgreSQL 15 and compares both against the same golden -files, so a query that reads a catalog column added in a later version, or any -other difference in what 14 produces, fails every pull request. Raising the -minimum changes `MINIMUM_POSTGRES_VERSION` in `test/parity/parity.test.ts` and -this section together. +CLI accepts as `db.major_version`. View and `RETURNS TABLE` column nullability +are inferred the same way on every supported version. ## postgrest-js type tests