first commit
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
v,
|
||||
table,
|
||||
createDb,
|
||||
createTableSql,
|
||||
parseMigration,
|
||||
migrate,
|
||||
status,
|
||||
rollback,
|
||||
parseQueries,
|
||||
generateQueriesFile,
|
||||
} from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
import { bunSql } from "../src/adapters/bunsql.ts";
|
||||
|
||||
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
|
||||
id: v.id(),
|
||||
email: v.string().unique(),
|
||||
name: v.string(),
|
||||
active: v.boolean().default(true),
|
||||
});
|
||||
|
||||
test("createTableSql renders dialect-specific DDL", () => {
|
||||
expect(createTableSql(users, "sqlite")).toContain("INTEGER PRIMARY KEY AUTOINCREMENT");
|
||||
const pg = createTableSql(users, "postgres");
|
||||
expect(pg).toContain("SERIAL PRIMARY KEY");
|
||||
expect(pg).toContain("BOOLEAN");
|
||||
});
|
||||
|
||||
test("model.parse coerces DB rows to typed values", () => {
|
||||
const row = users.parse({ id: "1", email: "a@b.com", name: "Ann", active: 1 });
|
||||
expect(row.id).toBe(1);
|
||||
expect(row.active).toBe(true);
|
||||
});
|
||||
|
||||
for (const [label, driver] of [
|
||||
["bun:sqlite", () => sqlite()],
|
||||
["Bun.sql/sqlite", () => bunSql("sqlite://:memory:", "sqlite")],
|
||||
] as const) {
|
||||
test(`CRUD + transactions [${label}]`, async () => {
|
||||
const db = createDb(driver());
|
||||
await db.createTable(users);
|
||||
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
||||
"a@b.com",
|
||||
"Ann",
|
||||
true,
|
||||
]);
|
||||
// rollback
|
||||
try {
|
||||
await db.tx(async (t) => {
|
||||
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["x@y.com", "X"]);
|
||||
throw new Error("boom");
|
||||
});
|
||||
} catch {
|
||||
/* expected */
|
||||
}
|
||||
// commit
|
||||
await db.tx(async (t) => {
|
||||
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["c@d.com", "Cy"]);
|
||||
});
|
||||
const rows = await db.all("SELECT * FROM users ORDER BY id", [], users);
|
||||
expect(rows.map((r) => r.name)).toEqual(["Ann", "Cy"]);
|
||||
expect(typeof rows[0]!.active).toBe("boolean");
|
||||
db.close();
|
||||
});
|
||||
}
|
||||
|
||||
test("migration runner: parse, migrate, status, rollback", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "wire-mig-"));
|
||||
writeFileSync(
|
||||
join(dir, "0001_init.sql"),
|
||||
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY, n TEXT);\n-- +down\nDROP TABLE t;",
|
||||
);
|
||||
const parsed = parseMigration(
|
||||
"0001_init",
|
||||
"-- +up\nCREATE TABLE t (id INTEGER);\n-- +down\nDROP TABLE t;",
|
||||
);
|
||||
expect(parsed.up).toContain("CREATE TABLE t");
|
||||
expect(parsed.down).toContain("DROP TABLE t");
|
||||
|
||||
const db = createDb(sqlite());
|
||||
expect(await migrate(db, dir)).toEqual(["0001_init"]);
|
||||
expect(await migrate(db, dir)).toEqual([]); // idempotent
|
||||
expect((await status(db, dir))[0]).toEqual({ name: "0001_init", applied: true });
|
||||
const tablesAfter = await db.all<{ name: string }>(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
|
||||
);
|
||||
expect(tablesAfter.length).toBe(1);
|
||||
expect(await rollback(db, dir)).toBe("0001_init");
|
||||
expect((await status(db, dir))[0]!.applied).toBe(false);
|
||||
db.close();
|
||||
});
|
||||
|
||||
test("query generator infers params and result types", () => {
|
||||
const q = parseQueries(
|
||||
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
|
||||
"-- name: CountActive :one\nSELECT COUNT(*) AS n FROM users WHERE active = :active;\n" +
|
||||
"-- name: Create :exec\nINSERT INTO users (email, name) VALUES (:email, :name);",
|
||||
);
|
||||
expect(q.map((x) => x.name)).toEqual(["GetByEmail", "CountActive", "Create"]);
|
||||
const code = generateQueriesFile(q, [{ varName: "users", model: users }], "sqlite");
|
||||
expect(code).toContain("GetByEmail(db: Db, args: { email: string })");
|
||||
expect(code).toContain(
|
||||
"CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null>",
|
||||
);
|
||||
expect(code).toContain(
|
||||
"Create(db: Db, args: { email: string; name: string }): Promise<ExecResult>",
|
||||
);
|
||||
});
|
||||
|
||||
test("paginate returns a page window with correct metadata", async () => {
|
||||
const { paginate } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await db.createTable(users);
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
|
||||
`u${i}@x.com`,
|
||||
`U${i}`,
|
||||
true,
|
||||
]);
|
||||
}
|
||||
const p2 = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
||||
{ page: 2, perPage: 10 },
|
||||
);
|
||||
expect(p2.total).toBe(25);
|
||||
expect(p2.totalPages).toBe(3);
|
||||
expect(p2.items.length).toBe(10);
|
||||
expect(p2.items[0]!.name).toBe("U11");
|
||||
expect(p2.hasNext).toBe(true);
|
||||
expect(p2.hasPrev).toBe(true);
|
||||
|
||||
const p3 = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id" },
|
||||
{ page: 3, perPage: 10 },
|
||||
);
|
||||
expect(p3.items.length).toBe(5);
|
||||
expect(p3.hasNext).toBe(false);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("loadRelated batches children onto parents (no N+1)", async () => {
|
||||
const { loadRelated } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
|
||||
await db.exec("CREATE TABLE posts (id INTEGER PRIMARY KEY, userId INTEGER, title TEXT)");
|
||||
await db.exec("INSERT INTO users (id, name) VALUES (1, 'Ann'), (2, 'Bob')");
|
||||
await db.exec(
|
||||
"INSERT INTO posts (id, userId, title) VALUES (1, 1, 'a'), (2, 1, 'b'), (3, 2, 'c')",
|
||||
);
|
||||
|
||||
const parents = await db.all<{ id: number; name: string }>("SELECT * FROM users ORDER BY id");
|
||||
const withPosts = await loadRelated(db, parents, {
|
||||
table: "posts",
|
||||
foreignKey: "userId",
|
||||
as: "posts",
|
||||
});
|
||||
expect((withPosts[0]!.posts as unknown[]).length).toBe(2);
|
||||
expect((withPosts[1]!.posts as unknown[]).length).toBe(1);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("loadRelated rejects unsafe identifiers", async () => {
|
||||
const { loadRelated } = await import("../src/index.ts");
|
||||
const db = createDb(sqlite());
|
||||
await expect(
|
||||
loadRelated(db, [{ id: 1 }], {
|
||||
table: "posts; DROP TABLE users",
|
||||
foreignKey: "userId",
|
||||
as: "x",
|
||||
}),
|
||||
).rejects.toThrow("Unsafe table name");
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test("sqliteSessionStore persists sessions (get/set/delete/gc)", async () => {
|
||||
const { sqliteSessionStore } = await import("../src/session-store.ts");
|
||||
const store = sqliteSessionStore(":memory:");
|
||||
expect(store.get("s1")).toBeUndefined();
|
||||
store.set("s1", { data: { user: 7 }, expiresAt: Date.now() + 10_000 });
|
||||
expect(store.get("s1")!.data).toEqual({ user: 7 });
|
||||
store.set("s1", { data: { user: 8 }, expiresAt: Date.now() + 10_000 }); // upsert
|
||||
expect(store.get("s1")!.data).toEqual({ user: 8 });
|
||||
store.set("old", { data: {}, expiresAt: Date.now() - 1 });
|
||||
store.gc!(Date.now());
|
||||
expect(store.get("old")).toBeUndefined();
|
||||
store.delete("s1");
|
||||
expect(store.get("s1")).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Live Postgres/MySQL integration tests. These are GATED on env vars so the
|
||||
* normal `bun test` run stays green without a database:
|
||||
*
|
||||
* WRNEXUS_PG_URL=postgres://… WRNEXUS_MYSQL_URL=mysql://… bun test packages/db
|
||||
*
|
||||
* The `bun run test:db:live` script spins up both via docker-compose, sets the
|
||||
* env vars, runs this file, and tears the containers down.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { v, table, createDb, createTableSql, paginate, type Dialect } from "../src/index.ts";
|
||||
import { bunSql } from "../src/adapters/bunsql.ts";
|
||||
|
||||
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
|
||||
id: v.id(),
|
||||
email: v.string().unique(),
|
||||
name: v.string(),
|
||||
active: v.boolean().default(true),
|
||||
});
|
||||
|
||||
const targets: { dialect: Dialect; url: string }[] = [];
|
||||
if (process.env.WRNEXUS_PG_URL)
|
||||
targets.push({ dialect: "postgres", url: process.env.WRNEXUS_PG_URL });
|
||||
if (process.env.WRNEXUS_MYSQL_URL)
|
||||
targets.push({ dialect: "mysql", url: process.env.WRNEXUS_MYSQL_URL });
|
||||
|
||||
const ph = (dialect: Dialect, i: number) => (dialect === "postgres" ? `$${i}` : "?");
|
||||
|
||||
if (targets.length === 0) {
|
||||
test.skip("live PG/MySQL (set WRNEXUS_PG_URL / WRNEXUS_MYSQL_URL to run)", () => {});
|
||||
} else {
|
||||
for (const { dialect, url } of targets) {
|
||||
test(`${dialect}: DDL + CRUD + transaction + pagination`, async () => {
|
||||
const db = createDb(bunSql(url, dialect));
|
||||
try {
|
||||
await db.exec("DROP TABLE IF EXISTS users");
|
||||
await db.exec(createTableSql(users, dialect));
|
||||
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
await db.exec(
|
||||
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
|
||||
[`u${i}@x.com`, `U${i}`, true],
|
||||
);
|
||||
}
|
||||
|
||||
const count = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
|
||||
expect(Number(count?.n)).toBe(5);
|
||||
|
||||
// Transaction rollback leaves the table unchanged.
|
||||
await db
|
||||
.tx(async (t) => {
|
||||
await t.exec(
|
||||
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
|
||||
["rollback@x.com", "R", true],
|
||||
);
|
||||
throw new Error("rollback");
|
||||
})
|
||||
.catch(() => {});
|
||||
const after = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
|
||||
expect(Number(after?.n)).toBe(5);
|
||||
|
||||
const page = await paginate(
|
||||
db,
|
||||
{ sql: "SELECT * FROM users ORDER BY id", model: users },
|
||||
{ page: 1, perPage: 2 },
|
||||
);
|
||||
expect(page.total).toBe(5);
|
||||
expect(page.totalPages).toBe(3);
|
||||
expect(page.items.length).toBe(2);
|
||||
|
||||
await db.exec("DROP TABLE IF EXISTS users");
|
||||
} finally {
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import {
|
||||
createDb,
|
||||
setDb,
|
||||
getDb,
|
||||
hasDb,
|
||||
registerDb,
|
||||
databaseNames,
|
||||
closeDatabases,
|
||||
} from "../src/index.ts";
|
||||
import { sqlite } from "../src/adapters/sqlite.ts";
|
||||
|
||||
test("multi-database registry: default + named connections", async () => {
|
||||
await closeDatabases(); // isolate from any prior state
|
||||
|
||||
const main = createDb(sqlite(":memory:"));
|
||||
const analytics = createDb(sqlite(":memory:"));
|
||||
|
||||
setDb(main); // default
|
||||
registerDb("analytics", analytics); // named
|
||||
|
||||
expect(getDb()).toBe(main);
|
||||
expect(getDb("analytics")).toBe(analytics);
|
||||
expect(hasDb()).toBe(true);
|
||||
expect(hasDb("analytics")).toBe(true);
|
||||
expect(hasDb("missing")).toBe(false);
|
||||
expect(databaseNames().sort()).toEqual(["analytics", "default"]);
|
||||
|
||||
// Each connection is independent — a table in one is not in the other.
|
||||
await main.exec("CREATE TABLE a (id INTEGER)");
|
||||
await analytics.exec("CREATE TABLE b (id INTEGER)");
|
||||
await getDb().exec("INSERT INTO a (id) VALUES (1)");
|
||||
await getDb("analytics").exec("INSERT INTO b (id) VALUES (2)");
|
||||
expect((await getDb().all("SELECT id FROM a")).length).toBe(1);
|
||||
expect((await getDb("analytics").all("SELECT id FROM b")).length).toBe(1);
|
||||
|
||||
await closeDatabases();
|
||||
expect(hasDb()).toBe(false);
|
||||
expect(databaseNames()).toEqual([]);
|
||||
});
|
||||
|
||||
test("getDb throws a helpful error for an unknown named database", async () => {
|
||||
await closeDatabases();
|
||||
setDb(createDb(sqlite(":memory:")));
|
||||
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
|
||||
await closeDatabases();
|
||||
});
|
||||
Reference in New Issue
Block a user