alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
435 lines (373 loc) • 11.8 kB
text/typescript
import { AlephaError, type TObject, z } from "alepha";
import {
type EntityPrimitive,
type FromSchema,
ModelBuilder,
PG_CREATED_AT,
PG_GENERATED,
PG_IDENTITY,
PG_PRIMARY_KEY,
PG_REF,
PG_SERIAL,
PG_UPDATED_AT,
type PgGeneratedOptions,
type PgIdentityOptions,
type PgRefOptions,
type SequencePrimitive,
schema,
sql,
} from "alepha/orm";
import type { BuildExtraConfigColumns } from "drizzle-orm";
import * as pg from "drizzle-orm/pg-core";
import {
check,
foreignKey,
index,
type PgEnum,
type PgSchema,
type PgTableExtraConfigValue,
type PgTableWithColumns,
pgEnum,
pgSchema,
pgSequence,
pgTable,
unique,
uniqueIndex,
} from "drizzle-orm/pg-core";
import { byte } from "../types/byte.ts";
export class PostgresModelBuilder extends ModelBuilder {
protected schemas = new Map<string, PgSchema>();
/**
* Create a primary key column with UUID v7
*/
protected getPrimaryKeyUUID(key: string) {
return pg.uuid(key).default(sql`uuidv7()`);
}
protected getPgSchema(name: string) {
if (!this.schemas.has(name) && name !== "public") {
this.schemas.set(name, pgSchema(name));
}
const nsp =
name !== "public"
? this.schemas.get(name)
: ({
enum: pgEnum,
table: pgTable,
sequence: pgSequence,
} as any);
if (!nsp) {
throw new AlephaError(`Postgres schema ${name} not found`);
}
return nsp;
}
public buildTable(
entity: EntityPrimitive<any>,
options: {
tables: Map<string, unknown>;
enums: Map<string, unknown>;
schemas: Map<string, unknown>;
schema: string;
},
) {
const tableName = entity.name;
if (options.tables.has(tableName)) {
return;
}
const nsp = this.getPgSchema(options.schema);
// Register PgSchema so drizzle-kit knows the schema is declared in code.
// Without this, pushSchema diffs "schema exists in DB but not in code" → DROP.
if (options.schema !== "public" && !options.schemas.has(options.schema)) {
options.schemas.set(options.schema, nsp);
}
const columns = this.schemaToPgColumns(
tableName,
entity.schema,
nsp,
options.enums,
options.tables,
);
// Build the config function that includes indexes, foreign keys, constraints, and custom config
const configFn = this.getTableConfig(entity, options.tables);
const table = nsp.table(tableName, columns, configFn);
options.tables.set(tableName, table);
}
public buildSequence(
sequence: SequencePrimitive,
options: {
sequences: Map<string, unknown>;
schema: string;
},
) {
const sequenceName = sequence.name;
if (options.sequences.has(sequenceName)) {
return;
}
const nsp = this.getPgSchema(options.schema);
options.sequences.set(
sequenceName,
nsp.sequence(sequenceName, sequence.options),
);
}
// -------------------------------------------------------------------------------------------------------------------
/**
* Get PostgreSQL-specific config builder for the table.
*/
protected getTableConfig(
entity: EntityPrimitive,
tables: Map<string, unknown>,
):
| ((
self: BuildExtraConfigColumns<string, any, "pg">,
) => PgTableExtraConfigValue[])
| undefined {
// PostgreSQL-specific builders
const pgBuilders = {
index,
uniqueIndex,
unique,
check,
foreignKey,
};
// Table resolver function
const tableResolver = (entityName: string) => {
return tables.get(entityName) as any;
};
return this.buildTableConfig<
PgTableExtraConfigValue,
BuildExtraConfigColumns<string, any, "pg">
>(entity, pgBuilders as any, tableResolver);
}
schemaToPgColumns = <T extends TObject>(
tableName: string,
schema: T,
nsp: PgSchema,
enums: Map<string, unknown>,
tables: Map<string, unknown>,
): FromSchema<T> => {
return Object.entries(schema.properties as Record<string, any>).reduce<
Partial<FromSchema<T>>
>((columns, [key, value]) => {
let col = this.mapFieldToColumn(tableName, key, value, nsp, enums);
const defaultValue = z.schema.getDefault(value);
if (defaultValue != null) {
col = col.default(defaultValue as any);
}
if (PG_PRIMARY_KEY in value) {
col = col.primaryKey();
}
if (PG_REF in value) {
const config = value[PG_REF] as PgRefOptions;
col = col.references(() => {
const ref = config.ref();
const table = tables.get(ref.entity.name) as PgTableWithColumns<any>;
if (!table) {
throw new AlephaError(
`Referenced table ${ref.entity.name} not found for ${tableName}.${key}`,
);
}
const target = table[ref.name];
if (!target) {
throw new AlephaError(
`Referenced column ${ref.name} not found in table ${ref.entity.name} for ${tableName}.${key}`,
);
}
return target;
}, config.actions);
}
if (PG_GENERATED in value) {
const gen = value[PG_GENERATED] as PgGeneratedOptions;
col = col.generatedAlwaysAs(gen.expression);
}
if (z.schema.requiredKeys(schema).includes(key)) {
col = col.notNull();
}
(columns as Record<string, unknown>)[key] = col;
return columns;
}, {}) as FromSchema<T>;
};
mapFieldToColumn = (
tableName: string,
fieldName: string,
value: any,
nsp: PgSchema,
enums: Map<string, any>,
) => {
const key = this.toColumnName(fieldName);
// Peel optional / nullable / default wrappers to the underlying type.
value = z.schema.unwrap(value);
// `z.int64()` is a safe-integer with format "int64" → Postgres bigint
// (stored as a JS number). Must be checked before the plain-integer branch
// below, otherwise it maps to int4 and overflows past 2_147_483_647.
if (z.schema.isInteger(value) && z.schema.format(value) === "int64") {
if (PG_IDENTITY in value) {
const options = value[PG_IDENTITY] as PgIdentityOptions;
if (options.mode === "byDefault") {
return pg
.bigint(key, { mode: "number" })
.generatedByDefaultAsIdentity(options);
}
return pg
.bigint(key, { mode: "number" })
.generatedAlwaysAsIdentity(options);
}
return pg.bigint(key, { mode: "number" });
}
if (z.schema.isInteger(value)) {
if (PG_SERIAL in value) {
return pg.serial(key);
}
if (PG_IDENTITY in value) {
const options = value[PG_IDENTITY] as PgIdentityOptions;
if (options.mode === "byDefault") {
return pg.integer(key).generatedByDefaultAsIdentity(options);
}
return pg.integer(key).generatedAlwaysAsIdentity(options);
}
return pg.integer(key);
}
if (z.schema.isBigInt(value)) {
if (PG_IDENTITY in value) {
const options = value[PG_IDENTITY] as PgIdentityOptions;
if (options.mode === "byDefault") {
return pg
.bigint(key, { mode: "bigint" })
.generatedByDefaultAsIdentity(options);
}
return pg
.bigint(key, { mode: "bigint" })
.generatedAlwaysAsIdentity(options);
}
return pg.bigint(key, { mode: "bigint" });
}
if (z.schema.isNumber(value)) {
if (PG_IDENTITY in value) {
const options = value[PG_IDENTITY] as PgIdentityOptions;
if (options.mode === "byDefault") {
return pg
.bigint(key, { mode: "number" })
.generatedByDefaultAsIdentity(options);
}
return pg
.bigint(key, { mode: "number" })
.generatedAlwaysAsIdentity(options);
}
if (z.schema.format(value) === "int64") {
return pg.bigint(key, { mode: "number" });
}
// Native float column — drizzle returns a JS number. `numeric` would
// hand back a string, which strict `z.number()` validation rejects.
return pg.doublePrecision(key);
}
if (z.schema.isString(value) && !z.schema.isEnum(value)) {
return this.mapStringToColumn(key, value);
}
if (z.schema.isBoolean(value)) {
return pg.boolean(key);
}
if (z.schema.isObject(value)) {
return schema(key, value);
}
if (z.schema.isRecord(value)) {
return schema(key, value);
}
if (z.schema.isArray(value)) {
const items = value.items;
if (z.schema.isObject(items)) {
return schema(key, value);
}
if (z.schema.isRecord(items)) {
return schema(key, value);
}
if (z.schema.isString(items)) {
return pg.text(key).array();
}
if (z.schema.isInteger(items)) {
return pg.integer(key).array();
}
if (z.schema.isNumber(items)) {
// doublePrecision returns JS numbers; `numeric` hands back strings,
// which strict `z.number()` element validation rejects (mirrors the
// scalar-number column choice above).
return pg.doublePrecision(key).array();
}
if (z.schema.isBoolean(items)) {
return pg.boolean(key).array();
}
if (z.schema.isEnum(items)) {
return pg.text(key).array();
}
}
// Enum handling
if (z.schema.isEnum(value)) {
const enumVals = z.schema.enumValues(value);
if (!enumVals.every((it) => typeof it === "string")) {
throw new AlephaError(
`Enum for ${fieldName} must be an array of strings, got ${JSON.stringify(
enumVals,
)}`,
);
}
const enumMeta = (value.meta?.() ?? {}) as {
mode?: string;
name?: string;
};
// SQL Enum (default for z.enum unless mode: "text")
if (enumMeta.mode !== "text") {
const enumName = enumMeta.name ?? `${tableName}_${key}_enum`;
if (enums.has(enumName)) {
const values = (
enums.get(enumName) as PgEnum<[string]>
).enumValues.join(",");
const newValues = enumVals.join(",");
if (values !== newValues) {
throw new AlephaError(
`Enum name conflict for ${enumName}: [${values}] vs [${newValues}]`,
);
}
}
enums.set(enumName, nsp.enum(enumName, enumVals as [string]));
return enums.get(enumName)(key);
}
// else, map to TEXT
return this.mapStringToColumn(key, value);
}
throw new AlephaError(
`Unsupported schema type for ${fieldName} as ${JSON.stringify(value)}`,
);
};
/**
* Map a string to a PG column.
*
* @param key The key of the field.
* @param value The value of the field.
*/
mapStringToColumn = (key: string, value: any) => {
const format = z.schema.format(value);
if (format === "uuid") {
if (PG_PRIMARY_KEY in value) {
return this.getPrimaryKeyUUID(key);
}
return pg.uuid(key);
}
if (format === "binary") {
return byte(key);
}
if (format === "date-time") {
if (PG_CREATED_AT in value) {
return pg
.timestamp(key, { mode: "string", withTimezone: true })
.defaultNow();
}
if (PG_UPDATED_AT in value) {
return pg
.timestamp(key, { mode: "string", withTimezone: true })
.defaultNow();
}
return pg.timestamp(key, { mode: "string", withTimezone: true });
}
if (format === "date") {
return pg.date(key, { mode: "string" });
}
return pg.text(key);
};
}