130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
/**
|
|
* Database models — the single source of truth for a table's shape.
|
|
*
|
|
* A model defined with `table()` + the `v` column builder drives (1) DDL for
|
|
* migrations, (2) coercion/validation of DB rows into typed objects
|
|
* (`model.parse`), and later (3) the types the sqlc-style query generator emits.
|
|
* Column types are dialect-neutral; each adapter maps them to its own SQL types.
|
|
*/
|
|
|
|
export type BaseType = "id" | "text" | "int" | "real" | "bool" | "timestamp" | "json";
|
|
|
|
export interface ColumnDef {
|
|
type: BaseType;
|
|
/** NOT NULL unless `.optional()` was called. Ids are implicitly not-null. */
|
|
notNull: boolean;
|
|
primaryKey: boolean;
|
|
autoIncrement: boolean;
|
|
unique: boolean;
|
|
/** Literal default, or the sentinel "now" for a current-timestamp default. */
|
|
default?: unknown;
|
|
references?: { table: string; column: string };
|
|
}
|
|
|
|
/** A fluent column definition. Chain modifiers, then hand it to `table()`. */
|
|
export class Column {
|
|
readonly def: ColumnDef;
|
|
constructor(type: BaseType, overrides: Partial<ColumnDef> = {}) {
|
|
this.def = {
|
|
type,
|
|
notNull: true,
|
|
primaryKey: false,
|
|
autoIncrement: false,
|
|
unique: false,
|
|
...overrides,
|
|
};
|
|
}
|
|
optional(): this {
|
|
this.def.notNull = false;
|
|
return this;
|
|
}
|
|
unique(): this {
|
|
this.def.unique = true;
|
|
return this;
|
|
}
|
|
default(value: unknown): this {
|
|
this.def.default = value;
|
|
return this;
|
|
}
|
|
primaryKey(): this {
|
|
this.def.primaryKey = true;
|
|
return this;
|
|
}
|
|
references(table: string, column = "id"): this {
|
|
this.def.references = { table, column };
|
|
return this;
|
|
}
|
|
/** Coerce a raw DB value into its JS type (used by `model.parse`). */
|
|
coerce(raw: unknown): unknown {
|
|
if (raw === null || raw === undefined) return this.def.notNull ? raw : null;
|
|
switch (this.def.type) {
|
|
case "id":
|
|
case "int":
|
|
return typeof raw === "bigint" ? Number(raw) : Number(raw);
|
|
case "real":
|
|
return Number(raw);
|
|
case "bool":
|
|
return raw === true || raw === 1 || raw === "1" || raw === "true";
|
|
case "timestamp":
|
|
return raw instanceof Date ? raw : new Date(raw as string | number);
|
|
case "json":
|
|
return typeof raw === "string" ? safeJson(raw) : raw;
|
|
default:
|
|
return String(raw);
|
|
}
|
|
}
|
|
}
|
|
|
|
function safeJson(value: string): unknown {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
}
|
|
|
|
/** Column builders. `v.id()` is an auto-increment primary key. */
|
|
export const v = {
|
|
id: () => new Column("id", { primaryKey: true, autoIncrement: true }),
|
|
text: () => new Column("text"),
|
|
string: () => new Column("text"),
|
|
int: () => new Column("int"),
|
|
number: () => new Column("real"),
|
|
real: () => new Column("real"),
|
|
bool: () => new Column("bool"),
|
|
boolean: () => new Column("bool"),
|
|
timestamp: () => new Column("timestamp"),
|
|
json: () => new Column("json"),
|
|
};
|
|
|
|
export type Columns = Record<string, Column>;
|
|
|
|
export interface Model<T = Record<string, unknown>> {
|
|
name: string;
|
|
columns: Columns;
|
|
/** Coerce a raw DB row into a typed object (unknown columns pass through). */
|
|
parse(row: Record<string, unknown>): T;
|
|
/** Column definitions, for migrations and the query generator. */
|
|
describe(): Record<string, ColumnDef>;
|
|
}
|
|
|
|
/** Define a table model from a name and a map of columns. */
|
|
export function table<T = Record<string, unknown>>(name: string, columns: Columns): Model<T> {
|
|
return {
|
|
name,
|
|
columns,
|
|
parse(row) {
|
|
const out: Record<string, unknown> = { ...row };
|
|
for (const [key, column] of Object.entries(columns)) {
|
|
if (key in row) out[key] = column.coerce(row[key]);
|
|
}
|
|
return out as T;
|
|
},
|
|
describe() {
|
|
const out: Record<string, ColumnDef> = {};
|
|
for (const [key, column] of Object.entries(columns)) out[key] = column.def;
|
|
return out;
|
|
},
|
|
};
|
|
}
|