import type { Db, ExecResult } from "./driver.ts"; export type SeedRow = Record; 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 { 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 { 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 { 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 { 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 { 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; 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( accounts: readonly T[], options: { find(identifier: string): Promise; create(account: T): Promise; configure?(user: U, account: T): void | Promise; }, ): Promise { 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; }