swaf/src/db/Model.ts

257 lines
8.6 KiB
TypeScript

import {Request} from "express";
import {Connection} from "mysql";
import Extendable, {MissingComponentError} from "../Extendable.js";
import {Type} from "../Utils.js";
import ModelComponent from "./ModelComponent.js";
import ModelFactory, {PrimaryKeyValue} from "./ModelFactory.js";
import ModelQuery, {ModelFieldData, ModelQueryResult, QueryFields} from "./ModelQuery.js";
import ModelRelation from "./ModelRelation.js";
import MysqlConnectionManager from "./MysqlConnectionManager.js";
import Validator from "./Validator.js";
export default abstract class Model implements Extendable<ModelComponent<Model>> {
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<M extends Model>(this: ModelType<M>, data: Pick<M, keyof M>): M {
return ModelFactory.get(this).create(data, true);
}
public static select<M extends Model>(this: ModelType<M>, ...fields: QueryFields): ModelQuery<M> {
return ModelFactory.get(this).select(...fields);
}
public static update<M extends Model>(this: ModelType<M>, data: Pick<M, keyof M>): ModelQuery<M> {
return ModelFactory.get(this).update(data);
}
public static delete<M extends Model>(this: ModelType<M>): ModelQuery<M> {
return ModelFactory.get(this).delete();
}
public static async getById<M extends Model>(this: ModelType<M>, ...id: PrimaryKeyValue[]): Promise<M | null> {
return await ModelFactory.get(this).getById(...id);
}
public static async paginate<M extends Model>(
this: ModelType<M>,
request: Request,
perPage: number = 20,
query?: ModelQuery<M>,
): Promise<ModelQueryResult<M>> {
return await ModelFactory.get(this).paginate(request, perPage, query);
}
protected readonly _factory: ModelFactory<Model>;
private readonly _components: ModelComponent<this>[] = [];
private readonly _validators: { [K in keyof this]?: Validator<this[K]> | undefined } = {};
private _exists: boolean;
[key: string]: ModelFieldData;
public constructor(factory: ModelFactory<never>, 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<K extends keyof this>(propertyName: K): Validator<this[K]> {
const validator = new Validator<this[K]>();
this._validators[propertyName] = validator;
return validator;
}
public addComponent(modelComponent: ModelComponent<this>): void {
modelComponent.applyToModel();
this._components.push(modelComponent);
}
public as<C extends ModelComponent<Model>>(type: Type<C>): 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<C extends ModelComponent<Model>>(type: Type<C>): C | null {
for (const component of this._components) {
if (component instanceof type) {
return this as unknown as C;
}
}
return null;
}
public has<C extends ModelComponent<Model>>(type: Type<C>): boolean {
return !!this.asOptional(type);
}
public require<C extends ModelComponent<Model>>(type: Type<C>): void {
if (!this.has(type)) {
throw new MissingComponentError(type);
}
}
public updateWithData(data: Pick<this, keyof this> | Record<string, unknown>): 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<void>;
protected async beforeSave?(connection: Connection): Promise<void>;
protected async afterSave?(): Promise<void>;
public async save(connection?: Connection, postHook?: (callback: () => Promise<void>) => void): Promise<void> {
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<boolean> {
// 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();
}
let needsFullUpdate = false;
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;
}
if (this.exists()) {
const query = this._factory.update(data);
for (const indexField of this._factory.getPrimaryKeyFields()) {
query.where(indexField, this[indexField]);
}
await query.execute(connection);
} else {
const query = this._factory.insert(data);
const result = await query.execute(connection);
if (this.hasProperty('id')) this.id = Number(result.other?.insertId);
this._exists = true;
}
return needsFullUpdate;
}
public async delete(): Promise<void> {
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<void[]> {
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 extends keyof this & string>(k: K): NonNullable<this[K]> {
if (!this[k]) throw new Error(k + ' not initialized.');
return this[k] as NonNullable<this[K]>;
}
}
export interface ModelType<M extends Model> extends Type<M> {
table: string;
new(factory: ModelFactory<never>, isNew: boolean): M;
getPrimaryKeyFields(): (keyof M & string)[];
select<M extends Model>(this: ModelType<M>, ...fields: QueryFields): ModelQuery<M>;
}