alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
362 lines (314 loc) • 9.55 kB
text/typescript
import { randomUUID } from "node:crypto";
import {
AlephaError,
type Static,
type TObject,
type TSchema,
z,
} from "alepha";
import { type BuildColumns, sql } from "drizzle-orm";
import * as pg from "drizzle-orm/sqlite-core";
import {
check,
foreignKey,
index,
type SQLiteColumnBuilderBase,
type SQLiteTableWithColumns,
sqliteTable,
unique,
uniqueIndex,
} from "drizzle-orm/sqlite-core";
import {
PG_CREATED_AT,
PG_GENERATED,
PG_IDENTITY,
PG_PRIMARY_KEY,
PG_REF,
PG_SERIAL,
PG_UPDATED_AT,
type PgGeneratedOptions,
type PgRefOptions,
} from "../constants/PG_SYMBOLS.ts";
import type { EntityPrimitive } from "../primitives/$entity.ts";
import type { SequencePrimitive } from "../primitives/$sequence.ts";
import { ModelBuilder } from "./ModelBuilder.ts";
export class SqliteModelBuilder extends ModelBuilder {
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 columns = this.schemaToSqliteColumns(
tableName,
entity.schema,
options.enums,
options.tables,
);
// Build the config function for SQLite
const configFn = this.getTableConfig(entity, options.tables);
const table = sqliteTable(tableName, columns, configFn);
options.tables.set(tableName, table);
}
public buildSequence(
sequence: SequencePrimitive,
options: {
sequences: Map<string, unknown>;
schema: string;
},
) {
throw new AlephaError("SQLite does not support sequences");
}
/**
* Get SQLite-specific config builder for the table.
*/
protected getTableConfig(
entity: EntityPrimitive,
tables: Map<string, unknown>,
): ((self: BuildColumns<string, any, "sqlite">) => any) | undefined {
// SQLite-specific builders
const sqliteBuilders = {
index,
uniqueIndex,
unique,
check,
foreignKey,
};
// Table resolver function
const tableResolver = (entityName: string) => {
return tables.get(entityName) as any;
};
return this.buildTableConfig<any, BuildColumns<string, any, "sqlite">>(
entity,
sqliteBuilders,
tableResolver,
(config, self) => {
// SQLite custom config handler
const customConfigs = (config as any)(self);
return Array.isArray(customConfigs) ? customConfigs : [];
},
);
}
// -------------------------------------------------------------------------------------------------------------------
schemaToSqliteColumns = <T extends TObject>(
tableName: string,
schema: T,
enums: Map<string, unknown>,
tables: Map<string, unknown>,
): SchemaToSqliteBuilder<T> => {
return Object.entries(schema.properties as Record<string, any>).reduce<
Partial<SchemaToSqliteBuilder<T>>
>((columns, [key, value]) => {
let col = this.mapFieldToSqliteColumn(tableName, key, value, 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 SQLiteTableWithColumns<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, {
mode: gen.mode ?? "virtual",
});
}
if (z.schema.requiredKeys(schema).includes(key)) {
col = col.notNull();
}
(columns as Record<string, unknown>)[key] = col;
return columns;
}, {}) as SchemaToSqliteBuilder<T>;
};
mapFieldToSqliteColumn = (
tableName: string,
fieldName: string,
value: any,
enums: Map<string, any>,
) => {
const key = this.toColumnName(fieldName);
// Peel optional / nullable / default wrappers to the underlying type.
value = z.schema.unwrap(value);
if (z.schema.isInteger(value)) {
if (PG_SERIAL in value || PG_IDENTITY in value) {
return pg
.integer(key, { mode: "number" })
.primaryKey({ autoIncrement: true });
}
return pg.integer(key);
}
if (z.schema.isBigInt(value)) {
if (PG_PRIMARY_KEY in value || PG_IDENTITY in value) {
return pg
.integer(key, { mode: "number" })
.primaryKey({ autoIncrement: true });
}
return pg.integer(key, { mode: "number" });
}
if (z.schema.isNumber(value)) {
if (PG_IDENTITY in value) {
return pg
.integer(key, { mode: "number" })
.primaryKey({ autoIncrement: true });
}
// Native real column — returns a JS number (sqlite `numeric` yields a
// string, which strict `z.number()` validation rejects).
return pg.real(key);
}
if (z.schema.isString(value)) {
return this.mapStringToSqliteColumn(key, value);
}
if (z.schema.isBoolean(value)) {
return this.sqliteBool(key, value);
}
if (z.schema.isObject(value)) {
return this.sqliteJson(key, value);
}
if (z.schema.isRecord(value)) {
return this.sqliteJson(key, value);
}
if (z.schema.isAny(value)) {
return this.sqliteJson(key, value);
}
if (z.schema.isArray(value)) {
if (z.schema.isObject(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isRecord(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isAny(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isString(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isInteger(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isNumber(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isBoolean(value.items)) {
return this.sqliteJson(key, value);
}
if (z.schema.isEnum(value.items)) {
return this.sqliteJson(key, value);
}
}
if (z.schema.isEnum(value)) {
return this.mapStringToSqliteColumn(key, value);
}
throw new AlephaError(
`Unsupported schema for field '${tableName}.${fieldName}' (schema: ${JSON.stringify(value)})`,
);
};
mapStringToSqliteColumn = (key: string, value: any) => {
if (z.schema.format(value) === "uuid") {
if (PG_PRIMARY_KEY in value) {
return pg
.text(key)
.primaryKey()
.$defaultFn(() => randomUUID());
}
return pg.text(key);
}
if (z.schema.format(value) === "binary") {
return this.sqliteJson(key, value);
}
if (z.schema.format(value) === "date-time") {
if (PG_CREATED_AT in value) {
return this.sqliteDateTime(key, {}).default(
sql`(unixepoch('subsec') * 1000)`,
);
}
if (PG_UPDATED_AT in value) {
return this.sqliteDateTime(key, {}).default(
sql`(unixepoch('subsec') * 1000)`,
);
}
return this.sqliteDateTime(key, {});
}
if (z.schema.format(value) === "date") {
return this.sqliteDate(key, {});
}
return pg.text(key);
};
sqliteJson = <TDocument extends TSchema>(name: string, document: TDocument) =>
pg
.customType<{
data: Static<TDocument>;
driverData: string;
config: { document: TDocument };
configRequired: true;
}>({
dataType: () => "text",
toDriver: (value) => JSON.stringify(value),
fromDriver: (value: TDocument | string) => {
return value && typeof value === "string" ? JSON.parse(value) : value;
},
})(name, { document })
.$type<Static<TDocument>>();
sqliteDateTime = pg.customType<{
data: string;
driverData: number;
configRequired: true;
}>({
dataType: () => "integer",
toDriver: (value) => new Date(value).getTime(),
fromDriver: (value) => {
return new Date(value).toISOString();
},
});
sqliteBool = pg.customType<{
data: boolean;
driverData: number;
configRequired: true;
}>({
dataType: () => "integer",
toDriver: (value) => (value ? 1 : 0),
fromDriver: (value) => value === 1,
});
sqliteDate = pg.customType<{
data: string;
driverData: number;
configRequired: true;
}>({
dataType: () => "integer",
toDriver: (value) => new Date(value).getTime(),
fromDriver: (value) => {
return new Date(value).toISOString().split("T")[0];
},
});
}
export type SchemaToSqliteBuilder<T extends TObject> = {
[key in keyof T["properties"]]: SQLiteColumnBuilderBase;
};