mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
defineTable,
|
|
id,
|
|
varchar,
|
|
text,
|
|
json,
|
|
boolean,
|
|
timestamp,
|
|
integer,
|
|
bigint,
|
|
encryptedText,
|
|
refUser,
|
|
refHost,
|
|
prefixedTableName,
|
|
tablePrefix,
|
|
adoptLegacyTable,
|
|
} from "@termix/plugin-sdk/db";
|
|
import { splitStatements } from "@termix/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);
|
|
});
|
|
});
|