diff --git a/packages/postgrest-typegen/CLAUDE.md b/packages/postgrest-typegen/CLAUDE.md index 0fb01f5..373d56d 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,10 @@ 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. 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 de9b503..a198b48 100644 --- a/packages/postgrest-typegen/README.md +++ b/packages/postgrest-typegen/README.md @@ -287,6 +287,12 @@ 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`. View and `RETURNS TABLE` column nullability +are inferred the same way on every supported version. + ## 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")); + }); + }, +);