Skip to content

Commit 1a7fce5

Browse files
dmealingclaude
andcommitted
feat(migrate-ts): emit CHECK constraints for postgres (create-table + alter)
Postgres: inline CONSTRAINT ... CHECK in CREATE TABLE; add-check/drop-check emit ALTER TABLE ADD/DROP CONSTRAINT with inverse down statements. Exhaustiveness (Change kind is a closed union): add add-check/drop-check arms to the sqlite emit switches (STAGE_ORDER + changeTable + renderUpNative/ renderDownNative throw "CHECK migration not implemented for sqlite (recreate path pending)" rather than mis-emit) and to errors.ts changeLocator. Defensive: diff + create-table emit tolerate an absent `checks` array (?? []) so older snapshots / hand-built fixtures don't crash — mirrors the v1->v2 parse upgrade that defaults absent checks to []. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 946d5c9 commit 1a7fce5

5 files changed

Lines changed: 57 additions & 3 deletions

File tree

server/typescript/packages/migrate-ts/src/diff/index.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export async function diff(
129129
fk, status: ALLOWED,
130130
});
131131
}
132-
for (const check of table.checks) {
132+
for (const check of table.checks ?? []) {
133133
changes.push({
134134
kind: "add-check", table: table.name, ...schemaSpread(table.schema),
135135
check, status: ALLOWED,
@@ -269,8 +269,10 @@ function diffTableChecks(
269269
): void {
270270
const table = expected.name;
271271
const sx = schemaSpread(expected.schema);
272-
const expectedChk = new Map(expected.checks.map((c) => [c.name, c]));
273-
const actualChk = new Map(actual.checks.map((c) => [c.name, c]));
272+
// Tolerate a missing `checks` array (older snapshots / hand-built fixtures);
273+
// mirrors the v1→v2 parse upgrade that defaults absent checks to [].
274+
const expectedChk = new Map((expected.checks ?? []).map((c) => [c.name, c]));
275+
const actualChk = new Map((actual.checks ?? []).map((c) => [c.name, c]));
274276
for (const [name, ec] of expectedChk) {
275277
const ac = actualChk.get(name);
276278
if (!ac) {

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const STAGE_ORDER: Record<Change["kind"], number> = {
1616
"rename-column": 3, "rename-table": 3,
1717
"add-index": 4, "drop-index": 4,
1818
"add-fk": 5, "drop-fk": 5,
19+
"add-check": 5, "drop-check": 5,
1920
"drop-table": 6,
2021
"create-view": 7, "replace-view": 7,
2122
};
@@ -61,6 +62,8 @@ function renderUp(c: Change): string {
6162
case "drop-index": return `DROP INDEX ${quoteIndexQualified(c.index, c.schema)};`;
6263
case "add-fk": return renderAddFk(c.table, c.schema, c.fk);
6364
case "drop-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk)};`;
65+
case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD CONSTRAINT ${quote(c.check.name)} CHECK (${c.check.expression});`;
66+
case "drop-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check)};`;
6467
case "create-view": return renderCreateView(c.view, c.schema, /* orReplace */ false);
6568
case "drop-view": return `DROP VIEW ${quoteQualifiedView(c.view, c.schema)};`;
6669
case "replace-view": return renderCreateView(c.view, c.schema, /* orReplace */ true);
@@ -88,6 +91,8 @@ function renderDown(c: Change): string {
8891
case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
8992
case "add-fk": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.fk.name)};`;
9093
case "drop-fk": return `-- WARNING: down migration cannot restore the original FK definition`;
94+
case "add-check": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.check.name)};`;
95+
case "drop-check": return `-- WARNING: down migration cannot restore the original CHECK definition`;
9196
case "create-view": return `DROP VIEW ${quoteQualifiedView(c.view.name, c.schema)};`;
9297
case "drop-view": return `-- WARNING: down migration cannot restore the original view definition`;
9398
case "replace-view": return `-- WARNING: down migration cannot restore the original view definition`;
@@ -99,6 +104,9 @@ function renderCreateTable(t: TableDescriptor): string {
99104
if (t.primaryKey.length > 0) {
100105
colDefs.push(` CONSTRAINT ${quote(t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`);
101106
}
107+
for (const chk of t.checks ?? []) {
108+
colDefs.push(` CONSTRAINT ${quote(chk.name)} CHECK (${chk.expression})`);
109+
}
102110
const create = `CREATE TABLE ${quoteQualified(t.name, t.schema)} (\n${colDefs.join(",\n")}\n);`;
103111
const comments = renderTableComments(t);
104112
return comments.length === 0 ? create : `${create}\n${comments.join("\n")}`;

server/typescript/packages/migrate-ts/src/emit/sqlite.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const STAGE_ORDER: Record<Change["kind"], number> = {
1313
"rename-column": 3, "rename-table": 3,
1414
"add-index": 4, "drop-index": 4,
1515
"add-fk": 5, "drop-fk": 5,
16+
"add-check": 5, "drop-check": 5,
1617
"drop-table": 6,
1718
"create-view": 99, "drop-view": 99, "replace-view": 99,
1819
};
@@ -110,6 +111,8 @@ function changeTable(c: Change): string | undefined {
110111
case "drop-index":
111112
case "add-fk":
112113
case "drop-fk":
114+
case "add-check":
115+
case "drop-check":
113116
return c.table;
114117
default:
115118
return undefined;
@@ -188,6 +191,11 @@ function renderUpNative(c: Change): string {
188191
case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.from)} TO ${quote(c.to)};`;
189192
case "add-index": return renderCreateIndex(c.table, c.index);
190193
case "drop-index": return `DROP INDEX ${quote(c.index)};`;
194+
case "add-check":
195+
case "drop-check":
196+
// SQLite cannot ADD/DROP a CHECK in place; it needs the recreate-and-copy
197+
// machinery (a follow-on). Throw rather than silently mis-emit.
198+
throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
191199
case "change-column-type":
192200
case "change-column-nullable":
193201
case "change-column-default":
@@ -212,6 +220,11 @@ function renderDownNative(c: Change): string {
212220
case "rename-column": return `ALTER TABLE ${quote(c.table)} RENAME COLUMN ${quote(c.to)} TO ${quote(c.from)};`;
213221
case "add-index": return `DROP INDEX ${quote(c.index.name)};`;
214222
case "drop-index": return `-- WARNING: down migration cannot restore the original index definition`;
223+
case "add-check":
224+
case "drop-check":
225+
// SQLite cannot ADD/DROP a CHECK in place; it needs the recreate-and-copy
226+
// machinery (a follow-on). Throw rather than silently mis-emit.
227+
throw new Error("CHECK migration not implemented for sqlite (recreate path pending)");
215228
case "change-column-type":
216229
case "change-column-nullable":
217230
case "change-column-default":

server/typescript/packages/migrate-ts/src/errors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ function changeLocator(c: Change): string {
6565
return `${c.table}.${c.fk.name}`;
6666
case "drop-fk":
6767
return `${c.table}.${c.fk}`;
68+
case "add-check":
69+
return `${c.table}.${c.check.name}`;
70+
case "drop-check":
71+
return `${c.table}.${c.check}`;
6872
case "create-view":
6973
case "replace-view":
7074
return c.view.name;
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// test/check/emit-postgres-check.test.ts
2+
import { describe, test, expect } from "bun:test";
3+
import type { TableDescriptor, Change } from "../../src/types.js";
4+
import { emit } from "../../src/emit/index.js";
5+
6+
const ALLOWED = { state: "ok" } as const;
7+
const CHK = { name: "orders_status_chk", expression: "status IN ('OPEN','CLOSED')" };
8+
const table: TableDescriptor = {
9+
name: "orders", columns: [{ name: "status", sqlType: { kind: "text" }, nullable: false }],
10+
indexes: [], foreignKeys: [], primaryKey: [], checks: [CHK],
11+
};
12+
13+
describe("emit postgres — checks", () => {
14+
test("create-table inlines the CHECK constraint", () => {
15+
const r = emit([{ kind: "create-table", table, status: ALLOWED } as unknown as Change], { dialect: "postgres" });
16+
expect(r.up).toContain(`CONSTRAINT "orders_status_chk" CHECK (status IN ('OPEN','CLOSED'))`);
17+
});
18+
test("add-check → ALTER TABLE ADD CONSTRAINT; down drops it", () => {
19+
const r = emit([{ kind: "add-check", table: "orders", check: CHK, status: ALLOWED } as unknown as Change], { dialect: "postgres" });
20+
expect(r.up).toContain(`ALTER TABLE "orders" ADD CONSTRAINT "orders_status_chk" CHECK (status IN ('OPEN','CLOSED'));`);
21+
expect(r.down).toContain(`ALTER TABLE "orders" DROP CONSTRAINT "orders_status_chk";`);
22+
});
23+
test("drop-check → ALTER TABLE DROP CONSTRAINT", () => {
24+
const r = emit([{ kind: "drop-check", table: "orders", check: "orders_status_chk", status: ALLOWED } as unknown as Change], { dialect: "postgres" });
25+
expect(r.up).toContain(`ALTER TABLE "orders" DROP CONSTRAINT "orders_status_chk";`);
26+
});
27+
});

0 commit comments

Comments
 (0)