import MysqlConnectionManager, {isQueryVariable, query, QueryVariable} from "./MysqlConnectionManager"; import Validator from "./Validator"; import {Connection} from "mysql"; import ModelComponent from "./ModelComponent"; import {Type} from "../Utils"; import ModelFactory, {PrimaryKeyValue} from "./ModelFactory"; import ModelRelation from "./ModelRelation"; import ModelQuery, {ModelFieldData, ModelQueryResult, SelectFields} from "./ModelQuery"; import {Request} from "express"; import Extendable from "../Extendable"; export default abstract class Model implements Extendable> { public static get table(): string { const single = this.name .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) .replace(/^_/, ''); return single + 's'; } public static getPrimaryKeyFields(): string[] { return ['id']; } public static create(this: ModelType, data: Pick): M { return ModelFactory.get(this).create(data, true); } public static select(this: ModelType, ...fields: SelectFields): ModelQuery { return ModelFactory.get(this).select(...fields); } public static update(this: ModelType, data: Pick): ModelQuery { return ModelFactory.get(this).update(data); } public static delete(this: ModelType): ModelQuery { return ModelFactory.get(this).delete(); } public static async getById(this: ModelType, ...id: PrimaryKeyValue[]): Promise { return await ModelFactory.get(this).getById(...id); } public static async paginate( this: ModelType, request: Request, perPage: number = 20, query?: ModelQuery, ): Promise> { return await ModelFactory.get(this).paginate(request, perPage, query); } protected readonly _factory: ModelFactory; private readonly _components: ModelComponent[] = []; private readonly _validators: { [K in keyof this]?: Validator | undefined } = {}; private _exists: boolean; [key: string]: ModelFieldData; public constructor(factory: ModelFactory, isNew: boolean) { if (!(factory instanceof ModelFactory)) throw new Error('Cannot instantiate model directly.'); this._factory = factory; this.init?.(); this._exists = !isNew; } protected init?(): void; protected setValidation(propertyName: K): Validator { const validator = new Validator(); this._validators[propertyName] = validator; return validator; } public addComponent(modelComponent: ModelComponent): void { modelComponent.applyToModel(); this._components.push(modelComponent); } public as>(type: Type): C { for (const component of this._components) { if (component instanceof type) { return this as unknown as C; } } throw new Error(`Component ${type.name} was not initialized for this ${this.constructor.name}.`); } public asOptional>(type: Type): C | null { for (const component of this._components) { if (component instanceof type) { return this as unknown as C; } } return null; } public updateWithData(data: Pick | Record): void { for (const property of this._properties) { if (data[property] !== undefined) { this[property] = data[property] as this[keyof this & string]; } } } /** * Override this to automatically fill obvious missing data i.e. from relation or default value that are fetched * asynchronously. */ protected async autoFill?(): Promise; protected async beforeSave?(connection: Connection): Promise; protected async afterSave?(): Promise; public async save(connection?: Connection, postHook?: (callback: () => Promise) => void): Promise { if (connection && !postHook) throw new Error('If connection is provided, postHook must be provided too.'); await this.autoFill?.(); await this.validate(false, connection); const needs_full_update = connection ? await this.saveTransaction(connection) : await MysqlConnectionManager.wrapTransaction(async connection => await this.saveTransaction(connection)); const callback = async () => { if (needs_full_update) { const query = this._factory.select(); for (const field of this._factory.getPrimaryKeyFields()) { query.where(field, this[field]); } query.limit(1); const result = await query.execute(connection); this.updateWithData(result.results[0]); } await this.afterSave?.(); }; if (postHook) { postHook(callback); } else { await callback(); } } private async saveTransaction(connection: Connection): Promise { // Before save await this.beforeSave?.(connection); if (!this.exists() && this.hasProperty('created_at')) { this.created_at = new Date(); } if (this.exists() && this.hasProperty('updated_at')) { this.updated_at = new Date(); } const properties = []; const values: QueryVariable[] = []; let needsFullUpdate = false; if (this.exists()) { const data: { [K in keyof this]?: this[K] } = {}; for (const property of this._properties) { const value = this[property]; if (value === undefined) needsFullUpdate = true; else data[property] = value; } const query = this._factory.update(data); for (const indexField of this._factory.getPrimaryKeyFields()) { query.where(indexField, this[indexField]); } await query.execute(connection); } else { const props_holders = []; for (const property of this._properties) { let value: ModelFieldData = this[property]; if (value === undefined) { needsFullUpdate = true; } else { if (!isQueryVariable(value)) { value = value.toString(); } properties.push(property); props_holders.push('?'); values.push(value as QueryVariable); } } const fieldNames = properties.map(f => `\`${f}\``).join(', '); const result = await query(`INSERT INTO ${this.table} (${fieldNames}) VALUES(${props_holders.join(', ')})`, values, connection); if (this.hasProperty('id')) this.id = Number(result.other?.insertId); this._exists = true; } return needsFullUpdate; } public async delete(): Promise { if (!await this.exists()) throw new Error('This model instance doesn\'t exist in DB.'); const query = this._factory.delete(); for (const indexField of this._factory.getPrimaryKeyFields()) { query.where(indexField, this[indexField]); } await query.execute(); this._exists = false; } public async validate(onlyFormat: boolean = false, connection?: Connection): Promise { return await Promise.all(this._properties.map( prop => this._validators[prop]?.execute(prop, this[prop], onlyFormat, connection), )); } public exists(): boolean { return this._exists; } public equals(model: this): boolean { for (const field of this._factory.getPrimaryKeyFields()) { if (this[field] !== model[field]) return false; } return true; } public get table(): string { return this._factory.table; } private get _properties(): (keyof this & string)[] { return Object.getOwnPropertyNames(this).filter(p => { return !p.startsWith('_') && typeof this[p] !== 'function' && !(this[p] instanceof ModelRelation); }); } private hasProperty(key: string | number | symbol): key is keyof this { return typeof key === 'string' && this._properties.indexOf(key) >= 0; } public getOrFail(k: K): NonNullable { if (!this[k]) throw new Error(k + ' not initialized.'); return this[k] as NonNullable; } } export interface ModelType extends Type { table: string; new(factory: ModelFactory, isNew: boolean): M; getPrimaryKeyFields(): (keyof M & string)[]; select(this: ModelType, ...fields: SelectFields): ModelQuery; }