Revamp model system

- Add model relations
- Get rid of SQL_CALC_FOUND_ROWS (deprecated)
- Eager loading
This commit is contained in:
Alice Gaudon 2020-06-27 14:36:50 +02:00
parent c8157b7bb0
commit ec5b2b9aa0
14 changed files with 386 additions and 255 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "wms-core", "name": "wms-core",
"version": "0.9.3", "version": "0.10.0",
"description": "Node web framework", "description": "Node web framework",
"repository": "git@gitlab.com:ArisuOngaku/wms-core.git", "repository": "git@gitlab.com:ArisuOngaku/wms-core.git",
"author": "Alice Gaudon <alice@gaudon.pro>", "author": "Alice Gaudon <alice@gaudon.pro>",

View File

@ -34,6 +34,4 @@ export function cryptoRandomDictionary(size: number, dictionary: string): string
return output.join(''); return output.join('');
} }
export interface Type<T> extends Function { export type Type<T> = { new(...args: any[]): T };
new(...args: any[]): T
}

View File

@ -14,7 +14,7 @@ export default abstract class AuthGuard<P extends AuthProof> {
public async getUserForSession(session: Express.Session): Promise<User | null> { public async getUserForSession(session: Express.Session): Promise<User | null> {
if (!await this.isAuthenticated(session)) return null; if (!await this.isAuthenticated(session)) return null;
return await User.getById<User>(`${session.auth_id}`); return await User.getById<User>(session.auth_id);
} }
public async authenticateOrRegister(session: Express.Session, proof: P, registerCallback?: (connection: Connection, userID: number) => Promise<(() => Promise<void>)[]>): Promise<void> { public async authenticateOrRegister(session: Express.Session, proof: P, registerCallback?: (connection: Connection, userID: number) => Promise<(() => Promise<void>)[]>): Promise<void> {

View File

@ -59,7 +59,7 @@ export default abstract class MagicLinkAuthController extends Controller {
const email = req.body.email; const email = req.body.email;
if (!email) throw new BadRequestError('Email not specified.', 'Please try again.', req.originalUrl); 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; let isRegistration = false;
if (!userEmail) { if (!userEmail) {

View File

@ -85,7 +85,7 @@ export default abstract class MagicLinkController extends Controller {
let success = true; let success = true;
let err; let err;
const magicLink = await MagicLink.getById<MagicLink>(`${id}`); const magicLink = await MagicLink.getById<MagicLink>(id);
if (!magicLink) { if (!magicLink) {
res.status(404); res.status(404);
err = `Couldn't find this magic link. Perhaps it has already expired.`; err = `Couldn't find this magic link. Perhaps it has already expired.`;

View File

@ -5,7 +5,8 @@ import AuthProof from "../AuthProof";
import Validator from "../../db/Validator"; import Validator from "../../db/Validator";
import User from "./User"; import User from "./User";
import argon2 from "argon2"; 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 { export default class MagicLink extends Model implements AuthProof {
public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> { public static async bySessionID(sessionID: string, actionType?: string | string[]): Promise<MagicLink | null> {
@ -17,8 +18,7 @@ export default class MagicLink extends Model implements AuthProof {
query = query.where('action_type', actionType, WhereTest.IN); query = query.where('action_type', actionType, WhereTest.IN);
} }
} }
const links = await this.models<MagicLink>(query.first()); return await query.first();
return links.length > 0 ? links[0] : null;
} }
public static validityPeriod(): number { public static validityPeriod(): number {
@ -42,14 +42,14 @@ export default class MagicLink extends Model implements AuthProof {
} }
} }
protected defineProperties(): void { protected init(): void {
this.defineProperty<string>('session_id', new Validator().defined().length(32).unique(this)); this.addProperty<string>('session_id', new Validator().defined().length(32).unique(this));
this.defineProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX)); this.addProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX));
this.defineProperty<string>('token', new Validator().defined().length(96)); this.addProperty<string>('token', new Validator().defined().length(96));
this.defineProperty<string>('action_type', new Validator().defined().maxLength(64)); this.addProperty<string>('action_type', new Validator().defined().maxLength(64));
this.defineProperty<string>('original_url', new Validator().defined().maxLength(1745)); this.addProperty<string>('original_url', new Validator().defined().maxLength(1745));
this.defineProperty<Date>('generated_at', new Validator()); this.addProperty<Date>('generated_at', new Validator());
this.defineProperty<boolean>('authorized', new Validator().defined()); this.addProperty<boolean>('authorized', new Validator().defined());
} }
public async isOwnedBy(userId: number): Promise<boolean> { public async isOwnedBy(userId: number): Promise<boolean> {
@ -58,7 +58,10 @@ export default class MagicLink extends Model implements AuthProof {
} }
public async getUser(): Promise<User | null> { public async getUser(): Promise<User | null> {
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<void> { public async revoke(): Promise<void> {

View File

@ -1,54 +1,36 @@
import UserEmail from "./UserEmail";
import Model from "../../db/Model"; import Model from "../../db/Model";
import Validator from "../../db/Validator"; import Validator from "../../db/Validator";
import MysqlConnectionManager from "../../db/MysqlConnectionManager"; import MysqlConnectionManager from "../../db/MysqlConnectionManager";
import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable"; import AddApprovedFieldToUsersTable from "../migrations/AddApprovedFieldToUsersTable";
import config from "config"; import config from "config";
import {ManyModelRelation} from "../../db/ModelRelation";
import UserEmail from "./UserEmail";
export default class User extends Model { export default class User extends Model {
public static async fromEmail(email: string): Promise<User | null> {
const users = await this.models<User>(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<number> {
if (!this.isApprovalMode()) return 0;
return (await this.select('COUNT(*) as c').where('approved', false).execute())
.results[0]['c'];
}
public static async getUsersToApprove(): Promise<User[]> {
if (!this.isApprovalMode()) return [];
return await this.models<User>(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 { public static isApprovalMode(): boolean {
return config.get<boolean>('approval_mode') && MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable); return config.get<boolean>('approval_mode') && MysqlConnectionManager.hasMigration(AddApprovedFieldToUsersTable);
} }
public static async getAdminAccounts(): Promise<User[]> {
return await this.models<User>(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 name?: string;
public approved: boolean = false; public approved: boolean = false;
public is_admin: boolean = false; public is_admin: boolean = false;
public created_at?: Date; public created_at?: Date;
public updated_at?: Date; public updated_at?: Date;
protected defineProperties(): void { public readonly emails = new ManyModelRelation<this, UserEmail>(this, UserEmail, {
this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 64)); localKey: 'id',
if (User.isApprovalMode()) this.defineProperty<boolean>('approved', new Validator().defined()); foreignKey: 'user_id'
this.defineProperty<boolean>('is_admin', new Validator().defined()); });
this.defineProperty<Date>('created_at'); public readonly mainEmail = this.emails.clone().constraint(q => q.where('main', true));
this.defineProperty<Date>('updated_at');
protected init(): void {
this.addProperty<string>('name', new Validator().acceptUndefined().between(3, 64));
if (User.isApprovalMode()) this.addProperty<boolean>('approved', new Validator().defined());
this.addProperty<boolean>('is_admin', new Validator().defined());
this.addProperty<Date>('created_at');
this.addProperty<Date>('updated_at');
} }
public isApproved(): boolean { public isApproved(): boolean {

View File

@ -1,65 +1,43 @@
import User from "./User"; import User from "./User";
import {Connection} from "mysql"; 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 Validator from "../../db/Validator";
import {query} from "../../db/MysqlConnectionManager"; import {query} from "../../db/MysqlConnectionManager";
import {OneModelRelation} from "../../db/ModelRelation";
export default class UserEmail extends Model { export default class UserEmail extends Model {
public static async fromEmail(email: any): Promise<UserEmail | null> {
const emails = await this.models<UserEmail>(this.select().where('email', email).first());
return emails.length > 0 ? emails[0] : null;
}
public static async getMainFromUser(userID: number): Promise<UserEmail | null> {
const emails = await this.models<UserEmail>(this.select().where('user_id', userID).where('main', 1).first());
return emails.length > 0 ? emails[0] : null;
}
public static async fromUser(userID: number): Promise<UserEmail[] | null> {
return await this.models<UserEmail>(this.select().where('user_id', userID));
}
public user_id?: number; public user_id?: number;
public readonly email!: string; public readonly email!: string;
private main!: boolean; private main!: boolean;
public created_at?: Date; public created_at?: Date;
public readonly user = new OneModelRelation<this, User>(this, User, {
localKey: 'user_id',
foreignKey: 'id'
});
private wasSetToMain: boolean = false; private wasSetToMain: boolean = false;
constructor(data: any) { constructor(data: any) {
super(data); super(data);
} }
protected defineProperties(): void { protected init(): void {
this.defineProperty<number>('user_id', new Validator().acceptUndefined().exists(User, 'id')); this.addProperty<number>('user_id', new Validator().acceptUndefined().exists(User, 'id'));
this.defineProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this)); this.addProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this));
this.defineProperty<boolean>('main', new Validator().defined()); this.addProperty<boolean>('main', new Validator().defined());
this.defineProperty<Date>('created_at', new Validator()); this.addProperty<Date>('created_at', new Validator());
} }
async beforeSave(exists: boolean, connection: Connection) { async beforeSave(exists: boolean, connection: Connection) {
if (this.wasSetToMain) { if (this.wasSetToMain) {
await query(`UPDATE ${this.table} SET main=false WHERE user_id=${this.user_id}`, null, connection); await query(`UPDATE ${this.table} SET main=false WHERE user_id=${this.user_id}`, null, connection);
}
}
protected async afterSave(): Promise<void> {
if (this.wasSetToMain) {
this.wasSetToMain = false; 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 { public isMain(): boolean {
return !!this.main; return this.main;
} }
public setMain() { public setMain() {

View File

@ -1,72 +1,67 @@
import MysqlConnectionManager, {query} from "./MysqlConnectionManager"; import MysqlConnectionManager, {query} from "./MysqlConnectionManager";
import Validator from "./Validator"; import Validator from "./Validator";
import {Connection} from "mysql"; import {Connection} from "mysql";
import Query from "./Query"; import ModelQuery, {ModelQueryResult} from "./ModelQuery";
import {Request} from "express"; import {Request} from "express";
import Pagination from "../Pagination"; import {Type} from "../Utils";
export interface ModelClass<M extends Model> extends Type<M> {
getFactory<M extends Model>(factory?: ModelFactory<M>): ModelFactory<M>;
table: string;
getPrimaryKey(modelData: any): string;
getPrimaryKeyFields(): string[];
select<M extends Model>(...fields: string[]): ModelQuery<M>;
update<M extends Model>(data: { [key: string]: any }): ModelQuery<M>;
delete<M extends Model>(): ModelQuery<M>;
}
export default abstract class Model { export default abstract class Model {
public static async getById<T extends Model>(id: string): Promise<T | null> { public static get table(): string {
const cachedModel = ModelCache.get(this.table, id); return this.name
if (cachedModel?.constructor === this) { .replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase())
return <T>cachedModel; .replace(/^_/, '')
} + 's';
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, query?: Query): Promise<T[]> { public static async getById<T extends Model>(this: ModelClass<T>, id: number): Promise<T | null> {
return this.select<T>().where('id', id).first();
}
public static async paginate<T extends Model>(this: ModelClass<T>, request: Request, perPage: number = 20, query?: ModelQuery<T>): Promise<ModelQueryResult<T>> {
let page = request.params.page ? parseInt(request.params.page) : 1; let page = request.params.page ? parseInt(request.params.page) : 1;
if (!query) query = this.select(); if (!query) query = this.select();
query = query.limit(perPage, (page - 1) * perPage).withTotalRowCount();
if (request.params.sortBy) { if (request.params.sortBy) {
const dir = request.params.sortDirection; const dir = request.params.sortDirection;
query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined); query = query.sortBy(request.params.sortBy, dir === 'ASC' || dir === 'DESC' ? dir : undefined);
} else { } else {
query = query.sortBy('id'); query = query.sortBy('id');
} }
const models = await this.models<T>(query); return await query.paginate(page, perPage);
// @ts-ignore
models.pagination = new Pagination(models, page, perPage, models.totalCount);
return models;
} }
protected static select(...fields: string[]): Query { public static select<T extends Model>(this: ModelClass<T>, ...fields: string[]): ModelQuery<T> {
return Query.select(this.table, ...fields); return ModelQuery.select(this, ...fields);
} }
protected static update(data: { [key: string]: any }): Query { public static update<T extends Model>(this: ModelClass<T>, data: { [key: string]: any }): ModelQuery<T> {
return Query.update(this.table, data); return ModelQuery.update(this, data);
} }
protected static delete(): Query { public static delete<T extends Model>(this: ModelClass<T>): ModelQuery<T> {
return Query.delete(this.table); return ModelQuery.delete(this);
} }
protected static async models<T extends Model>(query: Query): Promise<T[]> { public static getPrimaryKey(modelData: any): string {
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(','); return this.getPrimaryKeyFields().map(f => `${modelData[f]}`).join(',');
} }
protected static getPrimaryKeyFields(): string[] { public static getPrimaryKeyFields(): string[] {
return ['id']; return ['id'];
} }
@ -82,7 +77,7 @@ export default abstract class Model {
} }
} }
private static getFactory<T extends Model>(factory?: ModelFactory<T>): ModelFactory<T> { public static getFactory<T extends Model>(this: ModelClass<T>, factory?: ModelFactory<T>): ModelFactory<T> {
if (factory === undefined) { if (factory === undefined) {
factory = (<any>this).FACTORY; factory = (<any>this).FACTORY;
if (factory === undefined) factory = data => new (<any>this)(data); if (factory === undefined) factory = data => new (<any>this)(data);
@ -91,8 +86,8 @@ export default abstract class Model {
} }
protected readonly modelClass: ModelClass<this> = <ModelClass<this>>this.constructor;
protected readonly properties: ModelProperty<any>[] = []; protected readonly properties: ModelProperty<any>[] = [];
private readonly relations: { [p: string]: (Model | null) } = {};
public id?: number; public id?: number;
private readonly automaticIdProperty: boolean; private readonly automaticIdProperty: boolean;
@ -101,23 +96,23 @@ export default abstract class Model {
public constructor(data: any, automaticIdProperty: boolean = true) { public constructor(data: any, automaticIdProperty: boolean = true) {
this.automaticIdProperty = automaticIdProperty; this.automaticIdProperty = automaticIdProperty;
if (automaticIdProperty) { if (automaticIdProperty) {
this.defineProperty<number>('id', new Validator()); this.addProperty<number>('id', new Validator());
} }
this.defineProperties(); this.init();
this.updateWithData(data); this.updateWithData(data);
} }
public getPrimaryKey(): string { public getPrimaryKey(): string {
return (<any>this.constructor).getPrimaryKey(this); return this.modelClass.getPrimaryKey(this);
} }
public getPrimaryKeyFields(): string { public getPrimaryKeyFields(): string[] {
return (<any>this.constructor).getPrimaryKeyFields(); return this.modelClass.getPrimaryKeyFields();
} }
protected abstract defineProperties(): void; protected abstract init(): void;
protected defineProperty<T>(name: string, validator?: Validator<T> | RegExp) { protected addProperty<T>(name: string, validator?: Validator<T> | RegExp) {
if (validator === undefined) validator = new Validator(); if (validator === undefined) validator = new Validator();
if (validator instanceof RegExp) { if (validator instanceof RegExp) {
const regexp = validator; const regexp = validator;
@ -162,11 +157,7 @@ export default abstract class Model {
const callback = async () => { const callback = async () => {
if (needs_full_update) { if (needs_full_update) {
this.updateWithData((await (<any>this.constructor).select().where('id', this.id!).first().execute()).results[0]); this.updateWithData((await this.modelClass.select().where('id', this.id!).limit(1).execute()).results[0]);
}
if (!exists) {
this.cache();
} }
await this.afterSave(); await this.afterSave();
@ -198,7 +189,7 @@ export default abstract class Model {
needs_full_update = true; needs_full_update = true;
} }
} }
let query = Query.update(this.table, data); let query = this.modelClass.update(data);
for (const indexField of this.getPrimaryKeyFields()) { for (const indexField of this.getPrimaryKeyFields()) {
query = query.where(indexField, this[indexField]); query = query.where(indexField, this[indexField]);
} }
@ -222,54 +213,34 @@ export default abstract class Model {
return needs_full_update; 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 { public get table(): string {
// @ts-ignore return this.modelClass.table;
return this.constructor.table;
} }
public async exists(): Promise<boolean> { public async exists(): Promise<boolean> {
if (!this.id) return false; if (!this.id) return false;
let query = Query.select(this.table, '1'); let query = this.modelClass.select('1');
for (const indexField of this.getPrimaryKeyFields()) { for (const indexField of this.getPrimaryKeyFields()) {
query = query.where(indexField, this[indexField]); query = query.where(indexField, this[indexField]);
} }
query = query.first(); return typeof (await query.first()) !== 'undefined';
const result = await query.execute();
return result.results.length > 0;
} }
public async delete(): Promise<void> { public async delete(): Promise<void> {
if (!(await this.exists())) throw new Error('This model instance doesn\'t exist in DB.'); 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()) { for (const indexField of this.getPrimaryKeyFields()) {
query = query.where(indexField, this[indexField]); query = query.where(indexField, this[indexField]);
} }
await query.execute(); await query.execute();
ModelCache.forget(this);
if (this.automaticIdProperty) this.id = undefined; if (this.automaticIdProperty) this.id = undefined;
} }
public async validate(onlyFormat: boolean = false, connection?: Connection): Promise<void[]> { public async validate(onlyFormat: boolean = false, connection?: Connection): Promise<void[]> {
return await Promise.all(this.properties.map(prop => prop.validate(onlyFormat, connection))); 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> { export interface ModelFactory<T extends Model> {
@ -299,44 +270,4 @@ class ModelProperty<T> {
} }
} }
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])?)+$/; 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])?)+$/;

View File

@ -1,28 +1,32 @@
import {query, QueryResult} from "./MysqlConnectionManager"; import {query, QueryResult} from "./MysqlConnectionManager";
import {Connection} from "mysql"; import {Connection} from "mysql";
import Model, {ModelClass} from "./Model";
import Pagination from "../Pagination";
import ModelRelation from "./ModelRelation";
export default class Query { export default class ModelQuery<M extends Model> {
public static select(table: string, ...fields: string[]): Query { public static select<M extends Model>(modelClass: ModelClass<M>, ...fields: string[]): ModelQuery<M> {
return new Query(QueryType.SELECT, table, fields.length > 0 ? fields : ['*']); return new ModelQuery(QueryType.SELECT, modelClass, fields.length > 0 ? fields : ['*']);
} }
public static update(table: string, data: { public static update<M extends Model>(modelClass: ModelClass<M>, data: {
[key: string]: any [key: string]: any
}): Query { }): ModelQuery<M> {
const fields = []; const fields = [];
for (let key in data) { for (let key in data) {
if (data.hasOwnProperty(key)) { if (data.hasOwnProperty(key)) {
fields.push(new UpdateFieldValue(key, data[key], false)); 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 { public static delete<M extends Model>(modelClass: ModelClass<M>): ModelQuery<M> {
return new Query(QueryType.DELETE, table); return new ModelQuery(QueryType.DELETE, modelClass);
} }
private readonly type: QueryType; private readonly type: QueryType;
private readonly modelClass: ModelClass<M>;
private readonly table: string; private readonly table: string;
private readonly fields: (string | SelectFieldValue | UpdateFieldValue)[]; private readonly fields: (string | SelectFieldValue | UpdateFieldValue)[];
private _leftJoin?: string; private _leftJoin?: string;
@ -32,11 +36,13 @@ export default class Query {
private _offset?: number; private _offset?: number;
private _sortBy?: string; private _sortBy?: string;
private _sortDirection?: 'ASC' | 'DESC'; 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<M>, fields?: (string | SelectFieldValue | UpdateFieldValue)[]) {
this.type = type; this.type = type;
this.table = table; this.modelClass = modelClass;
this.table = modelClass.table;
this.fields = fields || []; this.fields = fields || [];
} }
@ -50,7 +56,7 @@ export default class Query {
return this; 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> | any, test: WhereTest = WhereTest.EQ, operator: WhereOperator = WhereOperator.AND): this {
this._where.push(new WhereFieldValue(field, value, false, test, operator)); this._where.push(new WhereFieldValue(field, value, false, test, operator));
return this; return this;
} }
@ -61,25 +67,31 @@ export default class Query {
return this; return this;
} }
public first(): this {
return this.limit(1);
}
public sortBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this { public sortBy(field: string, direction: 'ASC' | 'DESC' = 'ASC'): this {
this._sortBy = field; this._sortBy = field;
this._sortDirection = direction; this._sortDirection = direction;
return this; 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; return this;
} }
public toString(final: boolean = false): string { public toString(final: boolean = false): string {
let query = ''; let query = '';
let fields = this.fields?.join(','); if (this._pivot) this.fields.push(...this._pivot);
let fields = this.fields.join(',');
let join = ''; let join = '';
if (this._leftJoin) { if (this._leftJoin) {
@ -112,7 +124,7 @@ export default class Query {
switch (this.type) { switch (this.type) {
case QueryType.SELECT: 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; break;
case QueryType.UPDATE: case QueryType.UPDATE:
query = `UPDATE ${this.table} SET ${fields} ${where} ${orderBy} ${limit}`; query = `UPDATE ${this.table} SET ${fields} ${where} ${orderBy} ${limit}`;
@ -140,18 +152,80 @@ export default class Query {
return variables; return variables;
} }
public isCacheable(): boolean { public async execute(connection?: Connection): Promise<QueryResult> {
return this.type === QueryType.SELECT && this.fields.length === 1 && this.fields[0] === '*'; return await query(this.build(), this.variables, connection);
} }
public async execute(connection?: Connection): Promise<QueryResult> { public async get(connection?: Connection): Promise<ModelQueryResult<M>> {
const queryResult = await query(this.build(), this.variables, connection); const queryResult = await this.execute();
if (this._foundRows) { const models: ModelQueryResult<M> = [];
const foundRows = await query('SELECT FOUND_ROWS() as r', undefined, connection);
queryResult.foundRows = foundRows.results[0].r; if (this._pivot) models.pivot = [];
// Eager loading init
const relationMap: { [p: string]: ModelRelation<any, any, any>[] } = {};
for (const relation of this.relations) {
relationMap[relation] = [];
} }
return queryResult;
const factory = this.modelClass.getFactory<M>();
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<ModelQueryResult<M>> {
this.limit(perPage, (page - 1) * perPage);
const result = await this.get(connection);
result.pagination = new Pagination<M>(result, page, perPage, await this.count(true, connection));
return result;
}
public async first(): Promise<M | null> {
const models = await this.limit(1).get();
return models.length > 0 ? models[0] : null;
}
public async count(removeLimit: boolean = false, connection?: Connection): Promise<number> {
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<M extends Model> extends Array<M> {
pagination?: Pagination<M>;
pivot?: { [p: string]: any }[];
} }
export enum QueryType { export enum QueryType {
@ -187,7 +261,7 @@ class FieldValue {
} }
public toString(first: boolean = true): string { 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 { protected get test(): string {
@ -195,13 +269,13 @@ class FieldValue {
} }
public get variables(): any[] { 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 { class SelectFieldValue extends FieldValue {
public toString(first: boolean = true): string { 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}`;
} }
} }

165
src/db/ModelRelation.ts Normal file
View File

@ -0,0 +1,165 @@
import ModelQuery, {ModelQueryResult, WhereTest} from "./ModelQuery";
import Model, {ModelClass} from "./Model";
export default abstract class ModelRelation<S extends Model, O extends Model, R extends O | O[] | null> {
protected readonly model: S;
protected readonly foreignModelClass: ModelClass<O>;
protected readonly query: ModelQuery<O>;
protected cachedModels?: R;
protected constructor(model: S, foreignModelClass: ModelClass<O>) {
this.model = model;
this.foreignModelClass = foreignModelClass;
this.query = this.foreignModelClass.select();
}
public abstract clone(): ModelRelation<S, O, R>;
public constraint(queryModifier: QueryModifier<O>): this {
queryModifier(this.query);
return this;
}
public abstract getModelID(): any;
protected abstract async compute(query: ModelQuery<O>): Promise<R>;
public async get(): Promise<R> {
if (this.cachedModels === undefined) {
this.cachedModels = await this.compute(this.query);
}
return this.cachedModels;
}
public abstract async eagerLoad(relations: ModelRelation<S, O, R>[]): Promise<ModelQueryResult<O>>;
public abstract async populate(models: ModelQueryResult<O>): Promise<void>;
}
export type QueryModifier<M extends Model> = (query: ModelQuery<M>) => ModelQuery<M>;
export class OneModelRelation<S extends Model, O extends Model> extends ModelRelation<S, O, O | null> {
protected readonly dbProperties: RelationDatabaseProperties;
constructor(model: S, foreignModelClass: ModelClass<O>, dbProperties: RelationDatabaseProperties) {
super(model, foreignModelClass);
this.dbProperties = dbProperties;
}
public clone(): OneModelRelation<S, O> {
return new OneModelRelation(this.model, this.foreignModelClass, this.dbProperties);
}
public getModelID() {
return this.model[this.dbProperties.localKey];
}
protected async compute(query: ModelQuery<O>): Promise<O | null> {
this.query.where(this.dbProperties.foreignKey, this.getModelID());
return await query.first();
}
public async eagerLoad(relations: ModelRelation<S, O, O | null>[]): Promise<ModelQueryResult<O>> {
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<O>): Promise<void> {
this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelID())[0] || null;
}
}
export class ManyModelRelation<S extends Model, O extends Model> extends ModelRelation<S, O, O[]> {
protected readonly dbProperties: RelationDatabaseProperties;
constructor(model: S, foreignModelClass: ModelClass<O>, dbProperties: RelationDatabaseProperties) {
super(model, foreignModelClass);
this.dbProperties = dbProperties;
}
public clone(): ManyModelRelation<S, O> {
return new ManyModelRelation<S, O>(this.model, this.foreignModelClass, this.dbProperties);
}
public getModelID(): any {
return this.model[this.dbProperties.localKey];
}
protected async compute(query: ModelQuery<O>): Promise<O[]> {
this.query.where(this.dbProperties.foreignKey, this.getModelID());
return await query.get();
}
public async eagerLoad(relations: ModelRelation<S, O, O[]>[]): Promise<ModelQueryResult<O>> {
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<O>): Promise<void> {
this.cachedModels = models.filter(m => m[this.dbProperties.foreignKey] === this.getModelID);
}
}
export class ManyThroughModelRelation<S extends Model, O extends Model> extends ModelRelation<S, O, O[]> {
protected readonly dbProperties: PivotRelationDatabaseProperties;
constructor(model: S, foreignModelClass: ModelClass<O>, 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<S, O> {
return new ManyThroughModelRelation<S, O>(this.model, this.foreignModelClass, this.dbProperties);
}
public getModelID(): any {
return this.model[this.dbProperties.localKey];
}
protected async compute(query: ModelQuery<O>): Promise<O[]> {
this.query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID());
return await query.get();
}
public async eagerLoad(relations: ModelRelation<S, O, O[]>[]): Promise<ModelQueryResult<O>> {
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<O>): Promise<void> {
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;
};

View File

@ -1,5 +1,5 @@
import Model from "./Model"; import Model from "./Model";
import Query, {WhereTest} from "./Query"; import ModelQuery, {WhereTest} from "./ModelQuery";
import {Connection} from "mysql"; import {Connection} from "mysql";
import {Type} from "../Utils"; import {Type} from "../Utils";
@ -174,11 +174,11 @@ export default class Validator<T> {
return this; return this;
} }
public unique<M extends Model>(model: M | Type<M>, foreignKey?: string, querySupplier?: () => Query): Validator<T> { public unique<M extends Model>(model: M | Type<M>, foreignKey?: string, querySupplier?: () => ModelQuery<M>): Validator<T> {
this.addStep({ this.addStep({
verifyStep: async (val, thingName, c) => { verifyStep: async (val, thingName, c) => {
if (!foreignKey) foreignKey = thingName; if (!foreignKey) foreignKey = thingName;
let query: Query; let query: ModelQuery<M>;
if (querySupplier) { if (querySupplier) {
query = querySupplier().where(foreignKey, val); query = querySupplier().where(foreignKey, val);
} else { } else {

View File

@ -11,14 +11,14 @@ export default class Log extends Model {
private error_stack?: string; private error_stack?: string;
private created_at?: Date; private created_at?: Date;
protected defineProperties(): void { protected init(): void {
this.defineProperty<number>('level', new Validator<number>().defined()); this.addProperty<number>('level', new Validator<number>().defined());
this.defineProperty<string>('message', new Validator<string>().defined().between(0, 65535)); this.addProperty<string>('message', new Validator<string>().defined().between(0, 65535));
this.defineProperty<Buffer>('log_id', new Validator<Buffer>().acceptUndefined().length(16)); this.addProperty<Buffer>('log_id', new Validator<Buffer>().acceptUndefined().length(16));
this.defineProperty<string>('error_name', new Validator<string>().acceptUndefined().between(0, 128)); this.addProperty<string>('error_name', new Validator<string>().acceptUndefined().between(0, 128));
this.defineProperty<string>('error_message', new Validator<string>().acceptUndefined().between(0, 512)); this.addProperty<string>('error_message', new Validator<string>().acceptUndefined().between(0, 512));
this.defineProperty<string>('error_stack', new Validator<string>().acceptUndefined().between(0, 65535)); this.addProperty<string>('error_stack', new Validator<string>().acceptUndefined().between(0, 65535));
this.defineProperty<Date>('created_at', new Validator<Date>()); this.addProperty<Date>('created_at', new Validator<Date>());
} }
public getLevel(): LogLevelKeys { public getLevel(): LogLevelKeys {

View File

@ -8,10 +8,10 @@ class FakeDummyModel extends Model {
public date?: Date; public date?: Date;
public date_default?: Date; public date_default?: Date;
protected defineProperties(): void { protected init(): void {
this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 256)); this.addProperty<string>('name', new Validator().acceptUndefined().between(3, 256));
this.defineProperty<Date>('date', new Validator()); this.addProperty<Date>('date', new Validator());
this.defineProperty<Date>('date_default', new Validator()); this.addProperty<Date>('date_default', new Validator());
} }
} }
@ -54,7 +54,7 @@ describe('Model', () => {
expect(instance.date?.getTime()).toBeCloseTo(date.getTime(), -4); expect(instance.date?.getTime()).toBeCloseTo(date.getTime(), -4);
expect(instance.date_default).toBeDefined(); expect(instance.date_default).toBeDefined();
instance = await FakeDummyModel.getById('1'); instance = await FakeDummyModel.getById(1);
expect(instance).toBeDefined(); expect(instance).toBeDefined();
expect(instance!.id).toBe(1); expect(instance!.id).toBe(1);
expect(instance!.name).toBe('name1'); expect(instance!.name).toBe('name1');