import { describe, expect, it } from "vitest"; import { defineTable, id, varchar, text, json, boolean, timestamp, integer, bigint, encryptedText, refUser, refHost, prefixedTableName, tablePrefix, adoptLegacyTable, } from "@termix-ssh/plugin-sdk/db"; import { splitStatements } from "@termix-ssh/plugin-sdk/ddl"; import { adoptTableSql, createTableSql, dropTableSql, addColumnSql, keyedColumns, buildTable, } from "../../plugins/table-builder.js"; /** * The three-dialect DDL is what verify:dialect would catch against a real * server, and that needs a live Postgres and MySQL. These snapshots are what * covers it in a run that has neither. */ const definition = defineTable( "note", { id: id(), userId: refUser(), hostId: refHost(), title: varchar(200).notNull(), body: text(), meta: json(), secret: encryptedText(), pinned: boolean().default(false), position: integer().default(0), size: bigint(), createdAt: timestamp().notNull().defaultNow(), }, { uniques: [{ name: "idx_note_user_title", columns: ["userId", "title"] }], indexes: [{ name: "idx_note_host", columns: ["hostId"] }], }, ); describe("table naming", () => { it("prefixes with the plugin id, dashes becoming underscores", () => { expect(tablePrefix("network-topology")).toBe("p_network_topology_"); expect(prefixedTableName("network-topology", "graph")).toBe( "p_network_topology_graph", ); }); }); describe("defineTable validation", () => { it("refuses an index on an unbounded text column", () => { expect(() => defineTable( "bad", { id: id(), body: text() }, { indexes: [{ name: "idx_bad", columns: ["body"] }] }, ), ).toThrow(/MySQL cannot index an unbounded column/); }); it("refuses an index on a json column", () => { expect(() => defineTable( "bad", { id: id(), meta: json() }, { indexes: [{ name: "idx_bad", columns: ["meta"] }] }, ), ).toThrow(/MySQL cannot index an unbounded column/); }); it("refuses an index naming a column the table does not declare", () => { expect(() => defineTable( "bad", { id: id() }, { indexes: [{ name: "idx_bad", columns: ["nope"] }] }, ), ).toThrow(/which table "bad" does not declare/); }); it("refuses a table name that is not lower snake_case", () => { expect(() => defineTable("Notes", { id: id() })).toThrow( /lower snake_case/, ); }); it("refuses a table with no columns", () => { expect(() => defineTable("empty", {})).toThrow(/declares no columns/); }); it("treats uniques as indexes that happen to be unique", () => { const table = defineTable( "thing", { id: id(), name: varchar(50) }, { uniques: [{ name: "idx_thing_name", columns: ["name"] }] }, ); expect(table.indexes[0]).toMatchObject({ name: "idx_thing_name", unique: true, }); }); }); describe("keyedColumns", () => { it("counts primary keys, uniques, refs and indexed columns", () => { const keyed = keyedColumns(definition); expect(keyed.has("userId")).toBe(true); expect(keyed.has("hostId")).toBe(true); expect(keyed.has("title")).toBe(true); // Not keyed, so it stays TEXT and is not narrowed to varchar(255). expect(keyed.has("body")).toBe(false); expect(keyed.has("meta")).toBe(false); }); }); describe("createTableSql", () => { it("emits sqlite DDL", () => { expect(createTableSql("sqlite", "demo", definition).join("\n")) .toMatchInlineSnapshot(` "CREATE TABLE IF NOT EXISTS "p_demo_note" ( "id" integer PRIMARY KEY AUTOINCREMENT, "user_id" text NOT NULL, "host_id" integer NOT NULL, "title" text NOT NULL, "body" text, "meta" text, "secret" text, "pinned" integer DEFAULT 0, "position" integer DEFAULT 0, "size" integer, "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title"); CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");" `); }); it("emits postgres DDL with serial and native booleans", () => { expect(createTableSql("postgres", "demo", definition).join("\n")) .toMatchInlineSnapshot(` "CREATE TABLE IF NOT EXISTS "p_demo_note" ( "id" serial PRIMARY KEY, "user_id" varchar(255) NOT NULL, "host_id" integer NOT NULL, "title" varchar(200) NOT NULL, "body" text, "meta" text, "secret" text, "pinned" boolean DEFAULT false, "position" integer DEFAULT 0, "size" bigint, "created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE, FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE ); CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title"); CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");" `); }); it("emits mysql DDL with backticks and a parenthesised timestamp default", () => { expect(createTableSql("mysql", "demo", definition).join("\n")) .toMatchInlineSnapshot(` "CREATE TABLE IF NOT EXISTS \`p_demo_note\` ( \`id\` int AUTO_INCREMENT PRIMARY KEY, \`user_id\` varchar(255) NOT NULL, \`host_id\` int NOT NULL, \`title\` varchar(200) NOT NULL, \`body\` text, \`meta\` text, \`secret\` text, \`pinned\` boolean DEFAULT false, \`position\` int DEFAULT 0, \`size\` bigint, \`created_at\` text NOT NULL DEFAULT (CURRENT_TIMESTAMP), FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE, FOREIGN KEY (\`host_id\`) REFERENCES \`ssh_data\` (\`id\`) ON DELETE CASCADE ); CREATE UNIQUE INDEX \`idx_note_user_title\` ON \`p_demo_note\` (\`user_id\`, \`title\`); CREATE INDEX \`idx_note_host\` ON \`p_demo_note\` (\`host_id\`);" `); }); it("never leaves an indexed column as TEXT off sqlite", () => { // MySQL 8 rejects a TEXT column in a key specification. MariaDB accepts // it, which is why this was missed in core until a real MySQL saw it. for (const dialect of ["postgres", "mysql"] as const) { const sql = createTableSql(dialect, "demo", definition).join("\n"); const keyed = keyedColumns(definition); for (const property of keyed) { const column = property.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); const line = sql .split("\n") .find( (row) => row.includes(`${column}\``) || row.includes(`"${column}"`), ); expect(line, `${dialect}.${column}`).not.toMatch(/\s(text|json)[,\s]/); } } }); it("omits IF NOT EXISTS on a mysql index, which does not support it", () => { const mysql = createTableSql("mysql", "demo", definition); const indexes = mysql.filter((statement) => statement.includes("INDEX")); expect(indexes).not.toHaveLength(0); for (const statement of indexes) { expect(statement).not.toContain("IF NOT EXISTS"); } }); }); describe("addColumnSql", () => { it("emits an ALTER per dialect", () => { expect(addColumnSql("sqlite", "demo", definition, "body")).toBe( 'ALTER TABLE "p_demo_note" ADD COLUMN "body" text;', ); expect(addColumnSql("mysql", "demo", definition, "title")).toBe( "ALTER TABLE `p_demo_note` ADD COLUMN `title` varchar(200) NOT NULL;", ); }); }); describe("dropTableSql", () => { it("drops the prefixed table", () => { expect(dropTableSql("postgres", "demo", definition)).toBe( 'DROP TABLE IF EXISTS "p_demo_note";', ); }); }); describe("buildTable", () => { it("builds a queryable drizzle table carrying the prefixed name", async () => { const { getTableName, getTableColumns } = await import("drizzle-orm"); const table = buildTable("demo", definition) as never; expect(getTableName(table)).toBe("p_demo_note"); expect(Object.keys(getTableColumns(table))).toContain("userId"); }); }); describe("adoptTableSql", () => { const adopted = adoptLegacyTable( "fleets", defineTable( "fleets", { id: id(), name: text().notNull().default("x") }, { indexes: [{ name: "idx_fleets_name", columns: ["id"] }] }, ), ); it("creates the legacy table when missing, indexes it, then renames it", () => { const sqlite = adoptTableSql("sqlite", "fleets", adopted); expect(sqlite[0]).toMatch(/^CREATE TABLE IF NOT EXISTS "fleets" \(/); expect(sqlite[1]).toBe( 'CREATE INDEX IF NOT EXISTS "idx_fleets_name" ON "fleets" ("id");', ); expect(sqlite[2]).toBe('ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";'); expect(adoptTableSql("postgres", "fleets", adopted).at(-1)).toBe( 'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";', ); }); it("emits no indexes on MySQL, which has no IF NOT EXISTS for them", () => { const mysql = adoptTableSql("mysql", "fleets", adopted); expect(mysql).toHaveLength(2); expect(mysql[1]).toBe("RENAME TABLE `fleets` TO `p_fleets_fleets`;"); }); it("writes a TEXT default as an expression on MySQL", () => { expect(adoptTableSql("mysql", "fleets", adopted)[0]).toContain( "`name` text NOT NULL DEFAULT ('x')", ); expect(createTableSql("sqlite", "fleets", adopted)[0]).toContain( "\"name\" text NOT NULL DEFAULT 'x'", ); }); it("refuses a definition that adopts nothing, and a table no plugin may adopt", () => { expect(() => adoptTableSql("sqlite", "demo", definition)).toThrow( /does not adopt/, ); expect(() => adoptLegacyTable("users", definition)).toThrow( /not a legacy core table/, ); }); it("splits into statements the runner applies one at a time", () => { const joined = adoptTableSql("sqlite", "fleets", adopted).join("\n"); expect(splitStatements(joined)).toHaveLength(3); }); });