From dfff57b00614d55f62a6f3ae1c69f776de88c4dd Mon Sep 17 00:00:00 2001 From: Francisco Buceta Date: Thu, 22 Oct 2020 18:52:54 +0200 Subject: [PATCH] feat: add HasAndBelongsToMany relation tests Signed-off-by: Francisco Buceta --- .../relation.factory.integration.ts | 246 ++++++++++ .../unit/decorator/relation.decorator.unit.ts | 61 +++ ...belongs-to-many-repository-factory.unit.ts | 112 +++++ ...e-has-and-belongs-to-many-metadata.unit.ts | 461 ++++++++++++++++++ .../has-and-belongs-to-many.helpers.ts | 16 +- 5 files changed, 888 insertions(+), 8 deletions(-) create mode 100644 packages/repository/src/__tests__/unit/repositories/has-and-belongs-to-many-repository-factory.unit.ts create mode 100644 packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-and-belongs-to-many-metadata.unit.ts diff --git a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts index 83768048488e..e9c0954107a8 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -8,6 +8,7 @@ import { BelongsToAccessor, BelongsToDefinition, createBelongsToAccessor, + createHasAndBelongsToManyRepositoryFactory, createHasManyRepositoryFactory, createHasManyThroughRepositoryFactory, DefaultCrudRepository, @@ -15,6 +16,9 @@ import { EntityCrudRepository, EntityNotFoundError, Getter, + HasAndBelongsToManyDefinition, + HasAndBelongsToManyRepository, + HasAndBelongsToManyRepositoryFactory, HasManyDefinition, HasManyRepository, HasManyRepositoryFactory, @@ -472,6 +476,248 @@ describe('HasManyThrough relation', () => { } }); +describe('HasAndBelongsToMany relation', () => { + let existingCustomerId: number; + // Customer has many CartItems through CustomerCartItemLink + let customerCartItemRepo: HasAndBelongsToManyRepository< + CartItem, + typeof CartItem.prototype.id + >; + let customerCartItemFactory: HasAndBelongsToManyRepositoryFactory< + CartItem, + typeof CartItem.prototype.id, + typeof Customer.prototype.id + >; + + before(givenCrudRepositories); + before(givenPersistedCustomerInstance); + before(givenConstrainedRepositories); + + beforeEach(async function resetDatabase() { + await customerRepo.deleteAll(); + await customerCartItemLinkRepo.deleteAll(); + await cartItemRepo.deleteAll(); + }); + + it('creates a target instance along with the corresponding through model', async () => { + const cartItem = await customerCartItemRepo.create({ + description: 'an item hasManyThrough', + }); + const persistedItem = await cartItemRepo.findById(cartItem.id); + const persistedLink = await customerCartItemLinkRepo.find(); + + expect(cartItem).to.deepEqual(persistedItem); + expect(persistedLink).have.length(1); + const expected = { + customerId: existingCustomerId, + itemId: cartItem.id, + }; + expect(toJSON(persistedLink[0])).to.containEql(toJSON(expected)); + }); + + it('finds an instance via through model', async () => { + const item = await customerCartItemRepo.create({ + description: 'an item hasManyThrough', + }); + const notMyItem = await cartItemRepo.create({ + description: "someone else's item desc", + }); + + const items = await customerCartItemRepo.find(); + + expect(items).to.not.containEql(notMyItem); + expect(items).to.deepEqual([item]); + }); + + it('finds instances via through models', async () => { + const item1 = await customerCartItemRepo.create({description: 'group 1'}); + const item2 = await customerCartItemRepo.create({ + description: 'group 2', + }); + const items = await customerCartItemRepo.find(); + + expect(items).have.length(2); + expect(items).to.deepEqual([item1, item2]); + const group1 = await customerCartItemRepo.find({ + where: {description: 'group 1'}, + }); + expect(group1).to.deepEqual([item1]); + }); + + it('deletes an instance, then deletes the through model', async () => { + await customerCartItemRepo.create({ + description: 'customer 1', + }); + const anotherHasManyThroughRepo = customerCartItemFactory( + existingCustomerId + 1, + ); + const item2 = await anotherHasManyThroughRepo.create({ + description: 'customer 2', + }); + let items = await cartItemRepo.find(); + let links = await customerCartItemLinkRepo.find(); + + expect(items).have.length(2); + expect(links).have.length(2); + + await customerCartItemRepo.delete(); + items = await cartItemRepo.find(); + links = await customerCartItemLinkRepo.find(); + + expect(items).have.length(1); + expect(links).have.length(1); + expect(items).to.deepEqual([item2]); + expect(links[0]).has.property('itemId', item2.id); + expect(links[0]).has.property('customerId', existingCustomerId + 1); + }); + + it('deletes through model when corresponding target gets deleted', async () => { + const item1 = await customerCartItemRepo.create({ + description: 'customer 1', + }); + const anotherHasManyThroughRepo = customerCartItemFactory( + existingCustomerId + 1, + ); + const item2 = await anotherHasManyThroughRepo.create({ + description: 'customer 2', + }); + // when order1 gets deleted, this through instance should be deleted too. + const through = await customerCartItemLinkRepo.create({ + id: 1, + customerId: existingCustomerId + 1, + itemId: item1.id, + }); + let items = await cartItemRepo.find(); + let links = await customerCartItemLinkRepo.find(); + + expect(items).have.length(2); + expect(links).have.length(3); + + await customerCartItemRepo.delete(); + + items = await cartItemRepo.find(); + links = await customerCartItemLinkRepo.find(); + + expect(items).have.length(1); + expect(links).have.length(1); + expect(items).to.deepEqual([item2]); + expect(links).to.not.containEql(through); + expect(links[0]).has.property('itemId', item2.id); + expect(links[0]).has.property('customerId', existingCustomerId + 1); + }); + + it('deletes instances based on the filter', async () => { + await customerCartItemRepo.create({ + description: 'customer 1', + }); + const item2 = await customerCartItemRepo.create({ + description: 'customer 2', + }); + + let items = await cartItemRepo.find(); + let links = await customerCartItemLinkRepo.find(); + expect(items).have.length(2); + expect(links).have.length(2); + + await customerCartItemRepo.delete({description: 'does not exist'}); + items = await cartItemRepo.find(); + links = await customerCartItemLinkRepo.find(); + expect(items).have.length(2); + expect(links).have.length(2); + + await customerCartItemRepo.delete({description: 'customer 1'}); + items = await cartItemRepo.find(); + links = await customerCartItemLinkRepo.find(); + + expect(items).have.length(1); + expect(links).have.length(1); + expect(items).to.deepEqual([item2]); + expect(links[0]).has.property('itemId', item2.id); + expect(links[0]).has.property('customerId', existingCustomerId); + }); + + it('patches instances that belong to the same source model (same source fk)', async () => { + const item1 = await customerCartItemRepo.create({ + description: 'group 1', + }); + const item2 = await customerCartItemRepo.create({ + description: 'group 1', + }); + + const count = await customerCartItemRepo.patch({description: 'group 2'}); + expect(count).to.match({count: 2}); + const updateResult = await cartItemRepo.find(); + expect(toJSON(updateResult)).to.containDeep( + toJSON([ + {id: item1.id, description: 'group 2'}, + {id: item2.id, description: 'group 2'}, + ]), + ); + }); + + it('links a target instance to the source instance', async () => { + const item = await cartItemRepo.create({description: 'an item'}); + let targets = await customerCartItemRepo.find(); + expect(targets).to.deepEqual([]); + + await customerCartItemRepo.link(item.id); + targets = await customerCartItemRepo.find(); + expect(toJSON(targets)).to.containDeep(toJSON([item])); + const link = await customerCartItemLinkRepo.find(); + expect(toJSON(link[0])).to.containEql( + toJSON({customerId: existingCustomerId, itemId: item.id}), + ); + }); + + it('unlinks a target instance from the source instance', async () => { + const item = await customerCartItemRepo.create({description: 'an item'}); + let targets = await customerCartItemRepo.find(); + expect(toJSON(targets)).to.containDeep(toJSON([item])); + + await customerCartItemRepo.unlink(item.id); + targets = await customerCartItemRepo.find(); + expect(targets).to.deepEqual([]); + // the through model should be deleted + const thoughs = await customerCartItemRepo.find(); + expect(thoughs).to.deepEqual([]); + }); + //--- HELPERS ---// + + async function givenPersistedCustomerInstance() { + const customer = await customerRepo.create({name: 'a customer'}); + existingCustomerId = customer.id; + } + + function givenConstrainedRepositories() { + customerCartItemFactory = createHasAndBelongsToManyRepositoryFactory< + CartItem, + typeof CartItem.prototype.id, + CustomerCartItemLink, + typeof CustomerCartItemLink.prototype.id, + typeof Customer.prototype.id + >( + { + name: 'cartItems', + type: 'hasAndBelongsToMany', + targetsMany: true, + source: Customer, + keyFrom: 'id', + target: () => CartItem, + keyTo: 'id', + through: { + model: () => CustomerCartItemLink, + sourceKey: 'customerId', + targetKey: 'itemId', + }, + } as HasAndBelongsToManyDefinition, + Getter.fromValue(customerCartItemLinkRepo), + Getter.fromValue(cartItemRepo), + ); + + customerCartItemRepo = customerCartItemFactory(existingCustomerId); + } +}); + //--- HELPERS ---// class Order extends Entity { diff --git a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts index 8774c3ed0224..dcc10e60e6c0 100644 --- a/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts +++ b/packages/repository/src/__tests__/unit/decorator/relation.decorator.unit.ts @@ -9,6 +9,7 @@ import { belongsTo, Entity, getModelRelations, + hasAndBelongsToMany, hasMany, model, MODEL_PROPERTIES_KEY, @@ -18,6 +19,66 @@ import { } from '../../..'; describe('relation decorator', () => { + describe('hasAndBelongsToMany', () => { + it('takes in complex property type and infers foreign key via source model name', () => { + // Source Model + @model() + class Rol extends Entity { + id: number; + name: string; + description?: string; + @hasAndBelongsToMany(() => RolesHasPermissions, () => Permission) + permissions: object; + } + + // Target Model + @model() + class Permission extends Entity { + id: number; + name: string; + description?: string; + resource: string; + scope: string; + } + + // Through Model + @model() + class RolesHasPermissions extends Entity { + rolId: number; + permissionId: number; + } + + const meta = MetadataInspector.getPropertyMetadata( + RELATIONS_KEY, + Rol.prototype, + 'permissions', + ); + + expect(meta).to.eql({ + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + target: () => Permission, + through: { + model: () => RolesHasPermissions, + }, + }); + expect(Rol.definition.relations).to.eql({ + permissions: { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + target: () => Permission, + through: { + model: () => RolesHasPermissions, + }, + }, + }); + }); + }); + describe('hasMany', () => { it('takes in complex property type and infers foreign key via source model name', () => { @model() diff --git a/packages/repository/src/__tests__/unit/repositories/has-and-belongs-to-many-repository-factory.unit.ts b/packages/repository/src/__tests__/unit/repositories/has-and-belongs-to-many-repository-factory.unit.ts new file mode 100644 index 000000000000..fcf2f56da941 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/has-and-belongs-to-many-repository-factory.unit.ts @@ -0,0 +1,112 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {createStubInstance, expect} from '@loopback/testlab'; +import { + DefaultCrudRepository, + Entity, + Getter, + juggler, + model, + property, + RelationType, +} from '../../..'; +import {createHasAndBelongsToManyRepositoryFactory} from '../../../relations/has-and-belongs-to-many'; +import {HasAndBelongsToManyResolvedDefinition} from '../../../relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers'; + +describe('createHasAndBelongsToManyRepositoryFactory', () => { + let rolesHasPermissionsRepo: RolesHasPermissionsRepository; + let permissionRepo: PermissionRepository; + + beforeEach(() => { + givenStubbedRolesHasPermissionsRepo(); + givenStubbedPermissionRepo(); + }); + + it('should return a function that could create hasManyThrough repository', () => { + const relationMeta = resolvedMetadata as HasAndBelongsToManyResolvedDefinition; + const result = createHasAndBelongsToManyRepositoryFactory( + relationMeta, + Getter.fromValue(rolesHasPermissionsRepo), + Getter.fromValue(permissionRepo), + ); + expect(result).to.be.Function(); + }); + + /*------------- HELPERS ---------------*/ + + @model() + class Rol extends Entity { + @property({id: true}) + id: number; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class Permission extends Entity { + @property({id: true}) + id: number; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class RolesHasPermissions extends Entity { + @property({id: true}) + rolId: number; + @property({id: true}) + permissionId: number; + + constructor(data: Partial) { + super(data); + } + } + + class PermissionRepository extends DefaultCrudRepository< + Permission, + typeof Permission.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Permission, dataSource); + } + } + + class RolesHasPermissionsRepository extends DefaultCrudRepository< + RolesHasPermissions, + typeof RolesHasPermissions.prototype.rolId + > { + constructor(dataSource: juggler.DataSource) { + super(RolesHasPermissions, dataSource); + } + } + + const resolvedMetadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + through: { + model: () => RolesHasPermissions, + sourceKey: 'rolId', + targetKey: 'permissionId', + }, + }; + + function givenStubbedRolesHasPermissionsRepo() { + rolesHasPermissionsRepo = createStubInstance(RolesHasPermissionsRepository); + } + + function givenStubbedPermissionRepo() { + permissionRepo = createStubInstance(PermissionRepository); + } +}); diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-and-belongs-to-many-metadata.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-and-belongs-to-many-metadata.unit.ts new file mode 100644 index 000000000000..e0df3e7627c2 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/resolve-has-and-belongs-to-many-metadata.unit.ts @@ -0,0 +1,461 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect} from '@loopback/testlab'; +import { + Entity, + HasAndBelongsToManyDefinition, + model, + ModelDefinition, + property, + RelationType, +} from '../../../..'; +import { + createTargetConstraintFromThrough, + createThroughConstraintFromSource, + createThroughConstraintFromTarget, + getTargetIdsFromTargetModels, + getTargetKeysFromThroughModels, + HasAndBelongsToManyResolvedDefinition, + resolveHasAndBelongsToManyMetadata, +} from '../../../../relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers'; + +describe('HasAndBelongsToManyHelpers', () => { + context('createThroughConstraintFromSource', () => { + it('creates constraint for searching through models', () => { + const result = createThroughConstraintFromSource(relationMetaData, 1); + expect(result).to.containEql({rolId: 1}); + }); + }); + context('getTargetKeysFromThroughModels', () => { + it('returns the target fk value of a given through instance', () => { + const through1 = createRolesHasPermissions({ + rolId: 2, + permissionId: 9, + }); + const result = getTargetKeysFromThroughModels(relationMetaData, [ + through1, + ]); + expect(result).to.deepEqual([9]); + }); + it('returns the target fk values of given through instances', () => { + const through1 = createRolesHasPermissions({ + rolId: 2, + permissionId: 9, + }); + const through2 = createRolesHasPermissions({ + rolId: 2, + permissionId: 8, + }); + const result = getTargetKeysFromThroughModels(relationMetaData, [ + through1, + through2, + ]); + expect(result).to.containDeep([9, 8]); + }); + }); + context('createTargetConstraintFromThrough', () => { + it('creates constraint for searching target models', () => { + const through1 = createRolesHasPermissions({ + rolId: 2, + permissionId: 9, + }); + const through2 = createRolesHasPermissions({ + rolId: 2, + permissionId: 8, + }); + + // single through model + let result = createTargetConstraintFromThrough(relationMetaData, [ + through1, + ]); + expect(result).to.containEql({id: 9}); + // multiple through models + result = createTargetConstraintFromThrough(relationMetaData, [ + through1, + through2, + ]); + expect(result).to.containEql({id: {inq: [9, 8]}}); + }); + + it('creates constraint for searching target models with duplicate keys', () => { + const through1 = createRolesHasPermissions({ + rolId: 2, + permissionId: 9, + }); + const through2 = createRolesHasPermissions({ + rolId: 3, + permissionId: 9, + }); + + const result = createTargetConstraintFromThrough(relationMetaData, [ + through1, + through2, + ]); + expect(result).to.containEql({id: 9}); + }); + }); + + context('getTargetIdsFromTargetModels', () => { + it('returns an empty array if the given target array is empty', () => { + const result = getTargetIdsFromTargetModels(relationMetaData, []); + expect(result).to.containDeep([]); + }); + it('creates constraint with a given fk', () => { + const result = getTargetIdsFromTargetModels(relationMetaData, [ + createPermission({id: 1}), + ]); + expect(result).to.containDeep([1]); + }); + it('creates constraint with given fks', () => { + const result = getTargetIdsFromTargetModels(relationMetaData, [ + createPermission({id: 1}), + createPermission({id: 2}), + ]); + expect(result).to.containDeep([1, 2]); + }); + }); + + context('createThroughConstraintFromTarget', () => { + it('creates constraint with a given fk', () => { + const result = createThroughConstraintFromTarget(relationMetaData, [1]); + expect(result).to.containEql({permissionId: 1}); + }); + it('creates constraint with given fks', () => { + const result = createThroughConstraintFromTarget(relationMetaData, [ + 1, + 2, + ]); + expect(result).to.containEql({permissionId: {inq: [1, 2]}}); + }); + it('throws if fkValue is undefined', () => { + expect(() => + createThroughConstraintFromTarget(relationMetaData, []), + ).to.throw(/"fkValue" must be provided/); + }); + }); + context('resolveHasManyThroughMetadata', () => { + it('throws if the wrong metadata type is used', async () => { + const metadata: unknown = { + name: 'permission', + type: RelationType.hasOne, + targetsMany: false, + source: Rol, + target: () => Permission, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasOne definition for Rol#permission: relation type must be HasAndBelongsToMany/, + ); + }); + + it('throws if the through is not provided', async () => { + const metadata: unknown = { + name: 'permission', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + target: () => Permission, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for Rol#permission: through must be specified/, + ); + }); + + it('throws if the source is not resolvable', async () => { + const metadata: unknown = { + name: 'permission', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: 'random', + target: () => Permission, + through: { + model: () => RolesHasPermissions, + }, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for #permission: source model must be defined/, + ); + }); + + it('throws if the through is not resolvable', async () => { + const metadata: unknown = { + name: 'permission', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + target: () => Permission, + through: {model: 'random'}, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for Rol#permission: through model must be a type resolver/, + ); + }); + + it('throws if the target is not resolvable', async () => { + const metadata: unknown = { + name: 'permission', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + target: 'random', + through: { + model: RolesHasPermissions, + }, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for Rol#permission: target must be a type resolver/, + ); + }); + + describe('resolves through.sourceKey/targetKey', () => { + it('resolves metadata with complete hasAndBelongsToMany definition', () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + + through: { + model: () => RolesHasPermissions, + sourceKey: 'rolId', + targetKey: 'permissionId', + }, + }; + const meta = resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + + expect(meta).to.eql(relationMetaData); + }); + + it('infers through.sourceKey if it is not provided', () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + + through: { + model: () => RolesHasPermissions, + // no through.sourceKey + targetKey: 'permissionId', + }, + }; + const meta = resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + + expect(meta).to.eql(relationMetaData); + }); + + it('infers through.targetKey if it is not provided', () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + + through: { + model: () => RolesHasPermissions, + sourceKey: 'rolId', + // no through.targetKey + }, + }; + + const meta = resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + + expect(meta).to.eql(relationMetaData); + }); + + it('throws if through.sourceKey is not provided in through', async () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + + through: { + model: () => InvalidThrough, + targetKey: 'permissionId', + }, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for Rol#permissions: through model InvalidThrough is missing definition of source foreign key/, + ); + }); + + it('throws if through.targetKey is not provided in through', async () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + + through: { + model: () => InvalidThrough2, + sourceKey: 'rolId', + }, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + /Invalid hasAndBelongsToMany definition for Rol#permissions: through model InvalidThrough2 is missing definition of target foreign key/, + ); + }); + + it('throws if the target model does not have the id property', async () => { + const metadata = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => InvalidPermission, + keyTo: 'id', + + through: { + model: () => RolesHasPermissions, + sourceKey: 'rolId', + targetKey: 'permissionId', + }, + }; + + expect(() => { + resolveHasAndBelongsToManyMetadata( + metadata as HasAndBelongsToManyDefinition, + ); + }).to.throw( + 'Invalid hasAndBelongsToMany definition for Rol#permissions: target model InvalidPermission does not have any primary key (id property)', + ); + }); + }); + }); + /****** HELPERS *******/ + + @model() + class Rol extends Entity { + @property({id: true}) + id: number; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class Permission extends Entity { + @property({id: true}) + id: number; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class InvalidPermission extends Entity { + @property({id: false}) + random: number; + + constructor(data: Partial) { + super(data); + } + } + + @model() + class RolesHasPermissions extends Entity { + @property({id: true}) + rolId: number; + @property({id: true}) + permissionId: number; + + constructor(data: Partial) { + super(data); + } + } + + const relationMetaData = { + name: 'permissions', + type: RelationType.hasAndBelongsToMany, + targetsMany: true, + source: Rol, + keyFrom: 'id', + target: () => Permission, + keyTo: 'id', + through: { + model: () => RolesHasPermissions, + sourceKey: 'rolId', + targetKey: 'permissionId', + }, + } as HasAndBelongsToManyResolvedDefinition; + + class InvalidThrough extends Entity {} + InvalidThrough.definition = new ModelDefinition('InvalidThrough') + // lack through.sourceKey + .addProperty('permissionId', {type: 'number', id: true}); + + class InvalidThrough2 extends Entity {} + InvalidThrough2.definition = new ModelDefinition('InvalidThrough2') + // lack through.targetKey + .addProperty('rolId', {type: 'number', id: true}); + + function createRolesHasPermissions(properties: Partial) { + return new RolesHasPermissions(properties); + } + + function createPermission(properties: Partial) { + return new Permission(properties); + } +}); diff --git a/packages/repository/src/relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers.ts b/packages/repository/src/relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers.ts index db5ff89b0bb1..944778b1024f 100644 --- a/packages/repository/src/relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers.ts +++ b/packages/repository/src/relations/has-and-belongs-to-many/has-and-belongs-to-many.helpers.ts @@ -25,18 +25,18 @@ export function resolveHasAndBelongsToManyMetadata( if ( (relationMeta.type as RelationType) !== RelationType.hasAndBelongsToMany ) { - const reason = 'Relation type must be HasAndBelongsToMany'; + const reason = 'relation type must be HasAndBelongsToMany'; throw new InvalidRelationError(reason, relationMeta); } if (!isTypeResolver(relationMeta.target)) { - const reason = 'Target must be a type resolver'; + const reason = 'target must be a type resolver'; throw new InvalidRelationError(reason, relationMeta); } const sourceModel = relationMeta.source; if (!sourceModel || !sourceModel.modelName) { - const reason = 'Source model must be defined'; + const reason = 'source model must be defined'; throw new InvalidRelationError(reason, relationMeta); } let keyFrom; @@ -50,12 +50,12 @@ export function resolveHasAndBelongsToManyMetadata( } if (!relationMeta.through) { - const reason = 'Through must be specified'; + const reason = 'through must be specified'; throw new InvalidRelationError(reason, relationMeta); } if (!isTypeResolver(relationMeta.through.model)) { - const reason = 'Through model must be a type resolver'; + const reason = 'through model must be a type resolver'; throw new InvalidRelationError(reason, relationMeta); } @@ -92,21 +92,21 @@ export function resolveHasAndBelongsToManyMetadata( const sourceFkName = relationMeta.through.sourceKey ?? camelCase(sourceModel.modelName + '_id'); if (!throughModelProperties[sourceFkName]) { - const reason = `Through model ${throughModel.name} is missing definition of source foreign key`; + const reason = `through model ${throughModel.name} is missing definition of source foreign key`; throw new InvalidRelationError(reason, relationMeta); } const targetFkName = relationMeta.through.targetKey ?? camelCase(targetModel.modelName + '_id'); if (!throughModelProperties[targetFkName]) { - const reason = `Through model ${throughModel.name} is missing definition of target foreign key`; + const reason = `through model ${throughModel.name} is missing definition of target foreign key`; throw new InvalidRelationError(reason, relationMeta); } const targetPrimaryKey = relationMeta.keyTo ?? targetModel.definition.idProperties()[0]; if (!targetPrimaryKey || !targetModelProperties[targetPrimaryKey]) { - const reason = `Target model ${targetModel.modelName} does not have any primary key (id property)`; + const reason = `target model ${targetModel.modelName} does not have any primary key (id property)`; throw new InvalidRelationError(reason, relationMeta); }