Skip to content

Commit 434d8dc

Browse files
dmealingclaude
andcommitted
feat(migrate-ts): introspect Postgres CHECK constraints (pg_constraint)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d4b3db6 commit 434d8dc

2 files changed

Lines changed: 54 additions & 2 deletions

File tree

server/typescript/packages/migrate-ts/src/introspect/postgres.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
*/
2929
import type { Kysely } from "kysely";
3030
import { sql } from "kysely";
31-
import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor } from "../types.js";
31+
import type { SchemaSnapshot, TableDescriptor, ColumnDescriptor, ColumnDefault, IndexDescriptor, FkDescriptor, FkAction, ViewDescriptor, CheckDescriptor } from "../types.js";
3232
import type { SqlType } from "../sql-type.js";
3333
import { MIGRATIONS_TABLE } from "../apply/ledger.js";
3434

@@ -52,7 +52,7 @@ export async function introspectPostgres(db: Kysely<Record<string, unknown>>): P
5252
columns,
5353
indexes: await readPgIndexes(k, schema, name),
5454
foreignKeys: await readPgForeignKeys(k, schema, name),
55-
checks: [], // CHECK introspection is out of scope; expected-side derives them
55+
checks: await readPgChecks(k, schema, name),
5656
primaryKey,
5757
});
5858
}
@@ -437,3 +437,31 @@ function pgRuleToAction(rule: string): FkAction {
437437
if (r === "RESTRICT") return "restrict";
438438
return "no-action";
439439
}
440+
441+
/**
442+
* Read CHECK constraints for a table from pg_constraint. pg-mem does not support
443+
* pg_constraint, so this catches and returns [] there (same accepted gap as
444+
* readPgForeignKeys/readPgIndexes); real-DB coverage is the MIGRATE_TS_PG_URL-gated
445+
* integration test. `pg_get_constraintdef` returns `CHECK (<expr>)`; the wrapper is
446+
* stripped to the expression, compared via normalizeCheckExpr at diff time.
447+
*/
448+
async function readPgChecks(k: RawKysely, schema: string, table: string): Promise<CheckDescriptor[]> {
449+
try {
450+
const rows = await sql<{ name: string; def: string }>`
451+
SELECT con.conname AS name, pg_get_constraintdef(con.oid) AS def
452+
FROM pg_constraint con
453+
JOIN pg_class rel ON rel.oid = con.conrelid
454+
JOIN pg_namespace ns ON ns.oid = rel.relnamespace
455+
WHERE con.contype = 'c' AND rel.relname = ${table} AND ns.nspname = ${schema}
456+
`.execute(k);
457+
return rows.rows.map((r) => ({ name: r.name, expression: stripCheckWrapper(r.def) }));
458+
} catch {
459+
return []; // pg-mem: pg_constraint unsupported
460+
}
461+
}
462+
463+
/** `CHECK (<expr>)` → `<expr>` (balanced outer wrapper); returns input unchanged if no wrapper. */
464+
function stripCheckWrapper(def: string): string {
465+
const m = /^\s*CHECK\s*\((.*)\)\s*$/is.exec(def);
466+
return m ? m[1]!.trim() : def.trim();
467+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, test, expect } from "bun:test";
2+
import { Kysely, PostgresDialect, sql } from "kysely";
3+
import { Pool } from "pg";
4+
import { introspectPostgres } from "../../src/introspect/postgres.js";
5+
6+
const PG_URL = process.env.MIGRATE_TS_PG_URL;
7+
const d = PG_URL ? describe : describe.skip;
8+
9+
d("postgres CHECK introspection (real PG)", () => {
10+
test("reads a table's CHECK constraints with normalized-comparable expressions", async () => {
11+
const k = new Kysely<Record<string, unknown>>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) });
12+
try {
13+
const t = "chk_introspect_" + Math.random().toString(36).slice(2, 8);
14+
await sql.raw(`CREATE TABLE ${t} ( qty INTEGER NOT NULL, CONSTRAINT ${t}_qty_chk CHECK (qty >= 1 AND qty <= 100) )`).execute(k);
15+
const snap = await introspectPostgres(k);
16+
const table = snap.tables.find((x) => x.name === t)!;
17+
const chk = table.checks.find((c) => c.name === `${t}_qty_chk`);
18+
expect(chk).toBeDefined();
19+
// pg_get_constraintdef returns a parenthesized form; assert the expression is captured
20+
expect(chk!.expression.toLowerCase()).toContain("qty >= 1");
21+
await sql.raw(`DROP TABLE ${t}`).execute(k);
22+
} finally { await k.destroy(); }
23+
});
24+
});

0 commit comments

Comments
 (0)