/** * Phase 4c — DDL emit engine tests. * * Structure: * 1. `quoteIdent` — identifier quoting + rejection rules (security). * 2. `resolveColumnType` — dialect-matrix over 10 field types. * 3. `resolveDefault` + `nowExpr` — default literal + NOW() parity. * 4. `emitCreateTable` — column + constraint + index emission. * 5. `emitChange` — one section per `Change.kind`. * 6. `emitSchema` / `emitChanges` — multi-resource + empty-input cases. * 7. Injection-resistance regression (Agent G will cross-check). * * Each test that varies by dialect uses `describe.each([...providers])` * to guarantee parity without manual duplication. */ import { describe, expect, test } from "bun:test"; import { emitChange, emitChanges, emitCreateTable, emitDropTable, emitSchema, quoteIdent, } from "../emit"; import { nowExpr, resolveColumnType, resolveDefault } from "../type-map"; import type { Change, DdlFieldDef, DdlFieldType, DdlIndex, DdlResource, SqlProvider, } from "../types"; // --------------------------------------------------------------------- // Shared fixtures / helpers // --------------------------------------------------------------------- const PROVIDERS: readonly SqlProvider[] = ["postgres", "mysql", "sqlite"]; function field(partial: Partial & { name: string; type: DdlFieldType }): DdlFieldDef { return { nullable: false, primary: false, unique: false, indexed: false, ...partial, }; } function resource(name: string, fields: DdlFieldDef[], indexes: DdlIndex[] = []): DdlResource { return { name, fields, indexes }; } const USER_RESOURCE: DdlResource = resource("users", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "email", type: "email", unique: true }), field({ name: "name", type: "string", maxLength: 120 }), ]); // --------------------------------------------------------------------- // 1. quoteIdent // --------------------------------------------------------------------- describe("quoteIdent", () => { test("postgres wraps in double quotes", () => { expect(quoteIdent("users", "postgres")).toBe('"users"'); }); test("mysql wraps in backticks", () => { expect(quoteIdent("users", "mysql")).toBe("`users`"); }); test("sqlite wraps in double quotes", () => { expect(quoteIdent("users", "sqlite")).toBe('"users"'); }); test("rejects names containing a double quote (postgres)", () => { expect(() => quoteIdent('bad"name', "postgres")).toThrow(/unquotable character/); }); test("rejects names containing a double quote (sqlite)", () => { expect(() => quoteIdent('bad"name', "sqlite")).toThrow(/unquotable character/); }); test("rejects names containing a backtick (mysql)", () => { expect(() => quoteIdent("bad`name", "mysql")).toThrow(/unquotable character/); }); test("rejects empty string on every provider", () => { for (const p of PROVIDERS) { expect(() => quoteIdent("", p)).toThrow(/must not be empty/); } }); test("rejects identifiers longer than 63 chars", () => { const long = "x".repeat(70); for (const p of PROVIDERS) { expect(() => quoteIdent(long, p)).toThrow(/too long/); } }); test("accepts identifier exactly 63 chars", () => { const exact = "a".repeat(63); expect(quoteIdent(exact, "postgres")).toBe(`"${exact}"`); expect(quoteIdent(exact, "mysql")).toBe(`\`${exact}\``); }); test("rejects NUL byte", () => { expect(() => quoteIdent("bad\0name", "postgres")).toThrow(/NUL byte/); }); test("rejects non-string input", () => { // @ts-expect-error — deliberate type violation expect(() => quoteIdent(42, "postgres")).toThrow(/must be a string/); }); test("allows underscores and digits", () => { expect(quoteIdent("user_sessions_v2", "postgres")).toBe('"user_sessions_v2"'); }); }); // --------------------------------------------------------------------- // 2. resolveColumnType — dialect matrix // --------------------------------------------------------------------- describe("resolveColumnType", () => { test("string with maxLength → VARCHAR(N) on postgres", () => { expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 100 }), "postgres")) .toBe("VARCHAR(100)"); }); test("string without maxLength → TEXT on postgres", () => { expect(resolveColumnType(field({ name: "n", type: "string" }), "postgres")).toBe("TEXT"); }); test("string without maxLength → VARCHAR(255) on mysql", () => { expect(resolveColumnType(field({ name: "n", type: "string" }), "mysql")).toBe("VARCHAR(255)"); }); test("string with maxLength → VARCHAR(N) on mysql", () => { expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 40 }), "mysql")) .toBe("VARCHAR(40)"); }); test("string on sqlite is always TEXT (ignores maxLength)", () => { expect(resolveColumnType(field({ name: "n", type: "string", maxLength: 10 }), "sqlite")) .toBe("TEXT"); }); test("uuid → UUID/CHAR(36)/TEXT", () => { expect(resolveColumnType(field({ name: "id", type: "uuid" }), "postgres")).toBe("UUID"); expect(resolveColumnType(field({ name: "id", type: "uuid" }), "mysql")).toBe("CHAR(36)"); expect(resolveColumnType(field({ name: "id", type: "uuid" }), "sqlite")).toBe("TEXT"); }); test("boolean → BOOLEAN / TINYINT(1) / INTEGER", () => { expect(resolveColumnType(field({ name: "b", type: "boolean" }), "postgres")).toBe("BOOLEAN"); expect(resolveColumnType(field({ name: "b", type: "boolean" }), "mysql")).toBe("TINYINT(1)"); expect(resolveColumnType(field({ name: "b", type: "boolean" }), "sqlite")).toBe("INTEGER"); }); test("number → DOUBLE PRECISION / DOUBLE / REAL", () => { expect(resolveColumnType(field({ name: "n", type: "number" }), "postgres")).toBe("DOUBLE PRECISION"); expect(resolveColumnType(field({ name: "n", type: "number" }), "mysql")).toBe("DOUBLE"); expect(resolveColumnType(field({ name: "n", type: "number" }), "sqlite")).toBe("REAL"); }); test("date → TIMESTAMPTZ / DATETIME(6) / TEXT", () => { expect(resolveColumnType(field({ name: "d", type: "date" }), "postgres")).toBe("TIMESTAMPTZ"); expect(resolveColumnType(field({ name: "d", type: "date" }), "mysql")).toBe("DATETIME(6)"); expect(resolveColumnType(field({ name: "d", type: "date" }), "sqlite")).toBe("TEXT"); }); test("json → JSONB / JSON / TEXT", () => { expect(resolveColumnType(field({ name: "j", type: "json" }), "postgres")).toBe("JSONB"); expect(resolveColumnType(field({ name: "j", type: "json" }), "mysql")).toBe("JSON"); expect(resolveColumnType(field({ name: "j", type: "json" }), "sqlite")).toBe("TEXT"); }); test("array + object persist as JSON on postgres/mysql, TEXT on sqlite", () => { for (const t of ["array", "object"] as const) { expect(resolveColumnType(field({ name: "x", type: t }), "postgres")).toBe("JSONB"); expect(resolveColumnType(field({ name: "x", type: t }), "mysql")).toBe("JSON"); expect(resolveColumnType(field({ name: "x", type: t }), "sqlite")).toBe("TEXT"); } }); test("email → VARCHAR(320) / VARCHAR(320) / TEXT", () => { expect(resolveColumnType(field({ name: "e", type: "email" }), "postgres")).toBe("VARCHAR(320)"); expect(resolveColumnType(field({ name: "e", type: "email" }), "mysql")).toBe("VARCHAR(320)"); expect(resolveColumnType(field({ name: "e", type: "email" }), "sqlite")).toBe("TEXT"); }); test("url → VARCHAR(2048) / VARCHAR(2048) / TEXT", () => { expect(resolveColumnType(field({ name: "u", type: "url" }), "postgres")).toBe("VARCHAR(2048)"); expect(resolveColumnType(field({ name: "u", type: "url" }), "mysql")).toBe("VARCHAR(2048)"); expect(resolveColumnType(field({ name: "u", type: "url" }), "sqlite")).toBe("TEXT"); }); }); // --------------------------------------------------------------------- // 3. resolveDefault + nowExpr // --------------------------------------------------------------------- describe("resolveDefault", () => { test("kind:now returns NOW() on postgres", () => { expect(resolveDefault({ kind: "now" }, "postgres")).toBe("NOW()"); }); test("kind:now returns NOW() on mysql", () => { expect(resolveDefault({ kind: "now" }, "mysql")).toBe("NOW()"); }); test("kind:now returns CURRENT_TIMESTAMP on sqlite", () => { expect(resolveDefault({ kind: "now" }, "sqlite")).toBe("CURRENT_TIMESTAMP"); }); test("kind:null returns NULL everywhere", () => { for (const p of PROVIDERS) { expect(resolveDefault({ kind: "null" }, p)).toBe("NULL"); } }); test("string literal is ANSI-escaped (single quote doubling)", () => { expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "postgres")) .toBe("'O''Brien'"); expect(resolveDefault({ kind: "literal", value: "O'Brien" }, "mysql")) .toBe("'O''Brien'"); }); test("number literal is emitted verbatim", () => { expect(resolveDefault({ kind: "literal", value: 42 }, "mysql")).toBe("42"); expect(resolveDefault({ kind: "literal", value: -1.5 }, "postgres")).toBe("-1.5"); }); test("non-finite number throws", () => { expect(() => resolveDefault({ kind: "literal", value: Number.NaN }, "postgres")) .toThrow(/finite number/); expect(() => resolveDefault({ kind: "literal", value: Number.POSITIVE_INFINITY }, "postgres")) .toThrow(/finite number/); }); test("boolean literal → TRUE/FALSE on postgres, mysql; 1/0 on sqlite", () => { expect(resolveDefault({ kind: "literal", value: true }, "postgres")).toBe("TRUE"); expect(resolveDefault({ kind: "literal", value: false }, "postgres")).toBe("FALSE"); expect(resolveDefault({ kind: "literal", value: true }, "mysql")).toBe("TRUE"); expect(resolveDefault({ kind: "literal", value: true }, "sqlite")).toBe("1"); expect(resolveDefault({ kind: "literal", value: false }, "sqlite")).toBe("0"); }); test("sql expression is passed through verbatim", () => { expect(resolveDefault({ kind: "sql", expr: "gen_random_uuid()" }, "postgres")) .toBe("gen_random_uuid()"); }); }); describe("nowExpr", () => { test.each([["postgres", "NOW()"], ["mysql", "NOW()"], ["sqlite", "CURRENT_TIMESTAMP"]] as const)( "%s → %s", (p, expected) => { expect(nowExpr(p)).toBe(expected); }, ); }); // --------------------------------------------------------------------- // 4. emitCreateTable // --------------------------------------------------------------------- describe("emitCreateTable", () => { test("emits 3-field table on postgres", () => { const sql = emitCreateTable(USER_RESOURCE, "postgres"); expect(sql).toBe( [ 'CREATE TABLE "users" (', ' "id" UUID PRIMARY KEY,', ' "email" VARCHAR(320) NOT NULL UNIQUE,', ' "name" VARCHAR(120) NOT NULL', ");", ].join("\n"), ); }); test("emits 3-field table on mysql", () => { const sql = emitCreateTable(USER_RESOURCE, "mysql"); expect(sql).toBe( [ "CREATE TABLE `users` (", " `id` CHAR(36) PRIMARY KEY,", " `email` VARCHAR(320) NOT NULL UNIQUE,", " `name` VARCHAR(120) NOT NULL", ");", ].join("\n"), ); }); test("emits 3-field table on sqlite", () => { const sql = emitCreateTable(USER_RESOURCE, "sqlite"); expect(sql).toBe( [ 'CREATE TABLE "users" (', ' "id" TEXT PRIMARY KEY,', ' "email" TEXT NOT NULL UNIQUE,', ' "name" TEXT NOT NULL', ");", ].join("\n"), ); }); test("PRIMARY KEY column does not emit NOT NULL (implied)", () => { const sql = emitCreateTable(USER_RESOURCE, "postgres"); // The id column should be `"id" UUID PRIMARY KEY` — no NOT NULL. expect(sql).toContain('"id" UUID PRIMARY KEY,'); expect(sql).not.toContain('"id" UUID PRIMARY KEY NOT NULL'); }); test("inline UNIQUE emitted for non-primary field", () => { const sql = emitCreateTable(USER_RESOURCE, "postgres"); expect(sql).toContain('"email" VARCHAR(320) NOT NULL UNIQUE'); }); test("emits DEFAULT clause per dialect for a date field", () => { const r = resource("events", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "created_at", type: "date", default: { kind: "now" } }), ]); expect(emitCreateTable(r, "postgres")).toContain( '"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()', ); expect(emitCreateTable(r, "mysql")).toContain( "`created_at` DATETIME(6) NOT NULL DEFAULT NOW()", ); expect(emitCreateTable(r, "sqlite")).toContain( '"created_at" TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP', ); }); test("emits CREATE INDEX for each indexed:true field (auto-named)", () => { const r = resource("posts", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "author_id", type: "uuid", indexed: true }), field({ name: "published_at", type: "date", indexed: true }), ]); const sql = emitCreateTable(r, "postgres"); expect(sql).toContain('CREATE INDEX "idx_posts_author_id" ON "posts" ("author_id");'); expect(sql).toContain('CREATE INDEX "idx_posts_published_at" ON "posts" ("published_at");'); }); test("does NOT auto-index unique / primary columns (implicit)", () => { const r = resource("foo", [ field({ name: "id", type: "uuid", primary: true, indexed: true }), field({ name: "slug", type: "string", unique: true, indexed: true }), ]); const sql = emitCreateTable(r, "postgres"); expect(sql).not.toContain("idx_foo_id"); expect(sql).not.toContain("idx_foo_slug"); }); test("emits CREATE UNIQUE INDEX for multi-column unique DdlIndex", () => { const r = resource("memberships", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "user_id", type: "uuid" }), field({ name: "org_id", type: "uuid" }), ], [{ name: "uniq_membership_user_org", fields: ["user_id", "org_id"], unique: true }]); const sql = emitCreateTable(r, "postgres"); expect(sql).toContain( 'CREATE UNIQUE INDEX "uniq_membership_user_org" ON "memberships" ("user_id", "org_id");', ); }); test("non-unique multi-column index emits plain CREATE INDEX", () => { const r = resource("logs", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "ts", type: "date" }), field({ name: "level", type: "string" }), ], [{ name: "idx_logs_ts_level", fields: ["ts", "level"], unique: false }]); expect(emitCreateTable(r, "postgres")).toContain( 'CREATE INDEX "idx_logs_ts_level" ON "logs" ("ts", "level");', ); }); test("validates that DdlIndex references existing fields", () => { const r = resource("x", [ field({ name: "id", type: "uuid", primary: true }), ], [{ name: "bad_idx", fields: ["missing"], unique: false }]); expect(() => emitCreateTable(r, "postgres")).toThrow(/references unknown field/); }); test("throws for empty field list", () => { const r = resource("empty", []); expect(() => emitCreateTable(r, "postgres")).toThrow(/no fields/); }); }); describe("emitDropTable", () => { test.each([...PROVIDERS] as SqlProvider[])("%s emits DROP TABLE IF EXISTS", (p) => { const sql = emitDropTable("widgets", p); expect(sql).toContain("DROP TABLE IF EXISTS"); expect(sql.endsWith(";")).toBe(true); if (p === "mysql") expect(sql).toContain("`widgets`"); else expect(sql).toContain('"widgets"'); }); }); // --------------------------------------------------------------------- // 5. emitChange — one section per Change.kind // --------------------------------------------------------------------- describe("emitChange — add-column", () => { const change: Change = { kind: "add-column", resourceName: "users", field: field({ name: "age", type: "number", nullable: true }), }; test("postgres", () => { expect(emitChange(change, "postgres")) .toBe('ALTER TABLE "users" ADD COLUMN "age" DOUBLE PRECISION;'); }); test("mysql", () => { expect(emitChange(change, "mysql")) .toBe("ALTER TABLE `users` ADD COLUMN `age` DOUBLE;"); }); test("sqlite", () => { expect(emitChange(change, "sqlite")) .toBe('ALTER TABLE "users" ADD COLUMN "age" REAL;'); }); test("sqlite refuses required add-column without a non-NULL constant default (#294)", () => { const required: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "created_at", type: "date" }), }; expect(() => emitChange(required, "sqlite")).toThrow( /SQLite cannot add required column "created_at"/, ); }); test("sqlite allows required add-column when a scalar literal default is present", () => { const requiredWithDefault: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "created_at", type: "date", default: { kind: "literal", value: "1970-01-01T00:00:00.000Z" }, }), }; expect(emitChange(requiredWithDefault, "sqlite")).toBe( 'ALTER TABLE "votes" ADD COLUMN "created_at" TEXT NOT NULL DEFAULT \'1970-01-01T00:00:00.000Z\';', ); }); test("sqlite refuses default: now on add-column because SQLite requires a constant default", () => { const nowDefault: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "created_at", type: "date", nullable: true, default: { kind: "now" }, }), }; expect(() => emitChange(nowDefault, "sqlite")).toThrow( /CURRENT_TIMESTAMP, which SQLite rejects/, ); }); test("sqlite refuses UNIQUE add-column because SQLite cannot add the constraint in place", () => { const unique: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "slug", type: "string", nullable: true, unique: true }), }; expect(() => emitChange(unique, "sqlite")).toThrow(/cannot add UNIQUE column/); }); test("sqlite refuses PRIMARY KEY add-column because SQLite cannot add the constraint in place", () => { const primary: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "other_id", type: "uuid", primary: true }), }; expect(() => emitChange(primary, "sqlite")).toThrow(/cannot add PRIMARY KEY column/); }); test("sqlite refuses raw function defaults on add-column", () => { const rawFunction: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "created_at", type: "date", nullable: true, default: { kind: "sql", expr: "datetime('now')" }, }), }; expect(() => emitChange(rawFunction, "sqlite")).toThrow( /non-constant function defaults/, ); }); test("sqlite refuses raw expression defaults on add-column", () => { const rawExpression: Change = { kind: "add-column", resourceName: "votes", field: field({ name: "score", type: "number", nullable: true, default: { kind: "sql", expr: "(1+2)" }, }), }; expect(() => emitChange(rawExpression, "sqlite")).toThrow( /only accepts literal constant defaults/, ); }); test("indexed:true field emits ADD COLUMN plus auto CREATE INDEX", () => { const indexed: Change = { kind: "add-column", resourceName: "users", field: field({ name: "role", type: "string", indexed: true }), }; expect(emitChange(indexed, "postgres")).toBe( [ 'ALTER TABLE "users" ADD COLUMN "role" TEXT NOT NULL;', 'CREATE INDEX "idx_users_role" ON "users" ("role");', ].join("\n"), ); }); test("indexed:true unique or primary add-column does not create redundant index", () => { const unique: Change = { kind: "add-column", resourceName: "users", field: field({ name: "slug", type: "string", indexed: true, unique: true }), }; expect(emitChange(unique, "postgres")).not.toContain("idx_users_slug"); }); }); describe("emitChange — drop-column", () => { const change: Change = { kind: "drop-column", resourceName: "users", fieldName: "age", }; test("postgres", () => { expect(emitChange(change, "postgres")) .toBe('ALTER TABLE "users" DROP COLUMN "age";'); }); test("mysql", () => { expect(emitChange(change, "mysql")) .toBe("ALTER TABLE `users` DROP COLUMN `age`;"); }); test("sqlite (assumes SQLite >= 3.35)", () => { // Documented assumption inline in emit.ts — Bun ships a modern SQLite. expect(emitChange(change, "sqlite")) .toBe('ALTER TABLE "users" DROP COLUMN "age";'); }); }); describe("emitChange — alter-column-type stub", () => { const change: Change = { kind: "alter-column-type", resourceName: "users", fieldName: "age", fromType: "number", toType: "string", stub: true, }; test("emits comment block with required TODO text", () => { for (const p of PROVIDERS) { const sql = emitChange(change, p); expect(sql).toContain("-- TODO: Mandu does not auto-generate ALTER COLUMN TYPE in v1."); expect(sql).toContain("mandu db apply"); expect(sql).toContain("Column type change detected: users.age"); expect(sql).toContain("from: number"); expect(sql).toContain("to: string"); expect(sql).toContain("mandu_manual_migration_required"); } }); test("includes a deliberate failing manual-migration sentinel", () => { const sql = emitChange(change, "postgres"); expect(sql).toContain("SELECT mandu_manual_migration_required"); expect(sql).not.toContain("SELECT 1;"); }); }); describe("emitChange — alter-column-nullable", () => { test("postgres SET NOT NULL / DROP NOT NULL", () => { expect( emitChange( { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false }, "postgres", ), ).toBe('ALTER TABLE "users" ALTER COLUMN "email" SET NOT NULL;'); expect( emitChange( { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true }, "postgres", ), ).toBe('ALTER TABLE "users" ALTER COLUMN "email" DROP NOT NULL;'); }); test("sqlite emits a stub (cannot toggle NOT NULL in place)", () => { const sql = emitChange( { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: true }, "sqlite", ); expect(sql).toContain("TODO: SQLite cannot toggle NOT NULL in place"); expect(sql).toContain("mandu_manual_migration_required"); }); test("mysql emits a stub (MODIFY COLUMN needs full type)", () => { const sql = emitChange( { kind: "alter-column-nullable", resourceName: "users", fieldName: "email", nullable: false }, "mysql", ); expect(sql).toContain("MySQL MODIFY COLUMN requires"); expect(sql).toContain("mandu_manual_migration_required"); }); }); describe("emitChange — alter-column-default", () => { test("postgres SET DEFAULT", () => { const sql = emitChange( { kind: "alter-column-default", resourceName: "users", fieldName: "status", default: { kind: "literal", value: "active" }, }, "postgres", ); expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" SET DEFAULT \'active\';'); }); test("postgres DROP DEFAULT when default is undefined", () => { const sql = emitChange( { kind: "alter-column-default", resourceName: "users", fieldName: "status" }, "postgres", ); expect(sql).toBe('ALTER TABLE "users" ALTER COLUMN "status" DROP DEFAULT;'); }); test("mysql supports SET/DROP DEFAULT directly", () => { expect( emitChange( { kind: "alter-column-default", resourceName: "u", fieldName: "f", default: { kind: "literal", value: 1 }, }, "mysql", ), ).toBe("ALTER TABLE `u` ALTER COLUMN `f` SET DEFAULT 1;"); }); test("sqlite emits a failing manual stub for default changes", () => { const sql = emitChange( { kind: "alter-column-default", resourceName: "users", fieldName: "status", default: { kind: "literal", value: "active" }, }, "sqlite", ); expect(sql).toContain("TODO: SQLite cannot ALTER DEFAULT in place"); expect(sql).toContain("mandu_manual_migration_required"); expect(sql).not.toContain("SELECT 1;"); }); }); describe("emitChange — indexes", () => { test("add-index single column, non-unique", () => { for (const p of PROVIDERS) { const sql = emitChange( { kind: "add-index", resourceName: "users", index: { name: "idx_users_email", fields: ["email"], unique: false }, }, p, ); expect(sql).toContain("CREATE INDEX"); expect(sql).not.toContain("UNIQUE"); } }); test("add-index multi-column, unique", () => { const sql = emitChange( { kind: "add-index", resourceName: "memberships", index: { name: "uniq_user_org", fields: ["user_id", "org_id"], unique: true }, }, "postgres", ); expect(sql).toBe('CREATE UNIQUE INDEX "uniq_user_org" ON "memberships" ("user_id", "org_id");'); }); test("drop-index — postgres + sqlite standalone, mysql with ON table", () => { expect( emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "postgres"), ).toBe('DROP INDEX "idx_users_email";'); expect( emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "sqlite"), ).toBe('DROP INDEX "idx_users_email";'); expect( emitChange({ kind: "drop-index", resourceName: "users", indexName: "idx_users_email" }, "mysql"), ).toBe("DROP INDEX `idx_users_email` ON `users`;"); }); test("add-index with no fields throws", () => { expect(() => emitChange( { kind: "add-index", resourceName: "users", index: { name: "x", fields: [], unique: false }, }, "postgres", ), ).toThrow(/no fields/); }); }); describe("emitChange — rename", () => { test("rename-table (user-confirmed) per dialect", () => { for (const p of PROVIDERS) { const sql = emitChange( { kind: "rename-table", oldName: "old_users", newName: "users", origin: "user-confirmed" }, p, ); expect(sql).toContain("RENAME TO"); if (p === "mysql") { expect(sql).toBe("ALTER TABLE `old_users` RENAME TO `users`;"); } else { expect(sql).toBe(`ALTER TABLE "old_users" RENAME TO "users";`); } } }); test("rename-column (user-confirmed) per dialect", () => { expect( emitChange( { kind: "rename-column", resourceName: "users", oldName: "e_mail", newName: "email", origin: "user-confirmed", }, "postgres", ), ).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";'); expect( emitChange( { kind: "rename-column", resourceName: "users", oldName: "e_mail", newName: "email", origin: "user-confirmed", }, "mysql", ), ).toBe("ALTER TABLE `users` RENAME COLUMN `e_mail` TO `email`;"); expect( emitChange( { kind: "rename-column", resourceName: "users", oldName: "e_mail", newName: "email", origin: "user-confirmed", }, "sqlite", ), ).toBe('ALTER TABLE "users" RENAME COLUMN "e_mail" TO "email";'); }); }); describe("emitChange — create-table / drop-table dispatch", () => { test("create-table dispatch emits full CREATE TABLE", () => { const sql = emitChange({ kind: "create-table", resource: USER_RESOURCE }, "postgres"); expect(sql).toContain('CREATE TABLE "users"'); }); test("drop-table dispatch emits DROP TABLE IF EXISTS", () => { const sql = emitChange({ kind: "drop-table", resourceName: "users" }, "mysql"); expect(sql).toBe("DROP TABLE IF EXISTS `users`;"); }); test("unknown kind throws", () => { // @ts-expect-error — deliberate invalid input expect(() => emitChange({ kind: "unsupported" }, "postgres")).toThrow(/unknown Change\.kind/); }); }); // --------------------------------------------------------------------- // 6. emitSchema / emitChanges — multi-statement composition // --------------------------------------------------------------------- describe("emitSchema", () => { test("emits CREATE TABLE for each resource in order", () => { const rs = [ resource("a", [field({ name: "id", type: "uuid", primary: true })]), resource("b", [field({ name: "id", type: "uuid", primary: true })]), resource("c", [field({ name: "id", type: "uuid", primary: true })]), ]; const sql = emitSchema(rs, "postgres"); const idxA = sql.indexOf('CREATE TABLE "a"'); const idxB = sql.indexOf('CREATE TABLE "b"'); const idxC = sql.indexOf('CREATE TABLE "c"'); expect(idxA).toBeGreaterThanOrEqual(0); expect(idxB).toBeGreaterThan(idxA); expect(idxC).toBeGreaterThan(idxB); }); test("empty array → empty string", () => { expect(emitSchema([], "postgres")).toBe(""); }); test("separates resources with a blank line", () => { const rs = [ resource("a", [field({ name: "id", type: "uuid", primary: true })]), resource("b", [field({ name: "id", type: "uuid", primary: true })]), ]; const sql = emitSchema(rs, "sqlite"); expect(sql).toMatch(/\);\n\nCREATE TABLE "b"/); }); }); describe("emitChanges", () => { test("empty array returns empty string (not whitespace)", () => { expect(emitChanges([], "postgres")).toBe(""); }); test("joins change outputs with newlines", () => { const changes: Change[] = [ { kind: "drop-table", resourceName: "x" }, { kind: "drop-table", resourceName: "y" }, ]; expect(emitChanges(changes, "postgres")).toBe( [ 'DROP TABLE IF EXISTS "x";', 'DROP TABLE IF EXISTS "y";', ].join("\n"), ); }); test("preserves order across heterogeneous change kinds", () => { const changes: Change[] = [ { kind: "create-table", resource: resource("new_table", [field({ name: "id", type: "uuid", primary: true })]), }, { kind: "add-column", resourceName: "users", field: field({ name: "age", type: "number", nullable: true }), }, ]; const sql = emitChanges(changes, "postgres"); expect(sql.indexOf("CREATE TABLE")).toBeLessThan(sql.indexOf("ADD COLUMN")); }); }); // --------------------------------------------------------------------- // 7. Injection-resistance / security regression // --------------------------------------------------------------------- describe("security — SQL injection resistance", () => { test("malicious resource name with closing quote + DROP is rejected", () => { const malicious: DdlResource = { name: 'x"; DROP TABLE users; --', fields: [field({ name: "id", type: "uuid", primary: true })], indexes: [], }; expect(() => emitCreateTable(malicious, "postgres")).toThrow(/unquotable character/); }); test("malicious field name with backtick is rejected on mysql", () => { const bad: DdlResource = { name: "users", fields: [field({ name: "id`; DROP;", type: "uuid", primary: true })], indexes: [], }; expect(() => emitCreateTable(bad, "mysql")).toThrow(/unquotable character/); }); test("malicious index name is rejected", () => { expect(() => emitChange( { kind: "add-index", resourceName: "users", index: { name: 'bad"idx', fields: ["id"], unique: false }, }, "postgres", ), ).toThrow(/unquotable character/); }); test("string literal DEFAULT with quote is escaped, not rejected", () => { // Literals are data, not identifiers — they escape, not throw. const r = resource("t", [ field({ name: "id", type: "uuid", primary: true }), field({ name: "note", type: "string", default: { kind: "literal", value: "'; DROP TABLE; --" }, }), ]); const sql = emitCreateTable(r, "postgres"); // Single quotes in the value are escaped to ''. The DROP is inside // a balanced pair of single quotes, i.e. data not SQL. expect(sql).toContain("DEFAULT '''; DROP TABLE; --'"); // The leading quote in the user input was escaped (''') — the token // that follows is inside the string literal, not executable SQL. const defaultMatch = sql.match(/DEFAULT ('(?:''|[^'])*')/); expect(defaultMatch).not.toBeNull(); expect(defaultMatch?.[1]).toBe("'''; DROP TABLE; --'"); }); });