From 517c2012dd481f3b1c1556ea825052974894b33f Mon Sep 17 00:00:00 2001 From: Nora Date: Tue, 30 Jul 2019 13:54:04 -0400 Subject: [PATCH] feat(repository): add function findByForeignKeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented initial version of the new helper function findByForeignKeys that finds model instances that contain any of the provided foreign key values. Co-authored-by: Agnes Lin Co-authored-by: Miroslav Bajtoš --- .../src/crud/create-retrieve.suite.ts | 29 +++++- .../repositories/relation.helpers.unit.ts | 89 +++++++++++++++++++ packages/repository/src/relations/index.ts | 5 +- .../src/relations/relation.helpers.ts | 44 +++++++++ 4 files changed, 162 insertions(+), 5 deletions(-) create mode 100644 packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts create mode 100644 packages/repository/src/relations/relation.helpers.ts diff --git a/packages/repository-tests/src/crud/create-retrieve.suite.ts b/packages/repository-tests/src/crud/create-retrieve.suite.ts index 42725b9a2c8a..7ec364f8b6d6 100644 --- a/packages/repository-tests/src/crud/create-retrieve.suite.ts +++ b/packages/repository-tests/src/crud/create-retrieve.suite.ts @@ -7,6 +7,7 @@ import { AnyObject, Entity, EntityCrudRepository, + findByForeignKeys, model, property, } from '@loopback/repository'; @@ -42,6 +43,9 @@ export function createRetrieveSuite( @property({type: 'string', required: true}) name: string; + @property() + categoryId?: number; + constructor(data?: Partial) { super(data); } @@ -58,9 +62,13 @@ export function createRetrieveSuite( }), ); + beforeEach(async () => { + await repo.deleteAll(); + }); + it('retrieves a newly created model with id set by the database', async () => { - const created = await repo.create({name: 'Pencil'}); - expect(created.toObject()).to.have.properties('id', 'name'); + const created = await repo.create({name: 'Pencil', categoryId: 1}); + expect(created.toObject()).to.have.properties('id', 'name', 'categoryId'); expect(created.id).to.be.ok(); const found = await repo.findById(created.id); @@ -68,12 +76,27 @@ export function createRetrieveSuite( }); it('retrieves a newly created model when id was transformed via JSON', async () => { - const created = await repo.create({name: 'Pen'}); + const created = await repo.create({name: 'Pen', categoryId: 1}); expect(created.id).to.be.ok(); const id = (toJSON(created) as AnyObject).id; const found = await repo.findById(id); expect(toJSON(created)).to.deepEqual(toJSON(found)); }); + + it('retrieves an instance of a model from its foreign key value', async () => { + const pens = await repo.create({name: 'Pens', categoryId: 1}); + const pencils = await repo.create({name: 'Pencils', categoryId: 2}); + const products = await findByForeignKeys(repo, 'categoryId', [1]); + expect(products).deepEqual([pens]); + expect(products).to.not.containDeep(pencils); + }); + + it('retrieves instances of a model from their foreign key value', async () => { + const pens = await repo.create({name: 'Pens', categoryId: 1}); + const pencils = await repo.create({name: 'Pencils', categoryId: 2}); + const products = await findByForeignKeys(repo, 'categoryId', [1, 2]); + expect(products).deepEqual([pens, pencils]); + }); }); } diff --git a/packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts new file mode 100644 index 000000000000..216b3f7ff397 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts @@ -0,0 +1,89 @@ +// Copyright IBM Corp. 2019. 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 {DefaultCrudRepository, findByForeignKeys, juggler} from '../../..'; +import {model, property} from '../../../decorators'; +import {Entity} from '../../../model'; + +describe('findByForeignKeys', () => { + let productRepo: ProductRepository; + + before(() => { + productRepo = new ProductRepository(testdb); + }); + + beforeEach(async () => { + await productRepo.deleteAll(); + }); + + it('returns an empty array when no instances have the foreign key value', async () => { + await productRepo.create({id: 1, name: 'product', categoryId: 1}); + const products = await findByForeignKeys(productRepo, 'categoryId', [2]); + expect(products).to.be.empty(); + }); + + it('returns all instances that have the foreign key value', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({name: 'pencils', categoryId: 1}); + const products = await findByForeignKeys(productRepo, 'categoryId', [1]); + expect(products).to.deepEqual([pens, pencils]); + }); + + it('does not include instances with different foreign key values', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({name: 'pencils', categoryId: 2}); + const products = await findByForeignKeys(productRepo, 'categoryId', [1]); + expect(products).to.deepEqual([pens]); + expect(products).to.not.containDeep(pencils); + }); + + it('returns all instances that have any of multiple foreign key values', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({name: 'pencils', categoryId: 2}); + const paper = await productRepo.create({name: 'paper', categoryId: 3}); + const products = await findByForeignKeys(productRepo, 'categoryId', [1, 3]); + expect(products).to.deepEqual([pens, paper]); + expect(products).to.not.containDeep(pencils); + }); + + it('throws error if scope is passed in and is non-empty', async () => { + let errorMessage; + try { + await findByForeignKeys(productRepo, 'categoryId', [1], { + limit: 1, + }); + } catch (error) { + errorMessage = error.message; + } + expect(errorMessage).to.eql('scope is not supported'); + }); + + /******************* HELPERS *******************/ + + @model() + class Product extends Entity { + @property({id: true}) + id: number; + @property() + name: string; + @property() + categoryId: number; + } + + class ProductRepository extends DefaultCrudRepository< + Product, + typeof Product.prototype.id + > { + constructor(dataSource: juggler.DataSource) { + super(Product, dataSource); + } + } + + const testdb: juggler.DataSource = new juggler.DataSource({ + name: 'db', + connector: 'memory', + }); +}); diff --git a/packages/repository/src/relations/index.ts b/packages/repository/src/relations/index.ts index 90f9cf3c2259..24b78a7a1398 100644 --- a/packages/repository/src/relations/index.ts +++ b/packages/repository/src/relations/index.ts @@ -3,8 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT -export * from './relation.types'; -export * from './relation.decorator'; export * from './belongs-to'; export * from './has-many'; export * from './has-one'; +export * from './relation.decorator'; +export * from './relation.helpers'; +export * from './relation.types'; diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts new file mode 100644 index 000000000000..921c27570d19 --- /dev/null +++ b/packages/repository/src/relations/relation.helpers.ts @@ -0,0 +1,44 @@ +// Copyright IBM Corp. 2019. 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 * as _ from 'lodash'; +import {Entity, EntityCrudRepository, Filter, Options, Where} from '..'; + +/** + * Finds model instances that contain any of the provided foreign key values. + * + * @param targetRepository - The target repository where the model instances are found + * @param fkName - Name of the foreign key + * @param fkValues - Array of the values of the foreign keys to be included + * @param scope - Additional scope constraints (not currently supported) + * @param options - Options for the operations + */ +export async function findByForeignKeys< + Target extends Entity, + TargetID, + TargetRelations extends object, + ForeignKey +>( + targetRepository: EntityCrudRepository, + fkName: StringKeyOf, + fkValues: ForeignKey[], + scope?: Filter, + options?: Options, +): Promise<(Target & TargetRelations)[]> { + // throw error if scope is defined and non-empty + // see https://github.com/strongloop/loopback-next/issues/3453 + if (scope && !_.isEmpty(scope)) { + throw new Error('scope is not supported'); + } + + const where = ({ + [fkName]: fkValues.length === 1 ? fkValues[0] : {inq: fkValues}, + } as unknown) as Where; + const targetFilter = {where}; + + return targetRepository.find(targetFilter, options); +} + +export type StringKeyOf = Extract;