diff --git a/src/db/ModelQuery.ts b/src/db/ModelQuery.ts index f83f3cd..6e9dcd2 100644 --- a/src/db/ModelQuery.ts +++ b/src/db/ModelQuery.ts @@ -41,6 +41,7 @@ export default class ModelQuery implements WhereFieldConsumer implements WhereFieldConsumer { + 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 implements WhereFieldConsumer 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))); } } diff --git a/src/db/ModelRelation.ts b/src/db/ModelRelation.ts index 1353df2..12ab91e 100644 --- a/src/db/ModelRelation.ts +++ b/src/db/ModelRelation.ts @@ -69,12 +69,13 @@ export default abstract class ModelRelation[]): Promise> { + public async eagerLoad(relations: ModelRelation[], subRelations: string[] = []): Promise> { 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 extends query.where(`pivot.${this.dbProperties.localPivotKey}`, this.getModelID()); } - public async eagerLoad(relations: ModelRelation[]): Promise> { + public async eagerLoad(relations: ModelRelation[], subRelations: string[] = []): Promise> { 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(); } diff --git a/test/Model.test.ts b/test/Model.test.ts index 5377cfb..7f8b486 100644 --- a/test/Model.test.ts +++ b/test/Model.test.ts @@ -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; +class Post extends Model { + public id?: number = undefined; + public author_id?: number = undefined; + public content?: string = undefined; + + public readonly author: OneModelRelation = new OneModelRelation(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 = new ManyThroughModelRelation(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 = new ManyThroughModelRelation(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; + +let postFactory: ModelFactory; +let authorFactory: ModelFactory; +let roleFactory: ModelFactory; +let permissionFactory: ModelFactory; 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(); @@ -129,4 +335,57 @@ describe('Model', () => { const postDeleteModel = await FakeDummyModel.getById(insertModel.id); expect(postDeleteModel).toBeNull(); }); -}); \ No newline at end of file +}); + +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]); + }); +});