184 lines
5.9 KiB
TypeScript
184 lines
5.9 KiB
TypeScript
import type { Db, ExecResult } from "./driver.ts";
|
|
|
|
export type SeedRow = Record<string, unknown>;
|
|
export type SeedDatabase = Db | (() => Db);
|
|
|
|
export interface AddSeedOptions {
|
|
conflict?: "error" | "ignore" | "replace";
|
|
}
|
|
|
|
export interface UpsertSeedOptions {
|
|
key: string | readonly string[];
|
|
update?: readonly string[];
|
|
}
|
|
|
|
function identifier(value: string, label: string): string {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
|
throw new TypeError(`${label} must be a safe SQL identifier`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function database(value: SeedDatabase): Db {
|
|
return typeof value === "function" ? value() : value;
|
|
}
|
|
|
|
/** Insert one or many object rows with parameterized values. */
|
|
export async function addSeedData(
|
|
source: SeedDatabase,
|
|
data: SeedRow | readonly SeedRow[],
|
|
table: string,
|
|
options: AddSeedOptions = {},
|
|
): Promise<ExecResult[]> {
|
|
const db = database(source);
|
|
const target = identifier(table, "seed table");
|
|
const rows = Array.isArray(data) ? data : [data];
|
|
const results: ExecResult[] = [];
|
|
const clause =
|
|
options.conflict === "ignore"
|
|
? " OR IGNORE"
|
|
: options.conflict === "replace"
|
|
? " OR REPLACE"
|
|
: "";
|
|
for (const row of rows) {
|
|
const entries = Object.entries(row);
|
|
if (!entries.length) throw new TypeError("seed row cannot be empty");
|
|
const columns = entries.map(([column]) => identifier(column, "seed column"));
|
|
results.push(
|
|
await db.exec(
|
|
`INSERT${clause} INTO ${target} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
|
|
entries.map(([, value]) => value),
|
|
),
|
|
);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** Delete matching seed rows. An empty match is refused unless `all` is explicit. */
|
|
export async function removeSeedData(
|
|
source: SeedDatabase,
|
|
match: SeedRow,
|
|
table: string,
|
|
options: { all?: boolean } = {},
|
|
): Promise<ExecResult> {
|
|
const db = database(source);
|
|
const target = identifier(table, "seed table");
|
|
const entries = Object.entries(match);
|
|
if (!entries.length && !options.all) {
|
|
throw new TypeError("removeSeedData requires match fields or { all: true }");
|
|
}
|
|
const where = entries.length
|
|
? ` WHERE ${entries.map(([column]) => `${identifier(column, "seed column")} = ?`).join(" AND ")}`
|
|
: "";
|
|
return db.exec(
|
|
`DELETE FROM ${target}${where}`,
|
|
entries.map(([, value]) => value),
|
|
);
|
|
}
|
|
|
|
/** Execute an application-owned parameterized seed statement. */
|
|
export function runSeedQuery(
|
|
source: SeedDatabase,
|
|
query: string,
|
|
data: readonly unknown[] = [],
|
|
): Promise<ExecResult> {
|
|
const db = database(source);
|
|
if (!query.trim()) throw new TypeError("seed query cannot be empty");
|
|
return db.exec(query, [...data]);
|
|
}
|
|
|
|
/** Portable object upsert for SQLite, PostgreSQL and MySQL. */
|
|
export async function upsertSeedData(
|
|
source: SeedDatabase,
|
|
data: SeedRow | readonly SeedRow[],
|
|
table: string,
|
|
options: UpsertSeedOptions,
|
|
): Promise<ExecResult[]> {
|
|
const db = database(source);
|
|
const target = identifier(table, "seed table");
|
|
const keys = (Array.isArray(options.key) ? options.key : [options.key]).map((key) =>
|
|
identifier(key, "seed key"),
|
|
);
|
|
if (!keys.length) throw new TypeError("upsertSeedData requires at least one key");
|
|
const results: ExecResult[] = [];
|
|
for (const row of Array.isArray(data) ? data : [data]) {
|
|
const entries = Object.entries(row);
|
|
if (!entries.length) throw new TypeError("seed row cannot be empty");
|
|
const columns = entries.map(([column]) => identifier(column, "seed column"));
|
|
for (const key of keys)
|
|
if (!columns.includes(key)) throw new TypeError(`seed row is missing key '${key}'`);
|
|
const updates = (options.update ?? columns.filter((column) => !keys.includes(column))).map(
|
|
(column) => identifier(column, "seed update column"),
|
|
);
|
|
const prefix = db.driver.dialect === "mysql" ? "INSERT INTO" : "INSERT INTO";
|
|
const suffix =
|
|
db.driver.dialect === "mysql"
|
|
? ` ON DUPLICATE KEY UPDATE ${updates.map((column) => `${column} = VALUES(${column})`).join(", ")}`
|
|
: ` ON CONFLICT (${keys.join(", ")}) DO ${
|
|
updates.length
|
|
? `UPDATE SET ${updates.map((column) => `${column} = excluded.${column}`).join(", ")}`
|
|
: "NOTHING"
|
|
}`;
|
|
results.push(
|
|
await db.exec(
|
|
`${prefix} ${target} (${columns.join(", ")}) VALUES (${columns.map(() => "?").join(", ")})${suffix}`,
|
|
entries.map(([, value]) => value),
|
|
),
|
|
);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
export async function seedIfMissing(
|
|
source: SeedDatabase,
|
|
table: string,
|
|
match: SeedRow,
|
|
data: SeedRow,
|
|
): Promise<boolean> {
|
|
const db = database(source);
|
|
const target = identifier(table, "seed table");
|
|
const where = Object.entries(match);
|
|
if (!where.length) throw new TypeError("seedIfMissing requires match fields");
|
|
const existing = await db.one(
|
|
`SELECT 1 AS found FROM ${target} WHERE ${where
|
|
.map(([column]) => `${identifier(column, "seed column")} = ?`)
|
|
.join(" AND ")} LIMIT 1`,
|
|
where.map(([, value]) => value),
|
|
);
|
|
if (existing) return false;
|
|
await addSeedData(db, data, target);
|
|
return true;
|
|
}
|
|
|
|
export type SeedStep = (db: Db) => void | Promise<void>;
|
|
|
|
export function defineSeed(...steps: readonly SeedStep[]): SeedStep {
|
|
return async (db) =>
|
|
db.tx(async (transaction) => {
|
|
for (const step of steps) await step(transaction);
|
|
});
|
|
}
|
|
|
|
export interface SeedUserAccount {
|
|
identifier: string;
|
|
password?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export async function seedUsers<T extends SeedUserAccount, U extends { id: string }>(
|
|
accounts: readonly T[],
|
|
options: {
|
|
find(identifier: string): Promise<U | null>;
|
|
create(account: T): Promise<U>;
|
|
configure?(user: U, account: T): void | Promise<void>;
|
|
},
|
|
): Promise<U[]> {
|
|
const users: U[] = [];
|
|
for (const account of accounts) {
|
|
const user = (await options.find(account.identifier)) ?? (await options.create(account));
|
|
await options.configure?.(user, account);
|
|
users.push(user);
|
|
}
|
|
return users;
|
|
}
|