From 7f398c1d4e94177cb64da344fe4bec15076d922f Mon Sep 17 00:00:00 2001 From: Alice Gaudon Date: Fri, 24 Jul 2020 12:13:28 +0200 Subject: [PATCH] Make models extendable --- src/Utils.ts | 2 +- .../AddApprovedFieldToUsersTable.ts | 7 + src/auth/migrations/CreateMagicLinksTable.ts | 33 ++- .../CreateUsersAndUserEmailsTable.ts | 50 ++-- src/auth/models/MagicLink.ts | 14 +- src/auth/models/User.ts | 23 +- src/auth/models/UserApprovedComponent.ts | 6 + src/auth/models/UserEmail.ts | 23 +- src/db/Migration.ts | 10 +- src/db/Model.ts | 223 ++++++------------ src/db/ModelComponent.ts | 31 +++ src/db/ModelFactory.ts | 107 +++++++++ src/db/ModelQuery.ts | 26 +- src/db/ModelRelation.ts | 33 +-- src/db/MysqlConnectionManager.ts | 2 + src/migrations/CreateLogsTable.ts | 33 ++- src/migrations/CreateMigrationsTable.ts | 22 +- src/models/Log.ts | 14 +- 18 files changed, 377 insertions(+), 282 deletions(-) create mode 100644 src/auth/models/UserApprovedComponent.ts create mode 100644 src/db/ModelComponent.ts create mode 100644 src/db/ModelFactory.ts diff --git a/src/Utils.ts b/src/Utils.ts index f2032ad..98ec04c 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -34,4 +34,4 @@ export function cryptoRandomDictionary(size: number, dictionary: string): string return output.join(''); } -export type Type = { new(...args: any[]): T }; \ No newline at end of file +export type Type = { new(...args: any[]): T }; diff --git a/src/auth/migrations/AddApprovedFieldToUsersTable.ts b/src/auth/migrations/AddApprovedFieldToUsersTable.ts index c90d78d..57c3fda 100644 --- a/src/auth/migrations/AddApprovedFieldToUsersTable.ts +++ b/src/auth/migrations/AddApprovedFieldToUsersTable.ts @@ -1,5 +1,8 @@ import Migration from "../../db/Migration"; import {Connection} from "mysql"; +import ModelFactory from "../../db/ModelFactory"; +import User from "../models/User"; +import UserApprovedComponent from "../models/UserApprovedComponent"; export default class AddApprovedFieldToUsersTable extends Migration { public async install(connection: Connection): Promise { @@ -9,4 +12,8 @@ export default class AddApprovedFieldToUsersTable extends Migration { public async rollback(connection: Connection): Promise { await this.query('ALTER TABLE users DROP COLUMN approved', connection); } + + public registerModels(): void { + ModelFactory.get(User).addComponent(UserApprovedComponent); + } } \ No newline at end of file diff --git a/src/auth/migrations/CreateMagicLinksTable.ts b/src/auth/migrations/CreateMagicLinksTable.ts index 23aa17f..7cc9cb3 100644 --- a/src/auth/migrations/CreateMagicLinksTable.ts +++ b/src/auth/migrations/CreateMagicLinksTable.ts @@ -1,23 +1,30 @@ import Migration from "../../db/Migration"; import {Connection} from "mysql"; +import ModelFactory from "../../db/ModelFactory"; +import MagicLink from "../models/MagicLink"; export default class CreateMagicLinksTable extends Migration { - async install(connection: Connection): Promise { - await this.query('CREATE TABLE magic_links(' + - 'id INT NOT NULL AUTO_INCREMENT,' + - 'session_id CHAR(32) UNIQUE NOT NULL,' + - 'email VARCHAR(254) NOT NULL,' + - 'token CHAR(96) NOT NULL,' + - 'action_type VARCHAR(64) NOT NULL,' + - 'original_url VARCHAR(1745) NOT NULL,' + - 'generated_at DATETIME NOT NULL,' + - 'authorized BOOLEAN NOT NULL,' + - 'PRIMARY KEY(id)' + - ')', connection); + public async install(connection: Connection): Promise { + await this.query(`CREATE TABLE magic_links + ( + id INT NOT NULL AUTO_INCREMENT, + session_id CHAR(32) UNIQUE NOT NULL, + email VARCHAR(254) NOT NULL, + token CHAR(96) NOT NULL, + action_type VARCHAR(64) NOT NULL, + original_url VARCHAR(1745) NOT NULL, + generated_at DATETIME NOT NULL, + authorized BOOLEAN NOT NULL, + PRIMARY KEY (id) + )`, connection); } - async rollback(connection: Connection): Promise { + public async rollback(connection: Connection): Promise { await this.query('DROP TABLE magic_links', connection); } + public registerModels(): void { + ModelFactory.register(MagicLink); + } + } \ No newline at end of file diff --git a/src/auth/migrations/CreateUsersAndUserEmailsTable.ts b/src/auth/migrations/CreateUsersAndUserEmailsTable.ts index 1ef4d5e..0c9ecf7 100644 --- a/src/auth/migrations/CreateUsersAndUserEmailsTable.ts +++ b/src/auth/migrations/CreateUsersAndUserEmailsTable.ts @@ -1,29 +1,39 @@ import Migration from "../../db/Migration"; import {Connection} from "mysql"; +import ModelFactory from "../../db/ModelFactory"; +import User from "../models/User"; +import UserEmail from "../models/UserEmail"; export default class CreateUsersAndUserEmailsTable extends Migration { - async install(connection: Connection): Promise { - await this.query('CREATE TABLE users(' + - 'id INT NOT NULL AUTO_INCREMENT,' + - 'name VARCHAR(64),' + - 'is_admin BOOLEAN NOT NULL DEFAULT false,' + - 'created_at DATETIME NOT NULL DEFAULT NOW(),' + - 'updated_at DATETIME NOT NULL DEFAULT NOW(),' + - 'PRIMARY KEY(id)' + - ')', connection); - await this.query('CREATE TABLE user_emails(' + - 'id INT NOT NULL AUTO_INCREMENT,' + - 'user_id INT,' + - 'email VARCHAR(254) UNIQUE NOT NULL,' + - 'main BOOLEAN NOT NULL,' + - 'created_at DATETIME NOT NULL DEFAULT NOW(),' + - 'PRIMARY KEY(id),' + - 'FOREIGN KEY user_fk (user_id) REFERENCES users (id) ON DELETE CASCADE' + - ')', connection); + public async install(connection: Connection): Promise { + await this.query(`CREATE TABLE users + ( + id INT NOT NULL AUTO_INCREMENT, + name VARCHAR(64), + is_admin BOOLEAN NOT NULL DEFAULT false, + created_at DATETIME NOT NULL DEFAULT NOW(), + updated_at DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id) + )`, connection); + await this.query(`CREATE TABLE user_emails + ( + id INT NOT NULL AUTO_INCREMENT, + user_id INT NOT NULL, + email VARCHAR(254) UNIQUE NOT NULL, + main BOOLEAN NOT NULL, + created_at DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id), + FOREIGN KEY user_fk (user_id) REFERENCES users (id) ON DELETE CASCADE + )`, connection); } - async rollback(connection: Connection): Promise { + public async rollback(connection: Connection): Promise { await this.query('DROP TABLE user_emails', connection); await this.query('DROP TABLE users', connection); } -} \ No newline at end of file + + public registerModels(): void { + ModelFactory.register(User); + ModelFactory.register(UserEmail); + } +} diff --git a/src/auth/models/MagicLink.ts b/src/auth/models/MagicLink.ts index d93a7fd..36c16b2 100644 --- a/src/auth/models/MagicLink.ts +++ b/src/auth/models/MagicLink.ts @@ -2,7 +2,6 @@ import crypto from "crypto"; import config from "config"; import Model, {EMAIL_REGEX} from "../../db/Model"; import AuthProof from "../AuthProof"; -import Validator from "../../db/Validator"; import User from "./User"; import argon2 from "argon2"; import {WhereTest} from "../../db/ModelQuery"; @@ -43,13 +42,12 @@ export default class MagicLink extends Model implements AuthProof { } protected init(): void { - this.addProperty('session_id', new Validator().defined().length(32).unique(this)); - this.addProperty('email', new Validator().defined().regexp(EMAIL_REGEX)); - this.addProperty('token', new Validator().defined().length(96)); - this.addProperty('action_type', new Validator().defined().maxLength(64)); - this.addProperty('original_url', new Validator().defined().maxLength(1745)); - this.addProperty('generated_at', new Validator()); - this.addProperty('authorized', new Validator().defined()); + this.setValidation('session_id').defined().length(32).unique(this); + this.setValidation('email').defined().regexp(EMAIL_REGEX); + this.setValidation('token').defined().length(96); + this.setValidation('action_type').defined().maxLength(64); + this.setValidation('original_url').defined().maxLength(1745); + this.setValidation('authorized').defined(); } public async isOwnedBy(userId: number): Promise { diff --git a/src/auth/models/User.ts b/src/auth/models/User.ts index 62768b2..01aef5b 100644 --- a/src/auth/models/User.ts +++ b/src/auth/models/User.ts @@ -1,10 +1,11 @@ import Model from "../../db/Model"; -import Validator from "../../db/Validator"; import MysqlConnectionManager from "../../db/MysqlConnectionManager"; import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable"; import config from "config"; import {ManyModelRelation} from "../../db/ModelRelation"; import UserEmail from "./UserEmail"; +import ModelFactory from "../../db/ModelFactory"; +import UserApprovedComponent from "./UserApprovedComponent"; export default class User extends Model { public static isApprovalMode(): boolean { @@ -12,34 +13,32 @@ export default class User extends Model { } public name?: string; - public approved!: boolean; public is_admin!: boolean; public created_at?: Date; public updated_at?: Date; - public readonly emails = new ManyModelRelation(this, UserEmail, { + public readonly emails = new ManyModelRelation(this, ModelFactory.get(UserEmail), { localKey: 'id', foreignKey: 'user_id' }); public readonly mainEmail = this.emails.cloneReduceToOne().constraint(q => q.where('main', true)); - constructor(data: any) { + public constructor(data: any) { super(data); if (this.approved === undefined) this.approved = false; if (this.is_admin === undefined) this.is_admin = false; } protected init(): void { - this.addProperty('name', new Validator().acceptUndefined().between(3, 64)); - if (User.isApprovalMode()) this.addProperty('approved', new Validator().defined()); - this.addProperty('is_admin', new Validator().defined()); - - this.addProperty('created_at'); - this.addProperty('updated_at'); + this.setValidation('name').acceptUndefined().between(3, 64); + if (User.isApprovalMode()) { + this.setValidation('approved').defined(); + } + this.setValidation('is_admin').defined(); } public isApproved(): boolean { - return !User.isApprovalMode() || this.approved!; + return !User.isApprovalMode() || this.as(UserApprovedComponent).approved; } -} \ No newline at end of file +} diff --git a/src/auth/models/UserApprovedComponent.ts b/src/auth/models/UserApprovedComponent.ts new file mode 100644 index 0000000..93d2448 --- /dev/null +++ b/src/auth/models/UserApprovedComponent.ts @@ -0,0 +1,6 @@ +import ModelComponent from "../../db/ModelComponent"; +import User from "./User"; + +export default class UserApprovedComponent extends ModelComponent { + public approved!: boolean; +} \ No newline at end of file diff --git a/src/auth/models/UserEmail.ts b/src/auth/models/UserEmail.ts index 6a6f72f..4295b4b 100644 --- a/src/auth/models/UserEmail.ts +++ b/src/auth/models/UserEmail.ts @@ -1,9 +1,9 @@ import User from "./User"; import {Connection} from "mysql"; import Model, {EMAIL_REGEX} from "../../db/Model"; -import Validator from "../../db/Validator"; import {query} from "../../db/MysqlConnectionManager"; import {OneModelRelation} from "../../db/ModelRelation"; +import ModelFactory from "../../db/ModelFactory"; export default class UserEmail extends Model { public user_id?: number; @@ -11,28 +11,27 @@ export default class UserEmail extends Model { private main!: boolean; public created_at?: Date; - public readonly user = new OneModelRelation(this, User, { + public readonly user = new OneModelRelation(this, ModelFactory.get(User), { localKey: 'user_id', foreignKey: 'id' }); - private wasSetToMain: boolean = false; + private _wasSetToMain: boolean = false; constructor(data: any) { super(data); } protected init(): void { - this.addProperty('user_id', new Validator().acceptUndefined().exists(User, 'id')); - this.addProperty('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this)); - this.addProperty('main', new Validator().defined()); - this.addProperty('created_at', new Validator()); + this.setValidation('user_id').acceptUndefined().exists(User, 'id'); + this.setValidation('email').defined().regexp(EMAIL_REGEX).unique(this); + this.setValidation('main').defined(); } - async beforeSave(exists: boolean, connection: Connection) { - if (this.wasSetToMain) { + protected async beforeSave(exists: boolean, connection: Connection) { + if (this._wasSetToMain) { await query(`UPDATE ${this.table} SET main=false WHERE user_id=${this.user_id}`, null, connection); - this.wasSetToMain = false; + this._wasSetToMain = false; } } @@ -43,7 +42,7 @@ export default class UserEmail extends Model { public setMain() { if (!this.isMain()) { this.main = true; - this.wasSetToMain = true; + this._wasSetToMain = true; } } -} \ No newline at end of file +} diff --git a/src/db/Migration.ts b/src/db/Migration.ts index a62ddb3..b52b725 100644 --- a/src/db/Migration.ts +++ b/src/db/Migration.ts @@ -4,17 +4,19 @@ import MysqlConnectionManager from "./MysqlConnectionManager"; export default abstract class Migration { public readonly version: number; - constructor(version: number) { + public constructor(version: number) { this.version = version; } - async shouldRun(currentVersion: number): Promise { + public async shouldRun(currentVersion: number): Promise { return this.version > currentVersion; } - abstract async install(connection: Connection): Promise; + public abstract async install(connection: Connection): Promise; - abstract async rollback(connection: Connection): Promise; + public abstract async rollback(connection: Connection): Promise; + + public abstract registerModels(): void; protected async query(queryString: string, connection: Connection): Promise { await MysqlConnectionManager.query(queryString, undefined, connection); diff --git a/src/db/Model.ts b/src/db/Model.ts index ec7f3c6..7d51481 100644 --- a/src/db/Model.ts +++ b/src/db/Model.ts @@ -1,138 +1,75 @@ import MysqlConnectionManager, {query} from "./MysqlConnectionManager"; import Validator from "./Validator"; import {Connection} from "mysql"; +import ModelComponent from "./ModelComponent"; +import {Type} from "../Utils"; +import ModelFactory from "./ModelFactory"; +import ModelRelation from "./ModelRelation"; import ModelQuery, {ModelQueryResult} from "./ModelQuery"; import {Request} from "express"; -import {Type} from "../Utils"; - -export interface ModelClass extends Type { - getFactory(factory?: ModelFactory): ModelFactory; - - table: string; - - getPrimaryKey(modelData: any): string; - - getPrimaryKeyFields(): string[]; - - select(...fields: string[]): ModelQuery; - - update(data: { [key: string]: any }): ModelQuery; - - delete(): ModelQuery; -} export default abstract class Model { - public static get table(): string { - return this.name - .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) - .replace(/^_/, '') - + 's'; + public static select(this: Type, ...fields: string[]): ModelQuery { + return ModelFactory.get(this).select(...fields); } - public static async getById(this: ModelClass, id: number): Promise { - return this.select().where('id', id).first(); + public static update(this: Type, data: { [key: string]: any }): ModelQuery { + return ModelFactory.get(this).update(data); } - public static async paginate(this: ModelClass, request: Request, perPage: number = 20, query?: ModelQuery): Promise> { - let page = request.params.page ? parseInt(request.params.page) : 1; - if (!query) query = this.select(); - 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'); - } - return await query.paginate(page, perPage); + public static delete(this: Type): ModelQuery { + return ModelFactory.get(this).delete(); } - public static select(this: ModelClass, ...fields: string[]): ModelQuery { - return ModelQuery.select(this, ...fields); + public static async getById(this: Type, ...id: any): Promise { + return ModelFactory.get(this).getById(id); } - public static update(this: ModelClass, data: { [key: string]: any }): ModelQuery { - return ModelQuery.update(this, data); + public static async paginate(this: Type, request: Request, perPage: number = 20, query?: ModelQuery): Promise> { + return ModelFactory.get(this).paginate(request, perPage, query); } - public static delete(this: ModelClass): ModelQuery { - return ModelQuery.delete(this); - } - - public static getPrimaryKey(modelData: any): string { - return this.getPrimaryKeyFields().map(f => `${modelData[f]}`).join(','); - } - - public 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); - } - } - - public static getFactory(this: ModelClass, factory?: ModelFactory): ModelFactory { - if (factory === undefined) { - factory = (this).FACTORY; - if (factory === undefined) factory = data => new (this)(data); - } - return factory; - } - - - protected readonly modelClass: ModelClass = >this.constructor; - protected readonly properties: ModelProperty[] = []; - public id?: number; - private readonly automaticIdProperty: boolean; + protected readonly _factory!: ModelFactory; + private readonly _components: ModelComponent[] = []; + private readonly _validators: { [key: string]: Validator } = {}; [key: string]: any; - public constructor(data: any, automaticIdProperty: boolean = true) { - this.automaticIdProperty = automaticIdProperty; - if (automaticIdProperty) { - this.addProperty('id', new Validator()); - } + public constructor(data: any) { this.init(); this.updateWithData(data); } - public getPrimaryKey(): string { - return this.modelClass.getPrimaryKey(this); - } - - public getPrimaryKeyFields(): string[] { - return this.modelClass.getPrimaryKeyFields(); + public setFactory(factory: ModelFactory) { + (this as any)._factory = factory; } protected abstract init(): void; - protected addProperty(name: string, validator?: Validator | RegExp) { - if (validator === undefined) validator = new Validator(); - if (validator instanceof RegExp) { - const regexp = validator; - validator = new Validator().regexp(regexp); - } + protected setValidation(propertyName: keyof this): Validator { + const validator = new Validator(); + this._validators[propertyName as string] = validator; + return validator; + } - const prop = new ModelProperty(name, validator); - this.properties.push(prop); - Object.defineProperty(this, name, { - get: () => prop.value, - set: (value: T) => prop.value = value, - }); + public addComponent(modelComponent: ModelComponent): void { + modelComponent.init(); + this._components.push(modelComponent); + } + + public as>(type: Type): T { + for (const component of this._components) { + if (component instanceof type) { + return this; + } + } + throw new Error(`Component ${type.name} was not initialized for this ${this.constructor.name}.`); } 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]; + for (const property of this._properties) { + if (data[property] !== undefined) { + this[property] = data[property]; } } } @@ -157,7 +94,7 @@ export default abstract class Model { const callback = async () => { if (needs_full_update) { - this.updateWithData((await this.modelClass.select().where('id', this.id!).limit(1).execute()).results[0]); + this.updateWithData((await this._factory.select().where('id', this.id!).limit(1).execute()).results[0]); } await this.afterSave(); @@ -177,50 +114,46 @@ export default abstract class Model { this.updated_at = new Date(); } - const props = []; + const properties = []; const values = []; if (exists) { const data: any = {}; - for (const prop of this.properties) { - if (prop.value !== undefined) { - data[prop.name] = prop.value; + for (const property of this._properties) { + const value = this[property]; + if (value !== undefined) { + data[property] = value; } else { needs_full_update = true; } } - let query = this.modelClass.update(data); + let query = this._factory.update(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); + for (const property of this._properties) { + const value = this[property]; + if (value !== undefined) { + properties.push(property); props_holders.push('?'); - values.push(prop.value); + values.push(value); } else { needs_full_update = true; } } - const result = await query(`INSERT INTO ${this.table} (${props.join(', ')}) VALUES(${props_holders.join(', ')})`, values, connection); + const result = await query(`INSERT INTO ${this.table} (${properties.join(', ')}) VALUES(${props_holders.join(', ')})`, values, connection); - if (this.automaticIdProperty) this.id = result.other.insertId; + if (this.hasOwnProperty('id')) this.id = result.other.insertId; } return needs_full_update; } - public get table(): string { - return this.modelClass.table; - } - public async exists(): Promise { - if (!this.id) return false; - - let query = this.modelClass.select('1'); + let query = this._factory.select('1'); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } @@ -230,44 +163,28 @@ export default abstract class Model { public async delete(): Promise { if (!(await this.exists())) throw new Error('This model instance doesn\'t exist in DB.'); - let query = this.modelClass.delete(); + let query = this._factory.delete(); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } await query.execute(); - 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))); + return await Promise.all(this._properties.map( + prop => this._validators[prop]?.execute(prop, this[prop], onlyFormat, connection) + )); + } + + public get table(): string { + return this._factory.table; + } + + private get _properties(): string[] { + return Object.getOwnPropertyNames(this).filter(p => { + return !p.startsWith('_') && !(this[p] instanceof ModelRelation); + }); } } -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 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])?)+$/; \ No newline at end of file +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])?)+$/; diff --git a/src/db/ModelComponent.ts b/src/db/ModelComponent.ts new file mode 100644 index 0000000..f1f675d --- /dev/null +++ b/src/db/ModelComponent.ts @@ -0,0 +1,31 @@ +import Model from "./Model"; +import Validator from "./Validator"; + +export default abstract class ModelComponent { + protected readonly _model: T; + private readonly _validators: { [key: string]: Validator } = {}; + + [key: string]: any; + + public constructor(model: T) { + this._model = model; + } + + public init(): void { + for (const property of this._properties) { + if (!property.startsWith('_')) { + (this._model as Model)[property] = this[property]; + } + } + } + + protected setValidation(propertyName: keyof this): Validator { + const validator = new Validator(); + this._validators[propertyName as string] = validator; + return validator; + } + + private get _properties(): string[] { + return Object.getOwnPropertyNames(this).filter(p => !p.startsWith('_')); + } +} diff --git a/src/db/ModelFactory.ts b/src/db/ModelFactory.ts new file mode 100644 index 0000000..1078931 --- /dev/null +++ b/src/db/ModelFactory.ts @@ -0,0 +1,107 @@ +import ModelComponent from "./ModelComponent"; +import Model from "./Model"; +import ModelQuery, {ModelQueryResult} from "./ModelQuery"; +import {Request} from "express"; +import {Type} from "../Utils"; + +export default class ModelFactory { + private static readonly factories: { [modelType: string]: ModelFactory } = {}; + + public static register(modelType: Type) { + if (this.factories[modelType.name]) throw new Error(`Factory for type ${modelType.name} already defined.`); + this.factories[modelType.name] = new ModelFactory(modelType); + } + + public static get(modelType: Type): ModelFactory { + const factory = this.factories[modelType.name]; + if (!factory) throw new Error(`No factory registered for ${modelType.name}.`); + return factory; + } + + private readonly modelType: Type; + private readonly components: ModelComponentFactory[] = []; + + protected constructor(modelType: Type) { + this.modelType = modelType; + } + + + public addComponent(modelComponentFactory: ModelComponentFactory) { + this.components.push(modelComponentFactory); + } + + public make(data: any): T { + const model = new this.modelType(data); + for (const component of this.components) { + model.addComponent(new component(model)); + } + model.setFactory(this); + return model; + } + + public get table(): string { + return this.constructor.name + .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) + .replace(/^_/, '') + + 's'; + } + + public select(...fields: string[]): ModelQuery { + return ModelQuery.select(this, ...fields); + } + + public update(data: { [key: string]: any }): ModelQuery { + return ModelQuery.update(this, data); + } + + public delete(): ModelQuery { + return ModelQuery.delete(this); + } + + public getPrimaryKeyFields(): string[] { + return ['id']; + } + + public getPrimaryKey(modelData: any): any[] { + return this.getPrimaryKeyFields().map(f => modelData[f]); + } + + public getPrimaryKeyString(modelData: any): string { + return this.getPrimaryKey(modelData).join(','); + } + + public async getById(...id: any): Promise { + let query = this.select(); + const primaryKeyFields = this.getPrimaryKeyFields(); + for (let i = 0; i < primaryKeyFields.length; i++) { + query = query.where(primaryKeyFields[i], id[i]); + } + return query.first(); + } + + public async paginate(request: Request, perPage: number = 20, query?: ModelQuery): Promise> { + let page = request.params.page ? parseInt(request.params.page) : 1; + if (!query) query = this.select(); + 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'); + } + return await query.paginate(page, perPage); + } + + public 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); + } + } +} + +export type ModelComponentFactory = new (model: T) => ModelComponent; diff --git a/src/db/ModelQuery.ts b/src/db/ModelQuery.ts index 1f3f8e5..57bdbe7 100644 --- a/src/db/ModelQuery.ts +++ b/src/db/ModelQuery.ts @@ -1,15 +1,16 @@ import {query, QueryResult} from "./MysqlConnectionManager"; import {Connection} from "mysql"; -import Model, {ModelClass} from "./Model"; +import Model from "./Model"; import Pagination from "../Pagination"; import ModelRelation from "./ModelRelation"; +import ModelFactory from "./ModelFactory"; export default class ModelQuery { - public static select(modelClass: ModelClass, ...fields: string[]): ModelQuery { - return new ModelQuery(QueryType.SELECT, modelClass, fields.length > 0 ? fields : ['*']); + public static select(factory: ModelFactory, ...fields: string[]): ModelQuery { + return new ModelQuery(QueryType.SELECT, factory, fields.length > 0 ? fields : ['*']); } - public static update(modelClass: ModelClass, data: { + public static update(factory: ModelFactory, data: { [key: string]: any }): ModelQuery { const fields = []; @@ -18,15 +19,15 @@ export default class ModelQuery { fields.push(new UpdateFieldValue(key, data[key], false)); } } - return new ModelQuery(QueryType.UPDATE, modelClass, fields); + return new ModelQuery(QueryType.UPDATE, factory, fields); } - public static delete(modelClass: ModelClass): ModelQuery { - return new ModelQuery(QueryType.DELETE, modelClass); + public static delete(factory: ModelFactory): ModelQuery { + return new ModelQuery(QueryType.DELETE, factory); } private readonly type: QueryType; - private readonly modelClass: ModelClass; + private readonly factory: ModelFactory; private readonly table: string; private readonly fields: (string | SelectFieldValue | UpdateFieldValue)[]; private _leftJoin?: string; @@ -39,10 +40,10 @@ export default class ModelQuery { private readonly relations: string[] = []; private _pivot?: string[]; - private constructor(type: QueryType, modelClass: ModelClass, fields?: (string | SelectFieldValue | UpdateFieldValue)[]) { + private constructor(type: QueryType, factory: ModelFactory, fields?: (string | SelectFieldValue | UpdateFieldValue)[]) { this.type = type; - this.modelClass = modelClass; - this.table = modelClass.table; + this.factory = factory; + this.table = factory.table; this.fields = fields || []; } @@ -168,9 +169,8 @@ export default class ModelQuery { relationMap[relation] = []; } - const factory = this.modelClass.getFactory(); for (const result of queryResult.results) { - const model = factory(result); + const model = this.factory.make(result); models.push(model); if (this._pivot) { diff --git a/src/db/ModelRelation.ts b/src/db/ModelRelation.ts index eecb5f7..1bdfd07 100644 --- a/src/db/ModelRelation.ts +++ b/src/db/ModelRelation.ts @@ -1,16 +1,17 @@ import ModelQuery, {ModelQueryResult, WhereTest} from "./ModelQuery"; -import Model, {ModelClass} from "./Model"; +import Model from "./Model"; +import ModelFactory from "./ModelFactory"; export default abstract class ModelRelation { protected readonly model: S; - protected readonly foreignModelClass: ModelClass; + protected readonly foreignFactory: ModelFactory; protected readonly query: ModelQuery; protected cachedModels?: R; - protected constructor(model: S, foreignModelClass: ModelClass) { + protected constructor(model: S, foreignFactory: ModelFactory) { this.model = model; - this.foreignModelClass = foreignModelClass; - this.query = this.foreignModelClass.select(); + this.foreignFactory = foreignFactory; + this.query = this.foreignFactory.select(); } public abstract clone(): ModelRelation; @@ -46,13 +47,13 @@ export type QueryModifier = (query: ModelQuery) => ModelQuer export class OneModelRelation extends ModelRelation { protected readonly dbProperties: RelationDatabaseProperties; - constructor(model: S, foreignModelClass: ModelClass, dbProperties: RelationDatabaseProperties) { - super(model, foreignModelClass); + constructor(model: S, foreignFactory: ModelFactory, dbProperties: RelationDatabaseProperties) { + super(model, foreignFactory); this.dbProperties = dbProperties; } public clone(): OneModelRelation { - return new OneModelRelation(this.model, this.foreignModelClass, this.dbProperties); + return new OneModelRelation(this.model, this.foreignFactory, this.dbProperties); } public getModelID() { @@ -82,17 +83,17 @@ export class OneModelRelation extends ModelRel export class ManyModelRelation extends ModelRelation { protected readonly dbProperties: RelationDatabaseProperties; - constructor(model: S, foreignModelClass: ModelClass, dbProperties: RelationDatabaseProperties) { - super(model, foreignModelClass); + constructor(model: S, foreignFactory: ModelFactory, dbProperties: RelationDatabaseProperties) { + super(model, foreignFactory); this.dbProperties = dbProperties; } public clone(): ManyModelRelation { - return new ManyModelRelation(this.model, this.foreignModelClass, this.dbProperties); + return new ManyModelRelation(this.model, this.foreignFactory, this.dbProperties); } public cloneReduceToOne(): OneModelRelation { - return new OneModelRelation(this.model, this.foreignModelClass, this.dbProperties); + return new OneModelRelation(this.model, this.foreignFactory, this.dbProperties); } public getModelID(): any { @@ -123,16 +124,16 @@ export class ManyModelRelation extends ModelRe export class ManyThroughModelRelation extends ModelRelation { protected readonly dbProperties: PivotRelationDatabaseProperties; - constructor(model: S, foreignModelClass: ModelClass, dbProperties: PivotRelationDatabaseProperties) { - super(model, foreignModelClass); + constructor(model: S, foreignFactory: ModelFactory, dbProperties: PivotRelationDatabaseProperties) { + super(model, foreignFactory); this.dbProperties = dbProperties; this.query .leftJoin(`${this.dbProperties.pivotTable} as pivot`) - .on(`pivot.${this.dbProperties.foreignPivotKey}`, `${this.foreignModelClass.table}.${this.dbProperties.foreignKey}`); + .on(`pivot.${this.dbProperties.foreignPivotKey}`, `${this.foreignFactory.table}.${this.dbProperties.foreignKey}`); } public clone(): ManyThroughModelRelation { - return new ManyThroughModelRelation(this.model, this.foreignModelClass, this.dbProperties); + return new ManyThroughModelRelation(this.model, this.foreignFactory, this.dbProperties); } public getModelID(): any { diff --git a/src/db/MysqlConnectionManager.ts b/src/db/MysqlConnectionManager.ts index 7ca30ec..6594e53 100644 --- a/src/db/MysqlConnectionManager.ts +++ b/src/db/MysqlConnectionManager.ts @@ -192,6 +192,8 @@ export default class MysqlConnectionManager { ]); }); } + + migration.registerModels(); } } diff --git a/src/migrations/CreateLogsTable.ts b/src/migrations/CreateLogsTable.ts index d18bbbb..b25c2fc 100644 --- a/src/migrations/CreateLogsTable.ts +++ b/src/migrations/CreateLogsTable.ts @@ -1,25 +1,32 @@ import Migration from "../db/Migration"; import {Connection} from "mysql"; +import ModelFactory from "../db/ModelFactory"; +import Log from "../models/Log"; /** * Must be the first migration */ export default class CreateLogsTable extends Migration { - async install(connection: Connection): Promise { - await this.query('CREATE TABLE logs(' + - 'id INT NOT NULL AUTO_INCREMENT,' + - 'level TINYINT UNSIGNED NOT NULL,' + - 'message TEXT NOT NULL,' + - 'log_id BINARY(16),' + - 'error_name VARCHAR(128),' + - 'error_message VARCHAR(512),' + - 'error_stack TEXT,' + - 'created_at DATETIME NOT NULL DEFAULT NOW(),' + - 'PRIMARY KEY (id)' + - ')', connection); + public async install(connection: Connection): Promise { + await this.query(`CREATE TABLE logs + ( + id INT NOT NULL AUTO_INCREMENT, + level TINYINT UNSIGNED NOT NULL, + message TEXT NOT NULL, + log_id BINARY(16), + error_name VARCHAR(128), + error_message VARCHAR(512), + error_stack TEXT, + created_at DATETIME NOT NULL DEFAULT NOW(), + PRIMARY KEY (id) + )`, connection); } - async rollback(connection: Connection): Promise { + public async rollback(connection: Connection): Promise { await this.query('DROP TABLE logs', connection); } + + public registerModels(): void { + ModelFactory.register(Log); + } } \ No newline at end of file diff --git a/src/migrations/CreateMigrationsTable.ts b/src/migrations/CreateMigrationsTable.ts index c55fbb2..019a8c9 100644 --- a/src/migrations/CreateMigrationsTable.ts +++ b/src/migrations/CreateMigrationsTable.ts @@ -6,7 +6,7 @@ import {query} from "../db/MysqlConnectionManager"; * Must be the first migration */ export default class CreateMigrationsTable extends Migration { - async shouldRun(currentVersion: number): Promise { + public async shouldRun(currentVersion: number): Promise { try { await query('SELECT 1 FROM migrations LIMIT 1'); } catch (e) { @@ -18,16 +18,20 @@ export default class CreateMigrationsTable extends Migration { return await super.shouldRun(currentVersion); } - async install(connection: Connection): Promise { - await this.query('CREATE TABLE migrations(' + - 'id INT NOT NULL,' + - 'name VARCHAR(64) NOT NULL,' + - 'migration_date DATE,' + - 'PRIMARY KEY (id)' + - ')', connection); + public async install(connection: Connection): Promise { + await this.query(`CREATE TABLE migrations + ( + id INT NOT NULL, + name VARCHAR(64) NOT NULL, + migration_date DATE, + PRIMARY KEY (id) + )`, connection); } - async rollback(connection: Connection): Promise { + public async rollback(connection: Connection): Promise { await this.query('DROP TABLE migrations', connection); } + + public registerModels(): void { + } } \ No newline at end of file diff --git a/src/models/Log.ts b/src/models/Log.ts index a724a71..e4a38d4 100644 --- a/src/models/Log.ts +++ b/src/models/Log.ts @@ -1,6 +1,5 @@ import Model from "../db/Model"; import {LogLevel, LogLevelKeys} from "../Logger"; -import Validator from "../db/Validator"; export default class Log extends Model { private level?: number; @@ -12,13 +11,12 @@ export default class Log extends Model { private created_at?: Date; protected init(): void { - this.addProperty('level', new Validator().defined()); - this.addProperty('message', new Validator().defined().between(0, 65535)); - this.addProperty('log_id', new Validator().acceptUndefined().length(16)); - this.addProperty('error_name', new Validator().acceptUndefined().between(0, 128)); - this.addProperty('error_message', new Validator().acceptUndefined().between(0, 512)); - this.addProperty('error_stack', new Validator().acceptUndefined().between(0, 65535)); - this.addProperty('created_at', new Validator()); + this.setValidation('level').defined(); + this.setValidation('message').defined().between(0, 65535); + this.setValidation('log_id').acceptUndefined().length(16); + this.setValidation('error_name').acceptUndefined().between(0, 128); + this.setValidation('error_message').acceptUndefined().between(0, 512); + this.setValidation('error_stack').acceptUndefined().between(0, 65535); } public getLevel(): LogLevelKeys {