import MysqlConnectionManager, {query} from "./MysqlConnectionManager"; import Validator from "./Validator"; import {Connection} from "mysql"; import Query from "./Query"; import {Request} from "express"; import Pagination from "../Pagination"; export default abstract class Model { public static async getById(id: string): Promise { const cachedModel = ModelCache.get(this.table, id); if (cachedModel?.constructor === this) { return cachedModel; } const models = await this.models(this.select().where('id', id).first()); return models.length > 0 ? models[0] : null; } public static async paginate(request: Request, perPage: number = 20): Promise { let page = request.params.page ? parseInt(request.params.page) : 1; let query: Query = this.select().limit(perPage, (page - 1) * perPage).withTotalRowCount(); if (request.params.sortBy) { const dir = request.params.sortDirection; query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); } else { query = query.sortBy('id'); } const models = await this.models(query); // @ts-ignore models.pagination = new Pagination(models, page, perPage, models.totalCount); return models; } protected static select(...fields: string[]): Query { return Query.select(this.table, ...fields); } protected static update(data: { [key: string]: any }): Query { return Query.update(this.table, data); } protected static delete(): Query { return Query.delete(this.table); } protected static async models(query: Query): Promise { const results = await query.execute(); const models: T[] = []; const factory = this.getFactory(); for (const result of results.results) { const cachedModel = ModelCache.get(this.table, this.getPrimaryKey(result)); if (cachedModel && cachedModel.constructor === this) { cachedModel.updateWithData(result); models.push(cachedModel); } else { models.push(factory(result)); } } // @ts-ignore models.totalCount = results.foundRows; return models; } protected static getPrimaryKey(modelData: any): string { return this.getPrimaryKeyFields().map(f => `${modelData[f]}`).join(','); } protected static getPrimaryKeyFields(): string[] { return ['id']; } public static async loadRelation(models: T[], relation: string, model: Function, localField: string) { const loadMap: { [p: number]: (model: T) => void } = {}; const ids = models.map(m => { m.relations[relation] = null; if (m[localField]) loadMap[m[localField]] = v => m.relations[relation] = v; return m[localField]; }).filter(id => id); for (const v of await (model).models((model).select().whereIn('id', ids))) { loadMap[v.id!](v); } } private static getFactory(factory?: ModelFactory): ModelFactory { if (factory === undefined) { factory = (this).FACTORY; if (factory === undefined) factory = data => new (this)(data); } return factory; } protected readonly properties: ModelProperty[] = []; private readonly relations: { [p: string]: (Model | null) } = {}; public id?: number; private readonly automaticIdProperty: boolean; [key: string]: any; public constructor(data: any, automaticIdProperty: boolean = true) { this.automaticIdProperty = automaticIdProperty; if (automaticIdProperty) { this.defineProperty('id', new Validator()); } this.defineProperties(); this.updateWithData(data); } public getPrimaryKey(): string { return (this.constructor).getPrimaryKey(this); } public getPrimaryKeyFields(): string { return (this.constructor).getPrimaryKeyFields(); } protected abstract defineProperties(): void; protected defineProperty(name: string, validator?: Validator | RegExp) { if (validator === undefined) validator = new Validator(); if (validator instanceof RegExp) { const regexp = validator; validator = new Validator().regexp(regexp); } const prop = new ModelProperty(name, validator); this.properties.push(prop); Object.defineProperty(this, name, { get: () => prop.value, set: (value: T) => prop.value = value, }); } private updateWithData(data: any) { if (this.automaticIdProperty) this.id = data['id']; for (const prop of this.properties) { if (data[prop.name] !== undefined) { this[prop.name] = data[prop.name]; } } } protected async beforeSave(exists: boolean, connection: Connection): Promise { } protected async afterSave(): Promise { } public async save(connection?: Connection, postHook?: (callback: () => Promise) => void): Promise { await this.validate(false, connection); const exists = await this.exists(); let needs_full_update = false; if (connection) { needs_full_update = await this.saveTransaction(connection, exists, needs_full_update); } else { needs_full_update = await MysqlConnectionManager.wrapTransaction(async connection => this.saveTransaction(connection, exists, needs_full_update)); } const callback = async () => { if (needs_full_update) { this.updateWithData((await (this.constructor).select().where('id', this.id!).first().execute()).results[0]); } if (!exists) { this.cache(); } await this.afterSave(); }; if (connection) { postHook!(callback); } else { await callback(); } } private async saveTransaction(connection: Connection, exists: boolean, needs_full_update: boolean): Promise { // Before save await this.beforeSave(exists, connection); if (exists && this.hasOwnProperty('updated_at')) { this.updated_at = new Date(); } const props = []; const values = []; if (exists) { const data: any = {}; for (const prop of this.properties) { if (prop.value !== undefined) { data[prop.name] = prop.value; } else { needs_full_update = true; } } let query = Query.update(this.table, data); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } await query.execute(connection); } else { const props_holders = []; for (const prop of this.properties) { if (prop.value !== undefined) { props.push(prop.name); props_holders.push('?'); values.push(prop.value); } else { needs_full_update = true; } } const result = await query(`INSERT INTO ${this.table} (${props.join(', ')}) VALUES(${props_holders.join(', ')})`, values, connection); if (this.automaticIdProperty) this.id = result.other.insertId; } return needs_full_update; } public static get table(): string { return this.name .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) .replace(/^_/, '') + 's'; } public get table(): string { // @ts-ignore return this.constructor.table; } public async exists(): Promise { if (!this.id) return false; let query = Query.select(this.table, '1'); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } query = query.first(); const result = await query.execute(); return result.results.length > 0; } public async delete(): Promise { if (!(await this.exists())) throw new Error('This model instance doesn\'t exist in DB.'); let query = Query.delete(this.table); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } await query.execute(); ModelCache.forget(this); if (this.automaticIdProperty) this.id = undefined; } public async validate(onlyFormat: boolean = false, connection?: Connection): Promise { return await Promise.all(this.properties.map(prop => prop.validate(onlyFormat, connection))); } private cache() { ModelCache.cache(this); } protected relation(name: string): T | null { if (this.relations[name] === undefined) throw new Error('Model not loaded'); return this.relations[name]; } } export interface ModelFactory { (data: any): T; } class ModelProperty { public readonly name: string; private readonly validator: Validator; private val?: T; constructor(name: string, validator: Validator) { this.name = name; this.validator = validator; } public async validate(onlyFormat: boolean, connection?: Connection): Promise { return await this.validator.execute(this.name, this.value, onlyFormat, connection); } public get value(): T | undefined { return this.val; } public set value(val: T | undefined) { this.val = val; } } export class ModelCache { private static readonly caches: { [key: string]: { [key: string]: Model } } = {}; public static cache(instance: Model) { const primaryKey = instance.getPrimaryKey(); if (primaryKey === undefined) throw new Error('Cannot cache an instance with an undefined primaryKey.'); let tableCache = this.caches[instance.table]; if (!tableCache) tableCache = this.caches[instance.table] = {}; if (!tableCache[primaryKey]) tableCache[primaryKey] = instance; } public static forget(instance: Model) { const primaryKey = instance.getPrimaryKey(); if (primaryKey === undefined) throw new Error('Cannot forget an instance with an undefined primaryKey.'); let tableCache = this.caches[instance.table]; if (!tableCache) return; if (tableCache[primaryKey]) delete tableCache[primaryKey]; } public static all(table: string): { [key: string]: Model } | undefined { return this.caches[table]; } public static get(table: string, primaryKey: string): Model | undefined { const tableCache = this.all(table); if (!tableCache) return undefined; return tableCache[primaryKey]; } } export const EMAIL_REGEX = /^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+$/;