UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

435 lines (373 loc) 11.8 kB
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); }; }