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 13211c5799fc..7b63885ed855 100644 --- a/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts +++ b/packages/repository/src/__tests__/integration/repositories/relation.factory.integration.ts @@ -3,12 +3,13 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -import {expect} from '@loopback/testlab'; +import {expect, toJSON} from '@loopback/testlab'; import { BelongsToAccessor, BelongsToDefinition, createBelongsToAccessor, createHasManyRepositoryFactory, + createHasManyThroughRepositoryFactory, DefaultCrudRepository, Entity, EntityCrudRepository, @@ -17,6 +18,8 @@ import { HasManyDefinition, HasManyRepository, HasManyRepositoryFactory, + HasManyThroughRepository, + HasManyThroughRepositoryFactory, juggler, ModelDefinition, RelationType, @@ -26,6 +29,11 @@ import { let db: juggler.DataSource; let customerRepo: EntityCrudRepository; let orderRepo: EntityCrudRepository; +let cartItemRepo: EntityCrudRepository; +let CustomerCartItemLinkRepo: EntityCrudRepository< + CustomerCartItemLink, + typeof CustomerCartItemLink.prototype.id +>; let reviewRepo: EntityCrudRepository; describe('HasMany relation', () => { @@ -203,6 +211,245 @@ describe('BelongsTo relation', () => { } }); +describe('HasManyThrough relation', () => { + let existingCustomerId: number; + // Customer has many CartItems through CustomerCartItemLink + let customerCartItemRepo: HasManyThroughRepository< + CartItem, + typeof CartItem.prototype.id, + CustomerCartItemLink + >; + let customerCartItemFactory: HasManyThroughRepositoryFactory< + CartItem, + typeof CartItem.prototype.id, + CustomerCartItemLink, + 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 alone with the corresponding through model', async () => { + const cartItem = await customerCartItemRepo.create( + { + description: 'an item hasManyThrough', + }, + { + throughData: {id: 99}, + }, + ); + const persistedItem = await cartItemRepo.findById(cartItem.id); + const persistedLink = await CustomerCartItemLinkRepo.find(); + expect(cartItem).to.deepEqual(persistedItem); + expect(persistedLink).have.length(1); + const expected = { + id: 99, + customerId: existingCustomerId, + itemId: cartItem.id, + }; + expect(toJSON(persistedLink[0])).to.deepEqual(toJSON(expected)); + }); + + it('finds an instance via through model', async () => { + const item = await customerCartItemRepo.create( + { + description: 'an item hasManyThrough', + }, + { + throughData: {id: 99}, + }, + ); + 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', + }, + { + throughData: {id: 99}, + }, + ); + const item2 = await customerCartItemRepo.create( + { + description: 'group 2', + }, + { + throughData: {id: 98}, + }, + ); + 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', + }, + { + throughData: {id: 98}, + }, + ); + const anotherHasManyThroughRepo = customerCartItemFactory( + existingCustomerId + 1, + ); + const item2 = await anotherHasManyThroughRepo.create( + { + description: 'customer 2', + }, + { + throughData: {id: 99}, + }, + ); + 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', + }, + { + throughData: {id: 98}, + }, + ); + const anotherHasManyThroughRepo = customerCartItemFactory( + existingCustomerId + 1, + ); + const item2 = await anotherHasManyThroughRepo.create( + { + description: 'customer 2', + }, + { + throughData: {id: 99}, + }, + ); + // 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('patches instances that belong to the same source model (same source fk)', async () => { + const item1 = await customerCartItemRepo.create( + { + description: 'group 1', + }, + { + throughData: {id: 99}, + }, + ); + const item2 = await customerCartItemRepo.create( + { + description: 'group 1', + }, + { + throughData: {id: 98}, + }, + ); + + 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'}, + ]), + ); + }); + //--- HELPERS ---// + + async function givenPersistedCustomerInstance() { + const customer = await customerRepo.create({name: 'a customer'}); + existingCustomerId = customer.id; + } + + function givenConstrainedRepositories() { + customerCartItemFactory = createHasManyThroughRepositoryFactory< + CartItem, + typeof CartItem.prototype.id, + CustomerCartItemLink, + typeof CustomerCartItemLink.prototype.id, + typeof Customer.prototype.id + >( + { + name: 'cartItems', + type: 'hasMany', + targetsMany: true, + source: Customer, + keyFrom: 'id', + target: () => CartItem, + keyTo: 'id', + through: { + model: () => CustomerCartItemLink, + keyFrom: 'customerId', + keyTo: 'itemId', + }, + } as HasManyDefinition, + Getter.fromValue(cartItemRepo), + Getter.fromValue(CustomerCartItemLinkRepo), + ); + + customerCartItemRepo = customerCartItemFactory(existingCustomerId); + } +}); + //--- HELPERS ---// class Order extends Entity { @@ -225,6 +472,15 @@ class Order extends Entity { }); } +class CartItem extends Entity { + id: number; + description: string; + + static definition = new ModelDefinition('CartItem') + .addProperty('id', {type: 'number', id: true}) + .addProperty('description', {type: 'string', required: true}); +} + class Review extends Entity { id: number; description: string; @@ -277,10 +533,28 @@ class Customer extends Entity { }); } +class CustomerCartItemLink extends Entity { + id: number; + customerId: number; + itemId: number; + static definition = new ModelDefinition('CustomerCartItemLink') + .addProperty('id', { + type: 'number', + id: true, + required: true, + }) + .addProperty('itemId', {type: 'number'}) + .addProperty('customerId', {type: 'number'}); +} function givenCrudRepositories() { db = new juggler.DataSource({connector: 'memory'}); customerRepo = new DefaultCrudRepository(Customer, db); orderRepo = new DefaultCrudRepository(Order, db); + cartItemRepo = new DefaultCrudRepository(CartItem, db); + CustomerCartItemLinkRepo = new DefaultCrudRepository( + CustomerCartItemLink, + db, + ); reviewRepo = new DefaultCrudRepository(Review, db); } diff --git a/packages/repository/src/relations/has-many/has-many-through.repository.ts b/packages/repository/src/relations/has-many/has-many-through.repository.ts index 07380690476a..a57e4397caa7 100644 --- a/packages/repository/src/relations/has-many/has-many-through.repository.ts +++ b/packages/repository/src/relations/has-many/has-many-through.repository.ts @@ -4,6 +4,10 @@ // License text available at https://opensource.org/licenses/MIT import { + constrainDataObject, + constrainFilter, + constrainWhere, + constrainWhereOr, Count, DataObject, Entity, @@ -37,7 +41,7 @@ export interface HasManyThroughRepository< throughData?: DataObject; throughOptions?: Options; }, - ): Promise; + ): Promise; /** * Find target model instance(s) @@ -50,7 +54,7 @@ export interface HasManyThroughRepository< options?: Options & { throughOptions?: Options; }, - ): Promise; + ): Promise; /** * Delete multiple target model instances @@ -141,8 +145,24 @@ export class DefaultHasManyThroughRepository< throughData?: DataObject; throughOptions?: Options; }, - ): Promise { - throw new Error('Method not implemented.'); + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const targetInstance = await targetRepository.create( + targetModelData, + options, + ); + const targetConstraint = this.getThroughFkConstraint(targetInstance); + const throughConstraint = this.getThroughConstraint(); + const constraints = {...targetConstraint, ...throughConstraint}; + await throughRepository.create( + constrainDataObject( + options?.throughData ?? {}, + constraints as DataObject, + ), + options?.throughOptions, + ); + return targetInstance; } async find( @@ -150,8 +170,19 @@ export class DefaultHasManyThroughRepository< options?: Options & { throughOptions?: Options; }, - ): Promise { - throw new Error('Method not implemented.'); + ): Promise { + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + options?.throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.find( + constrainFilter(filter, targetConstraint), + options, + ); } async delete( @@ -160,9 +191,30 @@ export class DefaultHasManyThroughRepository< throughOptions?: Options; }, ): Promise { - throw new Error('Method not implemented.'); - } + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + options?.throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + // delete throughs that have the targets that are going to be deleted + const throughFkConstraint = this.getThroughFkConstraint( + targetConstraint as TargetEntity, + ); + await throughRepository.deleteAll( + constrainWhereOr({}, [throughConstraint, throughFkConstraint]), + ); + + // delete target(s) + return targetRepository.deleteAll( + constrainWhere(where, targetConstraint as Where), + options, + ); + } + // only allows patch target instances for now async patch( dataObject: DataObject, where?: Where, @@ -170,7 +222,19 @@ export class DefaultHasManyThroughRepository< throughOptions?: Options; }, ): Promise { - throw new Error('Method not implemented.'); + const targetRepository = await this.getTargetRepository(); + const throughRepository = await this.getThroughRepository(); + const throughConstraint = this.getThroughConstraint(); + const throughInstances = await throughRepository.find( + constrainFilter(undefined, throughConstraint), + options?.throughOptions, + ); + const targetConstraint = this.getTargetConstraint(throughInstances); + return targetRepository.updateAll( + constrainDataObject(dataObject, targetConstraint), + constrainWhere(where, targetConstraint as Where), + options, + ); } async link(