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",
"version": "0.9.3",
"version": "0.10.0",
"description": "Node web framework",
"repository": "git@gitlab.com:ArisuOngaku/wms-core.git",
"author": "Alice Gaudon <alice@gaudon.pro>",

View File

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

View File

@ -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) {

View File

@ -85,7 +85,7 @@ export default abstract class MagicLinkController extends Controller {
let success = true;
let err;
const magicLink = await MagicLink.getById<MagicLink>(`${id}`);
const magicLink = await MagicLink.getById<MagicLink>(id);
if (!magicLink) {
res.status(404);
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 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<MagicLink | null> {
@ -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<MagicLink>(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<string>('session_id', new Validator().defined().length(32).unique(this));
this.defineProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX));
this.defineProperty<string>('token', new Validator().defined().length(96));
this.defineProperty<string>('action_type', new Validator().defined().maxLength(64));
this.defineProperty<string>('original_url', new Validator().defined().maxLength(1745));
this.defineProperty<Date>('generated_at', new Validator());
this.defineProperty<boolean>('authorized', new Validator().defined());
protected init(): void {
this.addProperty<string>('session_id', new Validator().defined().length(32).unique(this));
this.addProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX));
this.addProperty<string>('token', new Validator().defined().length(96));
this.addProperty<string>('action_type', new Validator().defined().maxLength(64));
this.addProperty<string>('original_url', new Validator().defined().maxLength(1745));
this.addProperty<Date>('generated_at', new Validator());
this.addProperty<boolean>('authorized', new Validator().defined());
}
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> {
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> {

View File

@ -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<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 {
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 approved: boolean = false;
public is_admin: boolean = false;
public created_at?: Date;
public updated_at?: Date;
protected defineProperties(): void {
this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 64));
if (User.isApprovalMode()) this.defineProperty<boolean>('approved', new Validator().defined());
this.defineProperty<boolean>('is_admin', new Validator().defined());
public readonly emails = new ManyModelRelation<this, UserEmail>(this, UserEmail, {
localKey: 'id',
foreignKey: 'user_id'
});
this.defineProperty<Date>('created_at');
this.defineProperty<Date>('updated_at');
public readonly mainEmail = this.emails.clone().constraint(q => q.where('main', true));
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 {

View File

@ -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<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 readonly email!: string;
private main!: boolean;
public created_at?: Date;
public readonly user = new OneModelRelation<this, User>(this, User, {
localKey: 'user_id',
foreignKey: 'id'
});
private wasSetToMain: boolean = false;
constructor(data: any) {
super(data);
}
protected defineProperties(): void {
this.defineProperty<number>('user_id', new Validator().acceptUndefined().exists(User, 'id'));
this.defineProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this));
this.defineProperty<boolean>('main', new Validator().defined());
this.defineProperty<Date>('created_at', new Validator());
protected init(): void {
this.addProperty<number>('user_id', new Validator().acceptUndefined().exists(User, 'id'));
this.addProperty<string>('email', new Validator().defined().regexp(EMAIL_REGEX).unique(this));
this.addProperty<boolean>('main', new Validator().defined());
this.addProperty<Date>('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<void> {
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() {

View File

@ -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<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 {
public static async getById<T extends Model>(id: string): Promise<T | null> {
const cachedModel = ModelCache.get(this.table, id);
if (cachedModel?.constructor === this) {
return <T>cachedModel;
}
const models = await this.models<T>(this.select().where('id', id).first());
return models.length > 0 ? models[0] : null;
public static get table(): string {
return this.name
.replace(/(?:^|\.?)([A-Z])/g, (x, y) => '_' + y.toLowerCase())
.replace(/^_/, '')
+ 's';
}
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;
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<T>(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<T extends Model>(this: ModelClass<T>, ...fields: string[]): ModelQuery<T> {
return ModelQuery.select(this, ...fields);
}
protected static update(data: { [key: string]: any }): Query {
return Query.update(this.table, data);
public static update<T extends Model>(this: ModelClass<T>, data: { [key: string]: any }): ModelQuery<T> {
return ModelQuery.update(this, data);
}
protected static delete(): Query {
return Query.delete(this.table);
public static delete<T extends Model>(this: ModelClass<T>): ModelQuery<T> {
return ModelQuery.delete(this);
}
protected static async models<T extends Model>(query: Query): Promise<T[]> {
const results = await query.execute();
const models: T[] = [];
const factory = this.getFactory<T>();
for (const result of results.results) {
const cachedModel = ModelCache.get(this.table, this.getPrimaryKey(result));
if (cachedModel && cachedModel.constructor === this) {
cachedModel.updateWithData(result);
models.push(<T>cachedModel);
} else {
models.push(factory(result));
}
}
// @ts-ignore
models.totalCount = results.foundRows;
return models;
}
protected static getPrimaryKey(modelData: any): string {
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<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) {
factory = (<any>this).FACTORY;
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>[] = [];
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<number>('id', new Validator());
this.addProperty<number>('id', new Validator());
}
this.defineProperties();
this.init();
this.updateWithData(data);
}
public getPrimaryKey(): string {
return (<any>this.constructor).getPrimaryKey(this);
return this.modelClass.getPrimaryKey(this);
}
public getPrimaryKeyFields(): string {
return (<any>this.constructor).getPrimaryKeyFields();
public getPrimaryKeyFields(): string[] {
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 instanceof RegExp) {
const regexp = validator;
@ -162,11 +157,7 @@ export default abstract class Model {
const callback = async () => {
if (needs_full_update) {
this.updateWithData((await (<any>this.constructor).select().where('id', this.id!).first().execute()).results[0]);
}
if (!exists) {
this.cache();
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<boolean> {
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<void> {
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<void[]> {
return await Promise.all(this.properties.map(prop => prop.validate(onlyFormat, connection)));
}
private cache() {
ModelCache.cache(this);
}
protected relation<T extends Model>(name: string): T | null {
if (this.relations[name] === undefined) throw new Error('Model not loaded');
return <T | null>this.relations[name];
}
}
export interface ModelFactory<T extends Model> {
@ -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])?)+$/;

View File

@ -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<M extends Model> {
public static select<M extends Model>(modelClass: ModelClass<M>, ...fields: string[]): ModelQuery<M> {
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
}): Query {
}): ModelQuery<M> {
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<M extends Model>(modelClass: ModelClass<M>): ModelQuery<M> {
return new ModelQuery(QueryType.DELETE, modelClass);
}
private readonly type: QueryType;
private readonly modelClass: ModelClass<M>;
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<M>, 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> | 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<QueryResult> {
return await query(this.build(), this.variables, connection);
}
public async execute(connection?: Connection): Promise<QueryResult> {
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<ModelQueryResult<M>> {
const queryResult = await this.execute();
const models: ModelQueryResult<M> = [];
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 {
@ -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}`;
}
}

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 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<T> {
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({
verifyStep: async (val, thingName, c) => {
if (!foreignKey) foreignKey = thingName;
let query: Query;
let query: ModelQuery<M>;
if (querySupplier) {
query = querySupplier().where(foreignKey, val);
} else {

View File

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

View File

@ -8,10 +8,10 @@ class FakeDummyModel extends Model {
public date?: Date;
public date_default?: Date;
protected defineProperties(): void {
this.defineProperty<string>('name', new Validator().acceptUndefined().between(3, 256));
this.defineProperty<Date>('date', new Validator());
this.defineProperty<Date>('date_default', new Validator());
protected init(): void {
this.addProperty<string>('name', new Validator().acceptUndefined().between(3, 256));
this.addProperty<Date>('date', 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_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');