ModelQuery: add nested eagerloading

This commit is contained in:
Alice Gaudon 2020-09-07 13:43:02 +02:00
parent e86356ae74
commit 0e37014667
3 changed files with 288 additions and 18 deletions

View File

@ -41,6 +41,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
private _sortBy?: string; private _sortBy?: string;
private _sortDirection?: 'ASC' | 'DESC'; private _sortDirection?: 'ASC' | 'DESC';
private readonly relations: string[] = []; private readonly relations: string[] = [];
private readonly subRelations: { [relation: string]: string[] } = {};
private _pivot?: string[]; private _pivot?: string[];
private _union?: ModelQueryUnion; private _union?: ModelQueryUnion;
private _recursiveRelation?: RelationDatabaseProperties; private _recursiveRelation?: RelationDatabaseProperties;
@ -102,10 +103,18 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
} }
/** /**
* @param relation The relation field name to eagerload * @param relations The relations field names to eagerload. To load nested relations, separate fields with '.'
* (i.e.: "author.roles.permissions" loads authors, their roles, and the permissions of these roles)
*/ */
public with(relation: string): this { public with(...relations: string[]): this {
this.relations.push(relation); relations.forEach(relation => {
const parts = relation.split('.');
this.relations.push(parts[0]);
if (parts.length > 1) {
if (!this.subRelations[parts[0]]) this.subRelations[parts[0]] = [];
this.subRelations[parts[0]].push(parts.slice(1).join('.'));
}
});
return this; return this;
} }
@ -275,7 +284,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
for (const relationName of this.relations) { for (const relationName of this.relations) {
const relations = relationMap[relationName]; const relations = relationMap[relationName];
if (relations.length > 0) { if (relations.length > 0) {
const allModels = await relations[0].eagerLoad(relations); const allModels = await relations[0].eagerLoad(relations, this.subRelations[relationName]);
await Promise.all(relations.map(r => r.populate(allModels))); await Promise.all(relations.map(r => r.populate(allModels)));
} }
} }

View File

@ -69,12 +69,13 @@ export default abstract class ModelRelation<S extends Model, O extends Model, R
protected abstract collectionToOutput(models: O[]): R; protected abstract collectionToOutput(models: O[]): R;
public async eagerLoad(relations: ModelRelation<S, O, R>[]): Promise<ModelQueryResult<O>> { public async eagerLoad(relations: ModelRelation<S, O, R>[], subRelations: string[] = []): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelID()).filter(id => id !== null && id !== undefined); const ids = relations.map(r => r.getModelID()).filter(id => id !== null && id !== undefined);
if (ids.length === 0) return []; if (ids.length === 0) return [];
const query = this.makeQuery(); const query = this.makeQuery();
query.where(this.dbProperties.foreignKey, ids, WhereTest.IN); query.where(this.dbProperties.foreignKey, ids, WhereTest.IN);
query.with(...subRelations);
return await query.get(); return await query.get();
} }
@ -175,13 +176,14 @@ export class ManyThroughModelRelation<S extends Model, O extends Model> extends
query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID()); query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID());
} }
public async eagerLoad(relations: ModelRelation<S, O, O[]>[]): Promise<ModelQueryResult<O>> { public async eagerLoad(relations: ModelRelation<S, O, O[]>[], subRelations: string[] = []): Promise<ModelQueryResult<O>> {
const ids = relations.map(r => r.getModelID()); const ids = relations.map(r => r.getModelID());
if (ids.length === 0) return []; if (ids.length === 0) return [];
const query = this.makeQuery(); const query = this.makeQuery();
query.where(`pivot.${this.dbProperties.localPivotKey}`, ids, WhereTest.IN); query.where(`pivot.${this.dbProperties.localPivotKey}`, ids, WhereTest.IN);
query.pivot(`pivot.${this.dbProperties.localPivotKey}`, `pivot.${this.dbProperties.foreignPivotKey}`); query.pivot(`pivot.${this.dbProperties.localPivotKey}`, `pivot.${this.dbProperties.foreignPivotKey}`);
query.with(...subRelations);
return await query.get(); return await query.get();
} }

View File

@ -4,6 +4,7 @@ import {MIGRATIONS} from "./_migrations";
import ModelFactory from "../src/db/ModelFactory"; import ModelFactory from "../src/db/ModelFactory";
import {ValidationBag} from "../src/db/Validator"; import {ValidationBag} from "../src/db/Validator";
import Logger from "../src/Logger"; import Logger from "../src/Logger";
import {ManyThroughModelRelation, OneModelRelation} from "../src/db/ModelRelation";
class FakeDummyModel extends Model { class FakeDummyModel extends Model {
public id?: number = undefined; public id?: number = undefined;
@ -16,24 +17,229 @@ class FakeDummyModel extends Model {
} }
} }
let factory: ModelFactory<FakeDummyModel>; class Post extends Model {
public id?: number = undefined;
public author_id?: number = undefined;
public content?: string = undefined;
public readonly author: OneModelRelation<Post, Author> = new OneModelRelation<Post, Author>(this, ModelFactory.get(Author), {
localKey: 'author_id',
foreignKey: 'id',
});
protected init(): void {
this.setValidation('author_id').defined().exists(Author, 'id');
}
}
class Author extends Model {
public id?: number = undefined;
public name?: string = undefined;
public readonly roles: ManyThroughModelRelation<Author, Role> = new ManyThroughModelRelation<Author, Role>(this, ModelFactory.get(Role), {
localKey: 'id',
foreignKey: 'id',
pivotTable: 'author_role',
localPivotKey: 'author_id',
foreignPivotKey: 'role_id',
});
protected init(): void {
}
}
class Role extends Model {
public id?: number = undefined;
public name?: string = undefined;
public readonly permissions: ManyThroughModelRelation<Role, Permission> = new ManyThroughModelRelation<Role, Permission>(this, ModelFactory.get(Permission), {
localKey: 'id',
foreignKey: 'id',
pivotTable: 'role_permission',
localPivotKey: 'role_id',
foreignPivotKey: 'permission_id',
});
protected init(): void {
}
}
class Permission extends Model {
public id?: number = undefined;
public name?: string = undefined;
protected init(): void {
}
}
class AuthorRole extends Model {
public static get table(): string {
return 'author_role';
}
public author_id?: number = undefined;
public role_id?: number = undefined;
protected init(): void {
this.setValidation('author_id').defined().exists(Author, 'id');
this.setValidation('role_id').defined().exists(Role, 'id');
}
}
class RolePermission extends Model {
public static get table(): string {
return 'role_permission';
}
public role_id?: number = undefined;
public permission_id?: number = undefined;
protected init(): void {
this.setValidation('role_id').defined().exists(Role, 'id');
this.setValidation('permission_id').defined().exists(Permission, 'id');
}
}
let fakeDummyModelModelFactory: ModelFactory<FakeDummyModel>;
let postFactory: ModelFactory<Post>;
let authorFactory: ModelFactory<Author>;
let roleFactory: ModelFactory<Role>;
let permissionFactory: ModelFactory<Permission>;
beforeAll(async () => { beforeAll(async () => {
Logger.verbose(); Logger.verbose();
MysqlConnectionManager.registerMigrations(MIGRATIONS); MysqlConnectionManager.registerMigrations(MIGRATIONS);
ModelFactory.register(FakeDummyModel); ModelFactory.register(FakeDummyModel);
ModelFactory.register(Post);
ModelFactory.register(Author);
ModelFactory.register(Role);
ModelFactory.register(Permission);
ModelFactory.register(AuthorRole);
ModelFactory.register(RolePermission);
await MysqlConnectionManager.prepare(); await MysqlConnectionManager.prepare();
// Create FakeDummyModel table // Create FakeDummyModel table
factory = ModelFactory.get(FakeDummyModel); fakeDummyModelModelFactory = ModelFactory.get(FakeDummyModel);
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS ${(factory.table)}`); postFactory = ModelFactory.get(Post);
await MysqlConnectionManager.query(`CREATE TABLE ${(factory.table)}( authorFactory = ModelFactory.get(Author);
roleFactory = ModelFactory.get(Role);
permissionFactory = ModelFactory.get(Permission);
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS author_role`);
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS role_permission`);
for (const factory of [
fakeDummyModelModelFactory,
postFactory,
authorFactory,
roleFactory,
permissionFactory
]) {
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS ${(factory.table)}`);
}
await MysqlConnectionManager.query(`CREATE TABLE ${(fakeDummyModelModelFactory.table)}(
id INT NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(256), name VARCHAR(256),
date DATETIME, date DATETIME,
date_default DATETIME DEFAULT NOW(), date_default DATETIME DEFAULT NOW(),
PRIMARY KEY(id) PRIMARY KEY(id)
)`); )`);
await MysqlConnectionManager.query(`CREATE TABLE ${(authorFactory.table)}(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64),
PRIMARY KEY(id)
)`);
await MysqlConnectionManager.query(`CREATE TABLE ${(postFactory.table)}(
id INT NOT NULL AUTO_INCREMENT,
author_id INT NOT NULL,
content VARCHAR(512),
PRIMARY KEY(id),
FOREIGN KEY post_author_fk (author_id) REFERENCES ${(authorFactory.table)} (id)
)`);
await MysqlConnectionManager.query(`CREATE TABLE ${(roleFactory.table)}(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64),
PRIMARY KEY(id)
)`);
await MysqlConnectionManager.query(`CREATE TABLE ${(permissionFactory.table)}(
id INT NOT NULL AUTO_INCREMENT,
name VARCHAR(64),
PRIMARY KEY(id)
)`);
await MysqlConnectionManager.query(`CREATE TABLE author_role(
id INT NOT NULL AUTO_INCREMENT,
author_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY author_role_author_fk (author_id) REFERENCES ${(authorFactory.table)} (id),
FOREIGN KEY author_role_role_fk (role_id) REFERENCES ${(roleFactory.table)} (id)
)`);
await MysqlConnectionManager.query(`CREATE TABLE role_permission(
id INT NOT NULL AUTO_INCREMENT,
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY(id),
FOREIGN KEY role_permission_role_fk (role_id) REFERENCES ${(roleFactory.table)} (id),
FOREIGN KEY role_permission_permission_fk (permission_id) REFERENCES ${(permissionFactory.table)} (id)
)`);
/// SEED ///
// permissions
createPostPermission = Permission.create({name: 'create-post'});
await createPostPermission.save();
moderatePostPermission = Permission.create({name: 'moderate-post'});
await moderatePostPermission.save();
viewLogsPermission = Permission.create({name: 'view-logs'});
await viewLogsPermission.save();
// roles
guestRole = Role.create({name: 'guest'});
await guestRole.save();
await RolePermission.create({role_id: guestRole.id, permission_id: createPostPermission.id}).save();
moderatorRole = Role.create({name: 'moderator'});
await moderatorRole.save();
await RolePermission.create({role_id: moderatorRole.id, permission_id: createPostPermission.id}).save();
await RolePermission.create({role_id: moderatorRole.id, permission_id: moderatePostPermission.id}).save();
adminRole = Role.create({name: 'admin'});
await adminRole.save();
await RolePermission.create({role_id: adminRole.id, permission_id: createPostPermission.id}).save();
await RolePermission.create({role_id: adminRole.id, permission_id: moderatePostPermission.id}).save();
await RolePermission.create({role_id: adminRole.id, permission_id: viewLogsPermission.id}).save();
// authors
glimmerAuthor = Author.create({name: 'glimmer'});
await glimmerAuthor.save();
await AuthorRole.create({author_id: glimmerAuthor.id, role_id: guestRole.id}).save();
bowAuthor = Author.create({name: 'bow'});
await bowAuthor.save();
await AuthorRole.create({author_id: bowAuthor.id, role_id: moderatorRole.id}).save();
adoraAuthor = Author.create({name: 'adora'});
await adoraAuthor.save();
await AuthorRole.create({author_id: adoraAuthor.id, role_id: adminRole.id}).save();
// posts
post1 = Post.create({author_id: glimmerAuthor.id, content: 'I\'m the queen now and you\'ll do as I order.'});
await post1.save();
post2 = Post.create({author_id: adoraAuthor.id, content: 'But you\'re wrong!'});
await post2.save();
post3 = Post.create({author_id: bowAuthor.id, content: 'Come on guys, let\'s talk this through.'});
await post3.save();
}); });
afterAll(async () => { afterAll(async () => {
@ -43,7 +249,7 @@ afterAll(async () => {
describe('Model', () => { describe('Model', () => {
it('should construct properly', () => { it('should construct properly', () => {
const date = new Date(888); const date = new Date(888);
const model = factory.create({ const model = fakeDummyModelModelFactory.create({
name: 'a_name', name: 'a_name',
date: date, date: date,
non_existing_property: 'dropped_value', non_existing_property: 'dropped_value',
@ -57,17 +263,17 @@ describe('Model', () => {
}); });
it('should have a proper table name', () => { it('should have a proper table name', () => {
expect(factory.table).toBe('fake_dummy_models'); expect(fakeDummyModelModelFactory.table).toBe('fake_dummy_models');
expect(FakeDummyModel.table).toBe('fake_dummy_models'); expect(FakeDummyModel.table).toBe('fake_dummy_models');
expect(FakeDummyModel.create({}).table).toBe('fake_dummy_models'); expect(FakeDummyModel.create({}).table).toBe('fake_dummy_models');
}); });
it('should insert properly', async () => { it('should insert properly', async () => {
const date = new Date(569985); const date = new Date(569985);
const insertInstance: FakeDummyModel | null = factory.create({ const insertInstance: FakeDummyModel | null = fakeDummyModelModelFactory.create({
name: 'name1', name: 'name1',
date: date, date: date,
}, true); }, true);
// Insert // Insert
@ -88,14 +294,14 @@ describe('Model', () => {
expect(retrievedInstance!.date?.getTime()).toBeCloseTo(date.getTime(), -4); expect(retrievedInstance!.date?.getTime()).toBeCloseTo(date.getTime(), -4);
expect(retrievedInstance!.date_default).toBeDefined(); expect(retrievedInstance!.date_default).toBeDefined();
const failingInsertModel = factory.create({ const failingInsertModel = fakeDummyModelModelFactory.create({
name: 'a', name: 'a',
}, true); }, true);
await expect(failingInsertModel.save()).rejects.toBeInstanceOf(ValidationBag); await expect(failingInsertModel.save()).rejects.toBeInstanceOf(ValidationBag);
}); });
it('should update properly', async () => { it('should update properly', async () => {
const insertModel = factory.create({ const insertModel = fakeDummyModelModelFactory.create({
name: 'update', name: 'update',
}, true); }, true);
await insertModel.save(); await insertModel.save();
@ -116,7 +322,7 @@ describe('Model', () => {
}); });
it('should delete properly', async () => { it('should delete properly', async () => {
const insertModel = factory.create({ const insertModel = fakeDummyModelModelFactory.create({
name: 'delete', name: 'delete',
}, true); }, true);
await insertModel.save(); await insertModel.save();
@ -130,3 +336,56 @@ describe('Model', () => {
expect(postDeleteModel).toBeNull(); expect(postDeleteModel).toBeNull();
}); });
}); });
let createPostPermission: Permission;
let moderatePostPermission: Permission;
let viewLogsPermission: Permission;
let guestRole: Role;
let moderatorRole: Role;
let adminRole: Role;
let glimmerAuthor: Author;
let bowAuthor: Author;
let adoraAuthor: Author;
let post1: Post;
let post2: Post;
let post3: Post;
describe('ModelRelation', () => {
test('Query and check relations', async () => {
const posts = await Post.select()
.with('author.roles.permissions')
.sortBy('id', 'ASC')
.get();
expect(posts.length).toBe(3);
async function testPost(post: Post, originalPost: Post, expectedAuthor: Author, expectedRoles: Role[], expectedPermissions: Permission[]) {
console.log('Testing post', post)
expect(post.id).toBe(originalPost.id);
expect(post.content).toBe(originalPost.content);
const actualAuthor = await post.author.get();
expect(actualAuthor).not.toBeNull()
expect(await post.author.has(expectedAuthor)).toBeTruthy();
expect(actualAuthor!.equals(expectedAuthor)).toBe(true);
const authorRoles = await actualAuthor!.roles.get();
console.log('Roles:');
expect(authorRoles.map(r => r.id)).toStrictEqual(expectedRoles.map(r => r.id));
const authorPermissions = (await Promise.all(authorRoles.map(async r => await r.permissions.get()))).flatMap(p => p);
console.log('Permissions:');
expect(authorPermissions.map(p => p.id)).toStrictEqual(expectedPermissions.map(p => p.id));
}
await testPost(posts[0], post1, glimmerAuthor,
[guestRole],
[createPostPermission]);
await testPost(posts[1], post2, adoraAuthor,
[adminRole],
[createPostPermission, moderatePostPermission, viewLogsPermission]);
await testPost(posts[2], post3, bowAuthor,
[moderatorRole],
[createPostPermission, moderatePostPermission]);
});
});