swaf/src/db/Model.ts

341 lines
11 KiB
TypeScript

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<T extends Model>(id: string): Promise<T | null> {
const cachedModel = ModelCache.get(this.table, id);
if (cachedModel?.constructor === this) {
return <T>cachedModel;
}
const models = await this.models<T>(this.select().where('id', id).first());
return models.length > 0 ? models[0] : null;
}
public static async paginate<T extends Model>(request: Request, perPage: number = 20): Promise<T[]> {
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<T>(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<T extends Model>(query: Query): Promise<T[]> {
const results = await query.execute();
const models: T[] = [];
const factory = this.getFactory<T>();
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(<T>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<T extends Model>(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 (<any>model).models((<any>model).select().whereIn('id', ids))) {
loadMap[v.id!](v);
}
}
private static getFactory<T extends Model>(factory?: ModelFactory<T>): ModelFactory<T> {
if (factory === undefined) {
factory = (<any>this).FACTORY;
if (factory === undefined) factory = data => new (<any>this)(data);
}
return factory;
}
protected readonly properties: ModelProperty<any>[] = [];
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<number>('id', new Validator());
}
this.defineProperties();
this.updateWithData(data);
}
public getPrimaryKey(): string {
return (<any>this.constructor).getPrimaryKey(this);
}
public getPrimaryKeyFields(): string {
return (<any>this.constructor).getPrimaryKeyFields();
}
protected abstract defineProperties(): void;
protected defineProperty<T>(name: string, validator?: Validator<T> | RegExp) {
if (validator === undefined) validator = new Validator();
if (validator instanceof RegExp) {
const regexp = validator;
validator = new Validator().regexp(regexp);
}
const prop = new ModelProperty<T>(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<void> {
}
protected async afterSave(): Promise<void> {
}
public async save(connection?: Connection, postHook?: (callback: () => Promise<void>) => void): Promise<void> {
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 (<any>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<boolean> {
// 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<boolean> {
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<void> {
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<void[]> {
return await Promise.all(this.properties.map(prop => prop.validate(onlyFormat, connection)));
}
private cache() {
ModelCache.cache(this);
}
protected relation<T extends Model>(name: string): T | null {
if (this.relations[name] === undefined) throw new Error('Model not loaded');
return <T | null>this.relations[name];
}
}
export interface ModelFactory<T extends Model> {
(data: any): T;
}
class ModelProperty<T> {
public readonly name: string;
private readonly validator: Validator<T>;
private val?: T;
constructor(name: string, validator: Validator<T>) {
this.name = name;
this.validator = validator;
}
public async validate(onlyFormat: boolean, connection?: Connection): Promise<void> {
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])?)+$/;