ModelQuery: add nested eagerloading
This commit is contained in:
parent
e86356ae74
commit
0e37014667
@ -41,6 +41,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
|
||||
private _sortBy?: string;
|
||||
private _sortDirection?: 'ASC' | 'DESC';
|
||||
private readonly relations: string[] = [];
|
||||
private readonly subRelations: { [relation: string]: string[] } = {};
|
||||
private _pivot?: string[];
|
||||
private _union?: ModelQueryUnion;
|
||||
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 {
|
||||
this.relations.push(relation);
|
||||
public with(...relations: string[]): this {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -275,7 +284,7 @@ export default class ModelQuery<M extends Model> implements WhereFieldConsumer<M
|
||||
for (const relationName of this.relations) {
|
||||
const relations = relationMap[relationName];
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
@ -69,12 +69,13 @@ export default abstract class ModelRelation<S extends Model, O extends Model, 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);
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const query = this.makeQuery();
|
||||
query.where(this.dbProperties.foreignKey, ids, WhereTest.IN);
|
||||
query.with(...subRelations);
|
||||
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());
|
||||
}
|
||||
|
||||
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());
|
||||
if (ids.length === 0) return [];
|
||||
|
||||
const query = this.makeQuery();
|
||||
query.where(`pivot.${this.dbProperties.localPivotKey}`, ids, WhereTest.IN);
|
||||
query.pivot(`pivot.${this.dbProperties.localPivotKey}`, `pivot.${this.dbProperties.foreignPivotKey}`);
|
||||
query.with(...subRelations);
|
||||
return await query.get();
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import {MIGRATIONS} from "./_migrations";
|
||||
import ModelFactory from "../src/db/ModelFactory";
|
||||
import {ValidationBag} from "../src/db/Validator";
|
||||
import Logger from "../src/Logger";
|
||||
import {ManyThroughModelRelation, OneModelRelation} from "../src/db/ModelRelation";
|
||||
|
||||
class FakeDummyModel extends Model {
|
||||
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 () => {
|
||||
Logger.verbose();
|
||||
MysqlConnectionManager.registerMigrations(MIGRATIONS);
|
||||
ModelFactory.register(FakeDummyModel);
|
||||
ModelFactory.register(Post);
|
||||
ModelFactory.register(Author);
|
||||
ModelFactory.register(Role);
|
||||
ModelFactory.register(Permission);
|
||||
ModelFactory.register(AuthorRole);
|
||||
ModelFactory.register(RolePermission);
|
||||
await MysqlConnectionManager.prepare();
|
||||
|
||||
// Create FakeDummyModel table
|
||||
factory = ModelFactory.get(FakeDummyModel);
|
||||
await MysqlConnectionManager.query(`DROP TABLE IF EXISTS ${(factory.table)}`);
|
||||
await MysqlConnectionManager.query(`CREATE TABLE ${(factory.table)}(
|
||||
fakeDummyModelModelFactory = ModelFactory.get(FakeDummyModel);
|
||||
postFactory = ModelFactory.get(Post);
|
||||
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,
|
||||
name VARCHAR(256),
|
||||
date DATETIME,
|
||||
date_default DATETIME DEFAULT NOW(),
|
||||
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 () => {
|
||||
@ -43,7 +249,7 @@ afterAll(async () => {
|
||||
describe('Model', () => {
|
||||
it('should construct properly', () => {
|
||||
const date = new Date(888);
|
||||
const model = factory.create({
|
||||
const model = fakeDummyModelModelFactory.create({
|
||||
name: 'a_name',
|
||||
date: date,
|
||||
non_existing_property: 'dropped_value',
|
||||
@ -57,17 +263,17 @@ describe('Model', () => {
|
||||
});
|
||||
|
||||
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.create({}).table).toBe('fake_dummy_models');
|
||||
});
|
||||
|
||||
it('should insert properly', async () => {
|
||||
const date = new Date(569985);
|
||||
const insertInstance: FakeDummyModel | null = factory.create({
|
||||
const insertInstance: FakeDummyModel | null = fakeDummyModelModelFactory.create({
|
||||
name: 'name1',
|
||||
date: date,
|
||||
}, true);
|
||||
}, true);
|
||||
|
||||
|
||||
// Insert
|
||||
@ -88,14 +294,14 @@ describe('Model', () => {
|
||||
expect(retrievedInstance!.date?.getTime()).toBeCloseTo(date.getTime(), -4);
|
||||
expect(retrievedInstance!.date_default).toBeDefined();
|
||||
|
||||
const failingInsertModel = factory.create({
|
||||
const failingInsertModel = fakeDummyModelModelFactory.create({
|
||||
name: 'a',
|
||||
}, true);
|
||||
await expect(failingInsertModel.save()).rejects.toBeInstanceOf(ValidationBag);
|
||||
});
|
||||
|
||||
it('should update properly', async () => {
|
||||
const insertModel = factory.create({
|
||||
const insertModel = fakeDummyModelModelFactory.create({
|
||||
name: 'update',
|
||||
}, true);
|
||||
await insertModel.save();
|
||||
@ -116,7 +322,7 @@ describe('Model', () => {
|
||||
});
|
||||
|
||||
it('should delete properly', async () => {
|
||||
const insertModel = factory.create({
|
||||
const insertModel = fakeDummyModelModelFactory.create({
|
||||
name: 'delete',
|
||||
}, true);
|
||||
await insertModel.save();
|
||||
@ -130,3 +336,56 @@ describe('Model', () => {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user