import type { Operator, OrderByClauses, OrderDirection, QueryBuilder, QueryDescription, QueryType,} from "./query-builder.ts";import type { Database } from "./database.ts";import type { PivotModelSchema } from "./model-pivot.ts";import { camelCase } from "../deps.ts";import { DataTypes, FieldAlias, FieldOptions, FieldProps, FieldType, FieldTypeString, FieldValue, Values,} from "./data-types.ts";
export type ModelSchema = typeof Model;
export type ModelFields = { [key: string]: FieldType };export type ModelDefaults = { [field: string]: FieldValue | (() => FieldValue);};export type ModelPivotModels = { [modelName: string]: PivotModelSchema };export type FieldMatchingTable = { [clientField: string]: string };
export type ModelOptions = { queryBuilder: QueryBuilder; database: Database;};
export type AggregationResult = Model & { avg?: number; count?: number; max?: number; min?: number; sum?: number;};
export type ModelEventType = | "creating" | "created" | "updating" | "updated" | "deleting" | "deleted";
export type ModelEventListenerWithModel = (model: Model) => void;export type ModelEventListenerWithoutModel = (model?: Model) => void;export type ModelEventListener = | ModelEventListenerWithoutModel | ModelEventListenerWithModel;
export type ModelEventListeners = { [eventType in ModelEventType]?: ModelEventListener[];};
export class Model { [attribute: string]: FieldValue | Function
static table = "";
static timestamps = false;
static fields: ModelFields = {};
static defaults: ModelDefaults = {};
static pivot: ModelPivotModels = {};
private static _isCreatedInDatabase = false;
private static _queryBuilder: QueryBuilder;
private static _database: Database;
private static _primaryKey: string;
private static _fieldMatching: { toDatabase: FieldMatchingTable; toClient: FieldMatchingTable; } = { toDatabase: {}, toClient: {}, };
private static _currentQuery: QueryBuilder;
private static _options: ModelOptions;
private static _listeners: ModelEventListeners = {};
static _link(options: ModelOptions) { this._options = options; this._database = options.database; this._queryBuilder = options.queryBuilder;
this._fieldMatching = this._database._computeModelFieldMatchings( this.name, this.fields, this.timestamps, );
this._currentQuery = this._queryBuilder.queryForSchema(this); this._primaryKey = this._findPrimaryKey(); }
static async drop() { const dropQuery = this._options.queryBuilder .queryForSchema(this) .table(this.table) .dropIfExists() .toDescription();
await this._options.database.query(dropQuery);
this._isCreatedInDatabase = false; }
static async truncate() { const truncateQuery = this._options.queryBuilder .queryForSchema(this) .table(this.table) .truncate() .toDescription();
await this._options.database.query(truncateQuery); }
static async createTable() { if (this._isCreatedInDatabase) { throw new Error("This model has already been initialized."); }
const createQuery = this._options.queryBuilder .queryForSchema(this) .table(this.table) .createTable( this.formatFieldToDatabase(this.fields) as ModelFields, this.formatFieldToDatabase(this.defaults) as ModelDefaults, { withTimestamps: this.timestamps, ifNotExists: true, }, ) .toDescription();
await this._options.database.query(createQuery);
this._isCreatedInDatabase = true; }
private static _findPrimaryField(): FieldOptions { const field = Object.entries(this.fields).find( ([_, fieldType]) => typeof fieldType === "object" && fieldType.primaryKey, );
return { name: field ? (this.formatFieldToDatabase(field[0]) as string) : "", type: field ? field[1] : DataTypes.INTEGER, defaultValue: 0, }; }
private static _findPrimaryKey(): string { return this._findPrimaryField().name; }
static getComputedPrimaryKey(): string { if (!this._primaryKey) { this._primaryKey = this._findPrimaryKey(); }
return this._primaryKey; }
static getComputedPrimaryType(): FieldTypeString { const field = this._findPrimaryField();
return typeof field.type === "object" ? (field.type as any).type : field.type; }
static getComputedPrimaryProps(): FieldProps { const field = this._findPrimaryField();
return typeof field === "object" ? field.type : {}; }
private static async _runQuery(query: QueryDescription) { this._currentQuery = this._queryBuilder.queryForSchema(this);
if (query.type) { this._runEventListeners(query.type); }
const results = await this._database.query(query);
if (query.type) { this._runEventListeners(query.type, results); }
return results; }
private static _formatField( fieldMatching: FieldMatchingTable, field: string | { [fieldName: string]: any }, defaultCase?: (field: string) => string, ): string | { [fieldName: string]: any } { if (typeof field !== "string") { return Object.entries(field).reduce((prev: any, [fieldName, value]) => { prev[this._formatField(fieldMatching, fieldName) as string] = value; return prev; }, {}) as { [fieldName: string]: any }; }
if (field in fieldMatching) { return fieldMatching[field]; }
return defaultCase ? defaultCase(field) : field; }
static formatFieldToDatabase(field: string | Object) { return this._formatField(this._fieldMatching.toDatabase, field); }
static formatFieldToClient(field: string | Object) { return this._formatField(this._fieldMatching.toClient, field, camelCase); }
private static _wrapValuesWithDefaults(values: Values): Values { for (const field of Object.keys(this.fields)) { if (values.hasOwnProperty(field)) { continue; }
if (this.defaults.hasOwnProperty(field)) { const defaultValue = this.defaults[field];
if (typeof defaultValue === "function") { values[field] = defaultValue(); } else { values[field] = defaultValue; } } }
return values; }
static on<T extends ModelSchema>( this: T, eventType: ModelEventType, callback: ModelEventListener, ) { if (!(eventType in this._listeners)) { this._listeners[eventType] = []; }
this._listeners[eventType]!.push(callback);
return this; }
static addEventListener<T extends ModelSchema>( this: T, eventType: ModelEventType, callback: ModelEventListener, ) { return this.on(eventType, callback); }
static removeEventListener( eventType: ModelEventType, callback: ModelEventListener, ) { if (!(eventType in this._listeners)) { throw new Error( `There is no event listener for ${eventType}. You might be trying to remove a listener that you haven't added with Model.on('${eventType}', ...).`, ); }
this._listeners[eventType] = this._listeners[eventType]!.filter(( listener, ) => listener !== callback);
return this; }
private static _runEventListeners( queryType: QueryType, instances?: Model | Model[], ) { const isPastEvent = !!instances;
let eventType: ModelEventType; switch (queryType) { case "insert": eventType = isPastEvent ? "created" : "creating"; break;
case "update": eventType = isPastEvent ? "updated" : "updating"; break;
case "delete": eventType = isPastEvent ? "deleted" : "deleting"; break;
default: return; }
const listeners = this._listeners[eventType];
if (!listeners) { return; }
for (const listener of listeners) { if (instances) { if (Array.isArray(instances)) { if (instances.length > 0) { instances.forEach(listener); } else { (listener as ModelEventListenerWithoutModel)(); } } else { listener(instances); } } else { (listener as ModelEventListenerWithoutModel)(); } } }
static field(field: string): string; static field(field: string, nameAs: string): FieldAlias; static field(field: string, nameAs?: string): string | FieldAlias { const fullField = this.formatFieldToDatabase( `${this.table}.${field}`, ) as string;
if (nameAs) { return { [nameAs]: fullField }; }
return fullField; }
static get() { return this._runQuery( this._currentQuery.table(this.table).get().toDescription(), ); }
static all() { return this.get() as Promise<Model[]>; }
static select<T extends ModelSchema>( this: T, ...fields: (string | FieldAlias)[] ) { this._currentQuery.select( ...fields.map((field) => this.formatFieldToDatabase(field)), ); return this; }
static async create(values: Values): Promise<Model>; static async create(values: Values[]): Promise<Model[]>; static async create(values: Values | Values[]) { const insertions = Array.isArray(values) ? values : [values];
const results = await this._runQuery( this._currentQuery.table(this.table).create( insertions.map((field) => this.formatFieldToDatabase(this._wrapValuesWithDefaults(field)) ) as Values[], ).toDescription(), );
if (!Array.isArray(values) && Array.isArray(results)) { return results[0]; }
return results; }
static async find(idOrIds: FieldValue): Promise<Model>; static async find(idOrIds: FieldValue[]): Promise<Model[]>; static async find(idOrIds: FieldValue | FieldValue[]) { const results = await this._runQuery( this._currentQuery .table(this.table) .find( this.getComputedPrimaryKey(), Array.isArray(idOrIds) ? idOrIds : [idOrIds], ) .toDescription(), );
return Array.isArray(idOrIds) ? results : (results as Model[])[0]; }
static orderBy<T extends ModelSchema>( this: T, fieldOrFields: string | OrderByClauses, orderDirection: OrderDirection = "asc", ) { if (typeof fieldOrFields === "string") { this._currentQuery.orderBy( this.formatFieldToDatabase(fieldOrFields) as string, orderDirection, ); } else { for ( const [field, orderDirectionField] of Object.entries( fieldOrFields, ) ) { this._currentQuery.orderBy( this.formatFieldToDatabase(field) as string, orderDirectionField, ); } }
return this; }
static groupBy<T extends ModelSchema>(this: T, field: string) { this._currentQuery.groupBy(this.formatFieldToDatabase(field) as string); return this; }
static take<T extends ModelSchema>(this: T, limit: number) { return this.limit(limit); }
static limit<T extends ModelSchema>(this: T, limit: number) { this._currentQuery.limit(limit); return this; }
static async first() { this.take(1); const results = await this.get(); return (results as Model[])[0]; }
static offset<T extends ModelSchema>(this: T, offset: number) { this._currentQuery.offset(offset); return this; }
static skip<T extends ModelSchema>(this: T, offset: number) { return this.offset(offset); }
static where<T extends ModelSchema>( this: T, field: string, fieldValue: FieldValue, ): T; static where<T extends ModelSchema>( this: T, field: string, operator: Operator, fieldValue: FieldValue, ): T; static where<T extends ModelSchema>(this: T, fields: Values): T; static where<T extends ModelSchema>( this: T, fieldOrFields: string | Values, operatorOrFieldValue?: Operator | FieldValue, fieldValue?: FieldValue, ) { if (typeof fieldOrFields === "string") { const whereOperator: Operator = typeof fieldValue !== "undefined" ? (operatorOrFieldValue as Operator) : "=";
const whereValue: FieldValue = typeof fieldValue !== "undefined" ? fieldValue : (operatorOrFieldValue as FieldValue);
if (whereValue !== undefined) { this._currentQuery.where( this.formatFieldToDatabase(fieldOrFields) as string, whereOperator, whereValue, ); } } else {
for (const [field, value] of Object.entries(fieldOrFields)) { if (value === undefined) { continue; }
this._currentQuery.where( this.formatFieldToDatabase(field) as string, "=", value, ); } }
return this; }
static update(fieldOrFields: string | Values, fieldValue?: FieldValue) { let fieldsToUpdate: Values = {};
if (this.timestamps) { fieldsToUpdate[ this.formatFieldToDatabase("updated_at") as string ] = new Date(); }
if (typeof fieldOrFields === "string") { fieldsToUpdate[ this.formatFieldToDatabase(fieldOrFields) as string ] = fieldValue!; } else { fieldsToUpdate = { ...fieldsToUpdate, ...(this.formatFieldToDatabase(fieldOrFields) as { [fieldName: string]: any; }), }; }
return this._runQuery( this._currentQuery .table(this.table) .update(fieldsToUpdate) .toDescription(), ) as Promise<Model | Model[]>; }
static deleteById(id: FieldValue) { return this._runQuery( this._currentQuery .table(this.table) .where(this.getComputedPrimaryKey(), "=", id) .delete() .toDescription(), ); }
static delete() { return this._runQuery( this._currentQuery.table(this.table).delete().toDescription(), ); }
static join<T extends ModelSchema>( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.join( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; }
static leftOuterJoin<T extends ModelSchema>( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.leftOuterJoin( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; }
static leftJoin<T extends ModelSchema>( this: T, joinTable: ModelSchema, originField: string, targetField: string, ) { this._currentQuery.leftJoin( joinTable.table, joinTable.formatFieldToDatabase(originField) as string, this.formatFieldToDatabase(targetField) as string, ); return this; }
static async count(field = "*") { const value = await this._runQuery( this._currentQuery .table(this.table) .count(this.formatFieldToDatabase(field) as string) .toDescription(), );
return Number((value as AggregationResult[])[0].count); }
static async min(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .min(this.formatFieldToDatabase(field) as string) .toDescription(), );
return Number((value as AggregationResult[])[0].min); }
static async max(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .max(this.formatFieldToDatabase(field) as string) .toDescription(), );
return Number((value as AggregationResult[])[0].max); }
static async sum(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .sum(this.formatFieldToDatabase(field) as string) .toDescription(), );
return Number((value as AggregationResult[])[0].sum); }
static async avg(field: string) { const value = await this._runQuery( this._currentQuery .table(this.table) .avg(this.formatFieldToDatabase(field) as string) .toDescription(), );
return Number((value as AggregationResult[])[0].avg); }
static hasMany<T extends ModelSchema>( this: T, model: ModelSchema, ): Promise<Model | Model[]> { const currentWhereValue = this._findCurrentQueryWhereClause();
if (model.name in this.pivot) { const pivot = this.pivot[model.name]; const pivotField = this.formatFieldToDatabase( pivot._pivotsFields[this.name], ) as string; const pivotOtherModel = pivot._pivotsModels[model.name]; const pivotOtherModelField = pivotOtherModel.formatFieldToDatabase( pivot._pivotsFields[model.name], ) as string;
return pivot .where(pivot.field(pivotField), currentWhereValue) .join( pivotOtherModel, pivotOtherModel.field(pivotOtherModel.getComputedPrimaryKey()), pivot.field(pivotOtherModelField), ) .get(); }
const foreignKeyName = this._findModelForeignKeyField(model); this._currentQuery = this._queryBuilder.queryForSchema(this); return model.where(foreignKeyName, currentWhereValue).all(); }
static async hasOne<T extends ModelSchema>(this: T, model: ModelSchema) { const currentWhereValue = this._findCurrentQueryWhereClause(); const FKName = this._findModelForeignKeyField(model);
if (!FKName) { const currentModelFKName = this._findModelForeignKeyField(this, model); const currentModelValue = await this.where( this.getComputedPrimaryKey(), currentWhereValue, ).first(); const currentModelFKValue = currentModelValue[currentModelFKName] as FieldValue; return model.where(model.getComputedPrimaryKey(), currentModelFKValue) .first(); }
return model.where(FKName, currentWhereValue).first(); }
private static _findCurrentQueryWhereClause() { if (!this._currentQuery._query.wheres) { throw new Error("The current query does not have any where clause."); }
const where = this._currentQuery._query.wheres.find((where) => { return where.field === this.getComputedPrimaryKey(); });
if (!where) { throw new Error( "The current query does not have any where clause for this model primary key.", ); }
return where.value; }
private static _findModelForeignKeyField( model: ModelSchema, forModel: ModelSchema = this, ): string { const modelFK: [string, FieldType] | undefined = Object.entries( model.fields, ).find(([, type]) => { return typeof type === "object" ? type.relationship?.model === forModel : false; });
if (!modelFK) { return ""; }
return modelFK[0]; }
private _getCurrentPrimaryKey() { const model = this.constructor as ModelSchema; return (this as any)[model.getComputedPrimaryKey()] as string; }
async save() { const model = this.constructor as ModelSchema; const values: Values = {};
for (const field of Object.keys(model.fields)) { if (this.hasOwnProperty(field)) { values[field] = (this as any)[field]; } }
const createdInstance = await model.create(values);
for (const field in createdInstance) { (this as any)[field] = (createdInstance as any)[field]; }
return this; }
async update() { const model = this.constructor as ModelSchema; const modelPK = model.getComputedPrimaryKey();
const values: Values = {}; for (const field of Object.keys(model.fields)) { if (this.hasOwnProperty(field) && field !== modelPK) { values[field] = (this as any)[field]; } }
await model.where(modelPK, this._getCurrentPrimaryKey()).update( values, );
return this; }
delete() { const model = this.constructor as ModelSchema; const PKCurrentValue = this._getCurrentPrimaryKey();
if (PKCurrentValue === undefined) { throw new Error( "This instance does not have a value for its primary key. It cannot be deleted.", ); }
return model.deleteById(PKCurrentValue); }}