diff --git a/package.json b/package.json index 7287b95..833ca8e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "wms-core", - "version": "0.9.3", + "version": "0.10.0", "description": "Node web framework", "repository": "git@gitlab.com:ArisuOngaku/wms-core.git", "author": "Alice Gaudon ", diff --git a/src/Utils.ts b/src/Utils.ts index 99bbfdd..f2032ad 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -34,6 +34,4 @@ export function cryptoRandomDictionary(size: number, dictionary: string): string return output.join(''); } -export interface Type extends Function { - new(...args: any[]): T -} \ No newline at end of file +export type Type = { new(...args: any[]): T }; \ No newline at end of file diff --git a/src/auth/AuthGuard.ts b/src/auth/AuthGuard.ts index 23f0a07..51fb21e 100644 --- a/src/auth/AuthGuard.ts +++ b/src/auth/AuthGuard.ts @@ -14,7 +14,7 @@ export default abstract class AuthGuard

{ public async getUserForSession(session: Express.Session): Promise { if (!await this.isAuthenticated(session)) return null; - return await User.getById(`${session.auth_id}`); + return await User.getById(session.auth_id); } public async authenticateOrRegister(session: Express.Session, proof: P, registerCallback?: (connection: Connection, userID: number) => Promise<(() => Promise)[]>): Promise { diff --git a/src/auth/magic_link/MagicLinkAuthController.ts b/src/auth/magic_link/MagicLinkAuthController.ts index 5e00d93..8dfcda4 100644 --- a/src/auth/magic_link/MagicLinkAuthController.ts +++ b/src/auth/magic_link/MagicLinkAuthController.ts @@ -59,7 +59,7 @@ export default abstract class MagicLinkAuthController extends Controller { const email = req.body.email; if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl); - let userEmail = await UserEmail.fromEmail(email); + let userEmail = await UserEmail.select().where('email', email).first(); let isRegistration = false; if (!userEmail) { diff --git a/src/auth/magic_link/MagicLinkController.ts b/src/auth/magic_link/MagicLinkController.ts index 758b8a1..d9c3c6f 100644 --- a/src/auth/magic_link/MagicLinkController.ts +++ b/src/auth/magic_link/MagicLinkController.ts @@ -85,7 +85,7 @@ export default abstract class MagicLinkController extends Controller { let success = true; let err; - const magicLink = await MagicLink.getById(`${id}`); + const magicLink = await MagicLink.getById(id); if (!magicLink) { res.status(404); err = `Couldn't find this magic link. Perhaps it has already expired.`; diff --git a/src/auth/models/MagicLink.ts b/src/auth/models/MagicLink.ts index 291f578..d93a7fd 100644 --- a/src/auth/models/MagicLink.ts +++ b/src/auth/models/MagicLink.ts @@ -5,7 +5,8 @@ import AuthProof from "../AuthProof"; import Validator from "../../db/Validator"; import User from "./User"; import argon2 from "argon2"; -import {WhereTest} from "../../db/Query"; +import {WhereTest} from "../../db/ModelQuery"; +import UserEmail from "./UserEmail"; export default class MagicLink extends Model implements AuthProof { public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise { @@ -17,8 +18,7 @@ export default class MagicLink extends Model implements AuthProof { query = query.where('action_type', actionType, WhereTest.IN); } } - const links = await this.models(query.first()); - return links.length > 0 ? links[0] : null; + return await query.first(); } public static validityPeriod(): number { @@ -42,14 +42,14 @@ export default class MagicLink extends Model implements AuthProof { } } - protected defineProperties(): void { - this.defineProperty('session_id', new Validator().defined().length(32).unique(this)); - this.defineProperty('email', new Validator().defined().regexp(EMAIL_REGEX)); - this.defineProperty('token', new Validator().defined().length(96)); - this.defineProperty('action_type', new Validator().defined().maxLength(64)); - this.defineProperty('original_url', new Validator().defined().maxLength(1745)); - this.defineProperty('generated_at', new Validator()); - this.defineProperty('authorized', new Validator().defined()); + 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()); } public async isOwnedBy(userId: number): Promise { @@ -58,7 +58,10 @@ export default class MagicLink extends Model implements AuthProof { } public async getUser(): Promise { - return await User.fromEmail(await this.getEmail()); + const email = await UserEmail.select() + .where('email', await this.getEmail()) + .first(); + return email ? email.user.get() : null; } public async revoke(): Promise { diff --git a/src/auth/models/User.ts b/src/auth/models/User.ts index 3447890..1e41ea9 100644 --- a/src/auth/models/User.ts +++ b/src/auth/models/User.ts @@ -1,54 +1,36 @@ -import UserEmail from "./UserEmail"; 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"; export default class User extends Model { - public static async fromEmail(email: string): Promise { - const users = await this.models(this.select().where('id', UserEmail.select('user_id').where('email', email).first()).first()); - return users.length > 0 ? users[0] : null; - } - - public static async countAccountsToApprove(): Promise { - if (!this.isApprovalMode()) return 0; - return (await this.select('COUNT(*) as c').where('approved', false).execute()) - .results[0]['c']; - } - - public static async getUsersToApprove(): Promise { - if (!this.isApprovalMode()) return []; - return await this.models(this.select('users.*', 'ue.email as main_email') - .where('approved', false) - .leftJoin('user_emails as ue').on('ue.user_id', 'users.id') - .where('ue.main', '1')); - } - public static isApprovalMode(): boolean { return config.get('approval_mode') && MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable); } - public static async getAdminAccounts(): Promise { - return await this.models(this.select('users.*', '') - .where('is_admin', true) - .leftJoin('user_emails as ue').on('ue.user_id', 'users.id') - .where('ue.main', true)); - } - public name?: string; public approved: boolean = false; public is_admin: boolean = false; public created_at?: Date; public updated_at?: Date; - protected defineProperties(): void { - this.defineProperty('name', new Validator().acceptUndefined().between(3, 64)); - if (User.isApprovalMode()) this.defineProperty('approved', new Validator().defined()); - this.defineProperty('is_admin', new Validator().defined()); + public readonly emails = new ManyModelRelation(this, UserEmail, { + localKey: 'id', + foreignKey: 'user_id' + }); - this.defineProperty('created_at'); - this.defineProperty('updated_at'); + public readonly mainEmail = this.emails.clone().constraint(q => q.where('main', true)); + + 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'); } public isApproved(): boolean { diff --git a/src/auth/models/UserEmail.ts b/src/auth/models/UserEmail.ts index 4717eec..6a6f72f 100644 --- a/src/auth/models/UserEmail.ts +++ b/src/auth/models/UserEmail.ts @@ -1,65 +1,43 @@ import User from "./User"; import {Connection} from "mysql"; -import Model, {EMAIL_REGEX, ModelCache} from "../../db/Model"; +import Model, {EMAIL_REGEX} from "../../db/Model"; import Validator from "../../db/Validator"; import {query} from "../../db/MysqlConnectionManager"; +import {OneModelRelation} from "../../db/ModelRelation"; export default class UserEmail extends Model { - public static async fromEmail(email: any): Promise { - const emails = await this.models(this.select().where('email', email).first()); - return emails.length > 0 ? emails[0] : null; - } - - public static async getMainFromUser(userID: number): Promise { - const emails = await this.models(this.select().where('user_id', userID).where('main', 1).first()); - return emails.length > 0 ? emails[0] : null; - } - - public static async fromUser(userID: number): Promise { - return await this.models(this.select().where('user_id', userID)); - } - public user_id?: number; public readonly email!: string; private main!: boolean; public created_at?: Date; + public readonly user = new OneModelRelation(this, User, { + localKey: 'user_id', + foreignKey: 'id' + }); + private wasSetToMain: boolean = false; constructor(data: any) { super(data); } - protected defineProperties(): void { - this.defineProperty('user_id', new Validator().acceptUndefined().exists(User, 'id')); - this.defineProperty('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this)); - this.defineProperty('main', new Validator().defined()); - this.defineProperty('created_at', new Validator()); + 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()); } 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); - } - } - - protected async afterSave(): Promise { - if (this.wasSetToMain) { this.wasSetToMain = false; - const emails = ModelCache.all(this.table); - if (emails) { - for (const id in emails) { - const otherEmail = emails[id]; - if (otherEmail.id !== this.id && otherEmail.user_id === this.user_id) { - otherEmail.main = false; - } - } - } } } public isMain(): boolean { - return !!this.main; + return this.main; } public setMain() { diff --git a/src/db/Model.ts b/src/db/Model.ts index 5d6965a..48eb31d 100644 --- a/src/db/Model.ts +++ b/src/db/Model.ts @@ -1,72 +1,67 @@ import MysqlConnectionManager, {query} from "./MysqlConnectionManager"; import Validator from "./Validator"; import {Connection} from "mysql"; -import Query from "./Query"; +import ModelQuery, {ModelQueryResult} from "./ModelQuery"; import {Request} from "express"; -import Pagination from "../Pagination"; +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 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 get table(): string { + return this.name + .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase()) + .replace(/^_/, '') + + 's'; } - public static async paginate(request: Request, perPage: number = 20, query?: Query): Promise { + public static async getById(this: ModelClass, id: number): Promise { + return this.select().where('id', id).first(); + } + + 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(); - query = query.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; + return await query.paginate(page, perPage); } - protected static select(...fields: string[]): Query { - return Query.select(this.table, ...fields); + public static select(this: ModelClass, ...fields: string[]): ModelQuery { + return ModelQuery.select(this, ...fields); } - protected static update(data: { [key: string]: any }): Query { - return Query.update(this.table, data); + public static update(this: ModelClass, data: { [key: string]: any }): ModelQuery { + return ModelQuery.update(this, data); } - protected static delete(): Query { - return Query.delete(this.table); + public static delete(this: ModelClass): ModelQuery { + return ModelQuery.delete(this); } - 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 { + public static getPrimaryKey(modelData: any): string { return this.getPrimaryKeyFields().map(f => `${modelData[f]}`).join(','); } - protected static getPrimaryKeyFields(): string[] { + public static getPrimaryKeyFields(): string[] { return ['id']; } @@ -82,7 +77,7 @@ export default abstract class Model { } } - private static getFactory(factory?: ModelFactory): ModelFactory { + public static getFactory(this: ModelClass, factory?: ModelFactory): ModelFactory { if (factory === undefined) { factory = (this).FACTORY; if (factory === undefined) factory = data => new (this)(data); @@ -91,8 +86,8 @@ export default abstract class Model { } + protected readonly modelClass: ModelClass = >this.constructor; protected readonly properties: ModelProperty[] = []; - private readonly relations: { [p: string]: (Model | null) } = {}; public id?: number; private readonly automaticIdProperty: boolean; @@ -101,23 +96,23 @@ export default abstract class Model { public constructor(data: any, automaticIdProperty: boolean = true) { this.automaticIdProperty = automaticIdProperty; if (automaticIdProperty) { - this.defineProperty('id', new Validator()); + this.addProperty('id', new Validator()); } - this.defineProperties(); + this.init(); this.updateWithData(data); } public getPrimaryKey(): string { - return (this.constructor).getPrimaryKey(this); + return this.modelClass.getPrimaryKey(this); } - public getPrimaryKeyFields(): string { - return (this.constructor).getPrimaryKeyFields(); + public getPrimaryKeyFields(): string[] { + return this.modelClass.getPrimaryKeyFields(); } - protected abstract defineProperties(): void; + protected abstract init(): void; - protected defineProperty(name: string, validator?: Validator | RegExp) { + protected addProperty(name: string, validator?: Validator | RegExp) { if (validator === undefined) validator = new Validator(); if (validator instanceof RegExp) { const regexp = validator; @@ -162,11 +157,7 @@ export default abstract class Model { 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(); + this.updateWithData((await this.modelClass.select().where('id', this.id!).limit(1).execute()).results[0]); } await this.afterSave(); @@ -198,7 +189,7 @@ export default abstract class Model { needs_full_update = true; } } - let query = Query.update(this.table, data); + let query = this.modelClass.update(data); for (const indexField of this.getPrimaryKeyFields()) { query = query.where(indexField, this[indexField]); } @@ -222,54 +213,34 @@ export default abstract class Model { 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; + return this.modelClass.table; } public async exists(): Promise { if (!this.id) return false; - let query = Query.select(this.table, '1'); + let query = this.modelClass.select('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; + return typeof (await query.first()) !== 'undefined'; } 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); + let query = this.modelClass.delete(); 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 { @@ -299,44 +270,4 @@ class ModelProperty { } } -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])?)+$/; \ No newline at end of file diff --git a/src/db/Query.ts b/src/db/ModelQuery.ts similarity index 53% rename from src/db/Query.ts rename to src/db/ModelQuery.ts index decc9b1..5b5b4d5 100644 --- a/src/db/Query.ts +++ b/src/db/ModelQuery.ts @@ -1,28 +1,32 @@ import {query, QueryResult} from "./MysqlConnectionManager"; import {Connection} from "mysql"; +import Model, {ModelClass} from "./Model"; +import Pagination from "../Pagination"; +import ModelRelation from "./ModelRelation"; -export default class Query { - public static select(table: string, ...fields: string[]): Query { - return new Query(QueryType.SELECT, table, fields.length > 0 ? fields : ['*']); +export default class ModelQuery { + public static select(modelClass: ModelClass, ...fields: string[]): ModelQuery { + return new ModelQuery(QueryType.SELECT, modelClass, fields.length > 0 ? fields : ['*']); } - public static update(table: string, data: { + public static update(modelClass: ModelClass, data: { [key: string]: any - }): Query { + }): ModelQuery { const fields = []; for (let key in data) { if (data.hasOwnProperty(key)) { fields.push(new UpdateFieldValue(key, data[key], false)); } } - return new Query(QueryType.UPDATE, table, fields); + return new ModelQuery(QueryType.UPDATE, modelClass, fields); } - public static delete(table: string): Query { - return new Query(QueryType.DELETE, table); + public static delete(modelClass: ModelClass): ModelQuery { + return new ModelQuery(QueryType.DELETE, modelClass); } private readonly type: QueryType; + private readonly modelClass: ModelClass; private readonly table: string; private readonly fields: (string | SelectFieldValue | UpdateFieldValue)[]; private _leftJoin?: string; @@ -32,11 +36,13 @@ export default class Query { private _offset?: number; private _sortBy?: string; private _sortDirection?: 'ASC' | 'DESC'; - private _foundRows: boolean = false; + private readonly relations: string[] = []; + private _pivot?: string[]; - private constructor(type: QueryType, table: string, fields?: (string | SelectFieldValue | UpdateFieldValue)[]) { + private constructor(type: QueryType, modelClass: ModelClass, fields?: (string | SelectFieldValue | UpdateFieldValue)[]) { this.type = type; - this.table = table; + this.modelClass = modelClass; + this.table = modelClass.table; this.fields = fields || []; } @@ -50,7 +56,7 @@ export default class Query { return this; } - public where(field: string, value: string | Date | Query | any, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND): this { + public where(field: string, value: string | Date | ModelQuery | any, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND): this { this._where.push(new WhereFieldValue(field, value, false, test, operator)); return this; } @@ -61,25 +67,31 @@ export default class Query { return this; } - public first(): this { - return this.limit(1); - } - public sortBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this { this._sortBy = field; this._sortDirection = direction; return this; } - public withTotalRowCount(): this { - this._foundRows = true; + /** + * @param relation The relation field name to eagerload + */ + public with(relation: string): this { + this.relations.push(relation); + return this; + } + + public pivot(...fields: string[]): this { + this._pivot = fields; return this; } public toString(final: boolean = false): string { let query = ''; - let fields = this.fields?.join(','); + if (this._pivot) this.fields.push(...this._pivot); + + let fields = this.fields.join(','); let join = ''; if (this._leftJoin) { @@ -112,7 +124,7 @@ export default class Query { switch (this.type) { case QueryType.SELECT: - query = `SELECT ${this._foundRows ? 'SQL_CALC_FOUND_ROWS' : ''} ${fields} FROM ${this.table} ${join} ${where} ${orderBy} ${limit}`; + query = `SELECT ${fields} FROM ${this.table} ${join} ${where} ${orderBy} ${limit}`; break; case QueryType.UPDATE: query = `UPDATE ${this.table} SET ${fields} ${where} ${orderBy} ${limit}`; @@ -140,18 +152,80 @@ export default class Query { return variables; } - public isCacheable(): boolean { - return this.type === QueryType.SELECT && this.fields.length === 1 && this.fields[0] === '*'; + public async execute(connection?: Connection): Promise { + return await query(this.build(), this.variables, connection); } - public async execute(connection?: Connection): Promise { - const queryResult = await query(this.build(), this.variables, connection); - if (this._foundRows) { - const foundRows = await query('SELECT FOUND_ROWS() as r', undefined, connection); - queryResult.foundRows = foundRows.results[0].r; + public async get(connection?: Connection): Promise> { + const queryResult = await this.execute(); + const models: ModelQueryResult = []; + + if (this._pivot) models.pivot = []; + + // Eager loading init + const relationMap: { [p: string]: ModelRelation[] } = {}; + for (const relation of this.relations) { + relationMap[relation] = []; } - return queryResult; + + const factory = this.modelClass.getFactory(); + for (const result of queryResult.results) { + const model = factory(result); + models.push(model); + + if (this._pivot) { + const obj: any = {}; + for (const field of this._pivot) { + obj[field] = result[field]; + } + models.pivot!.push(obj); + } + + // Eager loading init map + for (const relation of this.relations) { + relationMap[relation].push(model[relation]); + } + } + + // Eager loading execute + for (const relationName of this.relations) { + const relations = relationMap[relationName]; + const allModels = await relations[0].eagerLoad(relations); + await Promise.all(relations.map(r => r.populate(allModels))); + } + + return models; } + + public async paginate(page: number, perPage: number, connection?: Connection): Promise> { + this.limit(perPage, (page - 1) * perPage); + const result = await this.get(connection); + result.pagination = new Pagination(result, page, perPage, await this.count(true, connection)); + return result; + } + + public async first(): Promise { + const models = await this.limit(1).get(); + return models.length > 0 ? models[0] : null; + } + + public async count(removeLimit: boolean = false, connection?: Connection): Promise { + if (removeLimit) { + this._limit = undefined; + this._offset = undefined; + } + this._sortBy = undefined; + this._sortDirection = undefined; + + this.fields.push('COUNT(*)'); + let queryResult = await this.execute(connection); + return queryResult.results.length; + } +} + +export interface ModelQueryResult extends Array { + pagination?: Pagination; + pivot?: { [p: string]: any }[]; } export enum QueryType { @@ -187,7 +261,7 @@ class FieldValue { } public toString(first: boolean = true): string { - return `${!first ? ',' : ''}${this.field}${this.test}${this.raw || this.value instanceof Query ? this.value : (Array.isArray(this.value) ? '(?)' : '?')}`; + return `${!first ? ',' : ''}${this.field}${this.test}${this.raw || this.value instanceof ModelQuery ? this.value : (Array.isArray(this.value) ? '(?)' : '?')}`; } protected get test(): string { @@ -195,13 +269,13 @@ class FieldValue { } public get variables(): any[] { - return this.value instanceof Query ? this.value.variables : [this.value]; + return this.value instanceof ModelQuery ? this.value.variables : [this.value]; } } class SelectFieldValue extends FieldValue { public toString(first: boolean = true): string { - return `(${this.value instanceof Query ? this.value : '?'}) AS ${this.field}`; + return `(${this.value instanceof ModelQuery ? this.value : '?'}) AS ${this.field}`; } } diff --git a/src/db/ModelRelation.ts b/src/db/ModelRelation.ts new file mode 100644 index 0000000..7cec698 --- /dev/null +++ b/src/db/ModelRelation.ts @@ -0,0 +1,165 @@ +import ModelQuery, {ModelQueryResult, WhereTest} from "./ModelQuery"; +import Model, {ModelClass} from "./Model"; + +export default abstract class ModelRelation { + protected readonly model: S; + protected readonly foreignModelClass: ModelClass; + protected readonly query: ModelQuery; + protected cachedModels?: R; + + protected constructor(model: S, foreignModelClass: ModelClass) { + this.model = model; + this.foreignModelClass = foreignModelClass; + this.query = this.foreignModelClass.select(); + } + + public abstract clone(): ModelRelation; + + public constraint(queryModifier: QueryModifier): this { + queryModifier(this.query); + return this; + } + + public abstract getModelID(): any; + + protected abstract async compute(query: ModelQuery): Promise; + + public async get(): Promise { + if (this.cachedModels === undefined) { + this.cachedModels = await this.compute(this.query); + } + return this.cachedModels; + } + + public abstract async eagerLoad(relations: ModelRelation[]): Promise>; + + public abstract async populate(models: ModelQueryResult): Promise; +} + +export type QueryModifier = (query: ModelQuery) => ModelQuery; + +export class OneModelRelation extends ModelRelation { + protected readonly dbProperties: RelationDatabaseProperties; + + constructor(model: S, foreignModelClass: ModelClass, dbProperties: RelationDatabaseProperties) { + super(model, foreignModelClass); + this.dbProperties = dbProperties; + } + + public clone(): OneModelRelation { + return new OneModelRelation(this.model, this.foreignModelClass, this.dbProperties); + } + + public getModelID() { + return this.model[this.dbProperties.localKey]; + } + + protected async compute(query: ModelQuery): Promise { + this.query.where(this.dbProperties.foreignKey, this.getModelID()); + return await query.first(); + } + + public async eagerLoad(relations: ModelRelation[]): Promise> { + this.query.where( + this.dbProperties.foreignKey, + relations.map(r => r.getModelID()).filter(id => id !== null && id !== undefined), + WhereTest.IN + ); + return await this.query.get(); + } + + public async populate(models: ModelQueryResult): Promise { + this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelID())[0] || null; + } + +} + +export class ManyModelRelation extends ModelRelation { + protected readonly dbProperties: RelationDatabaseProperties; + + constructor(model: S, foreignModelClass: ModelClass, dbProperties: RelationDatabaseProperties) { + super(model, foreignModelClass); + this.dbProperties = dbProperties; + } + + public clone(): ManyModelRelation { + return new ManyModelRelation(this.model, this.foreignModelClass, this.dbProperties); + } + + public getModelID(): any { + return this.model[this.dbProperties.localKey]; + } + + protected async compute(query: ModelQuery): Promise { + this.query.where(this.dbProperties.foreignKey, this.getModelID()); + return await query.get(); + } + + public async eagerLoad(relations: ModelRelation[]): Promise> { + this.query.where( + this.dbProperties.foreignKey, + relations.map(r => r.getModelID()).filter(id => id !== null && id !== undefined), + WhereTest.IN + ); + return await this.query.get(); + } + + public async populate(models: ModelQueryResult): Promise { + this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelID); + } + +} + + +export class ManyThroughModelRelation extends ModelRelation { + protected readonly dbProperties: PivotRelationDatabaseProperties; + + constructor(model: S, foreignModelClass: ModelClass, dbProperties: PivotRelationDatabaseProperties) { + super(model, foreignModelClass); + this.dbProperties = dbProperties; + this.query + .leftJoin(`${this.dbProperties.pivotTable} as pivot`) + .on(`pivot.${this.dbProperties.foreignPivotKey}`, `${this.foreignModelClass.table}.${this.dbProperties.foreignKey}`); + } + + public clone(): ManyThroughModelRelation { + return new ManyThroughModelRelation(this.model, this.foreignModelClass, this.dbProperties); + } + + public getModelID(): any { + return this.model[this.dbProperties.localKey]; + } + + protected async compute(query: ModelQuery): Promise { + this.query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID()); + return await query.get(); + } + + public async eagerLoad(relations: ModelRelation[]): Promise> { + this.query.where( + `pivot.${this.dbProperties.localPivotKey}`, + relations.map(r => r.getModelID()), + WhereTest.IN); + this.query.pivot(`pivot.${this.dbProperties.localPivotKey}`, `pivot.${this.dbProperties.foreignPivotKey}`); + return await this.query.get(); + } + + public async populate(models: ModelQueryResult): Promise { + const ids = models.pivot! + .filter(p => p[this.dbProperties.localPivotKey] === this.getModelID()) + .map(p => p[this.dbProperties.foreignPivotKey]); + this.cachedModels = models.filter(m => ids.indexOf(m[this.dbProperties.foreignKey]) >= 0); + } + +} + +export type RelationDatabaseProperties = { + localKey: string; + foreignKey: string; +}; + +export type PivotRelationDatabaseProperties = RelationDatabaseProperties & { + pivotTable: string; + localPivotKey: string; + foreignPivotKey: string; +}; diff --git a/src/db/Validator.ts b/src/db/Validator.ts index 9f0706e..4888ac9 100644 --- a/src/db/Validator.ts +++ b/src/db/Validator.ts @@ -1,5 +1,5 @@ import Model from "./Model"; -import Query, {WhereTest} from "./Query"; +import ModelQuery, {WhereTest} from "./ModelQuery"; import {Connection} from "mysql"; import {Type} from "../Utils"; @@ -174,11 +174,11 @@ export default class Validator { return this; } - public unique(model: M | Type, foreignKey?: string, querySupplier?: () => Query): Validator { + public unique(model: M | Type, foreignKey?: string, querySupplier?: () => ModelQuery): Validator { this.addStep({ verifyStep: async (val, thingName, c) => { if (!foreignKey) foreignKey = thingName; - let query: Query; + let query: ModelQuery; if (querySupplier) { query = querySupplier().where(foreignKey, val); } else { diff --git a/src/models/Log.ts b/src/models/Log.ts index e702423..a724a71 100644 --- a/src/models/Log.ts +++ b/src/models/Log.ts @@ -11,14 +11,14 @@ export default class Log extends Model { private error_stack?: string; private created_at?: Date; - protected defineProperties(): void { - this.defineProperty('level', new Validator().defined()); - this.defineProperty('message', new Validator().defined().between(0, 65535)); - this.defineProperty('log_id', new Validator().acceptUndefined().length(16)); - this.defineProperty('error_name', new Validator().acceptUndefined().between(0, 128)); - this.defineProperty('error_message', new Validator().acceptUndefined().between(0, 512)); - this.defineProperty('error_stack', new Validator().acceptUndefined().between(0, 65535)); - this.defineProperty('created_at', new Validator()); + 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()); } public getLevel(): LogLevelKeys { diff --git a/test/Model.test.ts b/test/Model.test.ts index 53e4e35..7ba3222 100644 --- a/test/Model.test.ts +++ b/test/Model.test.ts @@ -8,10 +8,10 @@ class FakeDummyModel extends Model { public date?: Date; public date_default?: Date; - protected defineProperties(): void { - this.defineProperty('name', new Validator().acceptUndefined().between(3, 256)); - this.defineProperty('date', new Validator()); - this.defineProperty('date_default', new Validator()); + protected init(): void { + this.addProperty('name', new Validator().acceptUndefined().between(3, 256)); + this.addProperty('date', new Validator()); + this.addProperty('date_default', new Validator()); } } @@ -54,7 +54,7 @@ describe('Model', () => { expect(instance.date?.getTime()).toBeCloseTo(date.getTime(), -4); expect(instance.date_default).toBeDefined(); - instance = await FakeDummyModel.getById('1'); + instance = await FakeDummyModel.getById(1); expect(instance).toBeDefined(); expect(instance!.id).toBe(1); expect(instance!.name).toBe('name1');