diff --git a/packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.helpers.unit.ts similarity index 78% rename from packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts rename to packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.helpers.unit.ts index 87f6dc79127a..71fad2147584 100644 --- a/packages/repository/src/__tests__/unit/repositories/relation.helpers.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/find-by-foreign-keys.helpers.unit.ts @@ -4,9 +4,8 @@ // 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'; +import {findByForeignKeys} from '../../../..'; +import {ProductRepository, testdb} from './relations-helpers-fixtures'; describe('findByForeignKeys', () => { let productRepo: ProductRepository; @@ -37,6 +36,7 @@ describe('findByForeignKeys', () => { const products = await findByForeignKeys(productRepo, 'categoryId', [2, 3]); 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}); @@ -59,6 +59,7 @@ describe('findByForeignKeys', () => { expect(products).to.deepEqual([pencils]); expect(products).to.not.containDeep(pens); }); + 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}); @@ -69,15 +70,9 @@ describe('findByForeignKeys', () => { }); 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'); + await expect( + findByForeignKeys(productRepo, 'categoryId', [1], {limit: 1}), + ).to.be.rejectedWith('scope is not supported'); }); it('does not throw an error if scope is passed in and is undefined or empty', async () => { @@ -92,29 +87,4 @@ describe('findByForeignKeys', () => { products = await findByForeignKeys(productRepo, 'categoryId', 1, {}, {}); expect(products).to.be.empty(); }); - /******************* 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/__tests__/unit/repositories/relations-helpers/include-related-models.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.helpers.unit.ts new file mode 100644 index 000000000000..12b7693c1b18 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/include-related-models.helpers.unit.ts @@ -0,0 +1,212 @@ +// 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, toJSON} from '@loopback/testlab'; +import {includeRelatedModels, InclusionResolver} from '../../../..'; +import { + Category, + CategoryRepository, + Product, + ProductRepository, + testdb, +} from './relations-helpers-fixtures'; + +describe('includeRelatedModels', () => { + let productRepo: ProductRepository; + let categoryRepo: CategoryRepository; + + before(() => { + productRepo = new ProductRepository(testdb); + categoryRepo = new CategoryRepository(testdb, async () => productRepo); + }); + + beforeEach(async () => { + await productRepo.deleteAll(); + await categoryRepo.deleteAll(); + }); + + it("defines a repository's inclusionResolvers property", () => { + expect(categoryRepo.inclusionResolvers).to.not.be.undefined(); + expect(productRepo.inclusionResolvers).to.not.be.undefined(); + }); + + it('returns source model if no filter is passed in', async () => { + const category = await categoryRepo.create({name: 'category 1'}); + await categoryRepo.create({name: 'category 2'}); + const result = await includeRelatedModels(categoryRepo, [category]); + expect(result).to.eql([category]); + }); + + it('throws error if the target repository does not have the registered resolver', async () => { + const category = await categoryRepo.create({name: 'category 1'}); + await expect( + includeRelatedModels(categoryRepo, [category], [{relation: 'products'}]), + ).to.be.rejectedWith( + /Invalid "filter.include" entries: {"relation":"products"}/, + ); + }); + + it('returns an empty array if target model of the source entity does not have any matched instances', async () => { + const category = await categoryRepo.create({name: 'category'}); + + categoryRepo.inclusionResolvers.set('products', hasManyResolver); + + const categories = await includeRelatedModels( + categoryRepo, + [category], + [{relation: 'products'}], + ); + + expect(categories[0].products).to.be.empty(); + }); + + it('includes related model for one instance - belongsTo', async () => { + const category = await categoryRepo.create({name: 'category'}); + const product = await productRepo.create({ + name: 'product', + categoryId: category.id, + }); + + productRepo.inclusionResolvers.set('category', belongsToResolver); + + const productWithCategories = await includeRelatedModels( + productRepo, + [product], + [{relation: 'category'}], + ); + + expect(productWithCategories[0].toJSON()).to.deepEqual({ + ...product.toJSON(), + category: category.toJSON(), + }); + }); + + it('includes related model for more than one instance - belongsTo', async () => { + const categoryOne = await categoryRepo.create({name: 'category 1'}); + const productOne = await productRepo.create({ + name: 'product 1', + categoryId: categoryOne.id, + }); + + const categoryTwo = await categoryRepo.create({name: 'category 2'}); + const productTwo = await productRepo.create({ + name: 'product 2', + categoryId: categoryTwo.id, + }); + + const productThree = await productRepo.create({ + name: 'product 3', + categoryId: categoryTwo.id, + }); + + productRepo.inclusionResolvers.set('category', belongsToResolver); + + const productWithCategories = await includeRelatedModels( + productRepo, + [productOne, productTwo, productThree], + [{relation: 'category'}], + ); + + expect(toJSON(productWithCategories)).to.deepEqual([ + {...productOne.toJSON(), category: categoryOne.toJSON()}, + {...productTwo.toJSON(), category: categoryTwo.toJSON()}, + {...productThree.toJSON(), category: categoryTwo.toJSON()}, + ]); + }); + + it('includes related models for one instance - hasMany', async () => { + const category = await categoryRepo.create({name: 'category'}); + const productOne = await productRepo.create({ + name: 'product 1', + categoryId: category.id, + }); + + const productTwo = await productRepo.create({ + name: 'product 2', + categoryId: category.id, + }); + + categoryRepo.inclusionResolvers.set('products', hasManyResolver); + + const categoryWithProducts = await includeRelatedModels( + categoryRepo, + [category], + [{relation: 'products'}], + ); + + expect(toJSON(categoryWithProducts)).to.deepEqual([ + { + ...category.toJSON(), + products: [productOne.toJSON(), productTwo.toJSON()], + }, + ]); + }); + + it('includes related models for more than one instance - hasMany', async () => { + const categoryOne = await categoryRepo.create({name: 'category 1'}); + const productOne = await productRepo.create({ + name: 'product 1', + categoryId: categoryOne.id, + }); + + const categoryTwo = await categoryRepo.create({name: 'category 2'}); + const productTwo = await productRepo.create({ + name: 'product 2', + categoryId: categoryTwo.id, + }); + + const categoryThree = await categoryRepo.create({name: 'category 3'}); + const productThree = await productRepo.create({ + name: 'product 3', + categoryId: categoryTwo.id, + }); + + categoryRepo.inclusionResolvers.set('products', hasManyResolver); + + const categoryWithProducts = await includeRelatedModels( + categoryRepo, + [categoryOne, categoryTwo, categoryThree], + [{relation: 'products'}], + ); + + expect(toJSON(categoryWithProducts)).to.deepEqual([ + {...categoryOne.toJSON(), products: [productOne.toJSON()]}, + { + ...categoryTwo.toJSON(), + products: [productTwo.toJSON(), productThree.toJSON()], + }, + {...categoryThree.toJSON(), products: []}, + ]); + }); + + // stubbed resolvers + + const belongsToResolver: InclusionResolver< + Product, + Category + > = async entities => { + const categories = []; + + for (const product of entities) { + const category = await categoryRepo.findById(product.categoryId); + categories.push(category); + } + + return categories; + }; + + const hasManyResolver: InclusionResolver< + Category, + Product + > = async entities => { + const products = []; + + for (const category of entities) { + const product = await categoryRepo.products(category.id).find(); + products.push(product); + } + return products; + }; +}); diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts new file mode 100644 index 000000000000..f6f0ec735256 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/relations-helpers-fixtures.ts @@ -0,0 +1,87 @@ +// 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 { + belongsTo, + BelongsToAccessor, + DefaultCrudRepository, + Entity, + Getter, + hasMany, + HasManyRepositoryFactory, + juggler, + model, + property, +} from '../../../..'; + +@model() +export class Product extends Entity { + @property({id: true}) + id: number; + @property() + name: string; + @belongsTo(() => Category) + categoryId: number; +} + +export class ProductRepository extends DefaultCrudRepository< + Product, + typeof Product.prototype.id +> { + public readonly category: BelongsToAccessor< + Category, + typeof Product.prototype.id + >; + constructor( + dataSource: juggler.DataSource, + categoryRepository?: Getter, + ) { + super(Product, dataSource); + if (categoryRepository) + this.category = this.createBelongsToAccessorFor( + 'category', + categoryRepository, + ); + } +} + +@model() +export class Category extends Entity { + @property({id: true}) + id?: number; + @property() + name: string; + @hasMany(() => Product, {keyTo: 'categoryId'}) + products?: Product[]; +} +interface CategoryRelations { + products?: Product[]; +} + +export class CategoryRepository extends DefaultCrudRepository< + Category, + typeof Category.prototype.id, + CategoryRelations +> { + public readonly products: HasManyRepositoryFactory< + Product, + typeof Category.prototype.id + >; + constructor( + dataSource: juggler.DataSource, + productRepository: Getter, + ) { + super(Category, dataSource); + this.products = this.createHasManyRepositoryFactoryFor( + 'products', + productRepository, + ); + } +} + +export const testdb: juggler.DataSource = new juggler.DataSource({ + name: 'db', + connector: 'memory', +}); diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index 17d8860e50fa..a79674e7b63a 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -3,8 +3,18 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import * as debugFactory from 'debug'; import * as _ from 'lodash'; -import {Entity, EntityCrudRepository, Filter, Options, Where} from '..'; +import { + AnyObject, + Entity, + EntityCrudRepository, + Filter, + Inclusion, + Options, + Where, +} from '..'; +const debug = debugFactory('loopback:repository:relation-helpers'); /** * Finds model instances that contain any of the provided foreign key values. @@ -48,3 +58,77 @@ export async function findByForeignKeys< } type StringKeyOf = Extract; + +/** + * Returns model instances that include related models that have a registered + * resolver. + * + * @param targetRepository - The target repository where the model instances are found + * @param entities - An array of entity instances or data + * @param include -Inclusion filter + * @param options - Options for the operations + */ + +export async function includeRelatedModels< + T extends Entity, + Relations extends object = {} +>( + targetRepository: EntityCrudRepository, + entities: T[], + include?: Inclusion[], + options?: Options, +): Promise<(T & Relations)[]> { + const result = entities as (T & Relations)[]; + if (!include) return result; + + const invalidInclusions = include.filter( + inclusionFilter => !isInclusionAllowed(targetRepository, inclusionFilter), + ); + if (invalidInclusions.length) { + const msg = + 'Invalid "filter.include" entries: ' + + invalidInclusions + .map(inclusionFilter => JSON.stringify(inclusionFilter)) + .join('; '); + const err = new Error(msg); + Object.assign(err, { + code: 'INVALID_INCLUSION_FILTER', + }); + throw err; + } + + const resolveTasks = include.map(async inclusionFilter => { + const relationName = inclusionFilter.relation; + const resolver = targetRepository.inclusionResolvers.get(relationName)!; + const targets = await resolver(entities, inclusionFilter, options); + + result.forEach((entity, ix) => { + const src = entity as AnyObject; + src[relationName] = targets[ix]; + }); + }); + + await Promise.all(resolveTasks); + + return result; +} +/** + * Checks if the resolver of the inclusion relation is registered + * in the inclusionResolver of the target repository + * + * @param targetRepository - The target repository where the relations are registered + * @param include - Inclusion filter + */ +function isInclusionAllowed( + targetRepository: EntityCrudRepository, + include: Inclusion, +): boolean { + const relationName = include.relation; + if (!relationName) { + debug('isInclusionAllowed for %j? No: missing relation name', include); + return false; + } + const allowed = targetRepository.inclusionResolvers.has(relationName); + debug('isInclusionAllowed for %j (relation %s)? %s', include, allowed); + return allowed; +} diff --git a/packages/repository/src/relations/relation.types.ts b/packages/repository/src/relations/relation.types.ts index 7c75f4150228..1bed6582653b 100644 --- a/packages/repository/src/relations/relation.types.ts +++ b/packages/repository/src/relations/relation.types.ts @@ -3,7 +3,9 @@ // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT +import {Options} from '../common-types'; import {Entity} from '../model'; +import {Inclusion} from '../query'; import {TypeResolver} from '../type-resolver'; export enum RelationType { @@ -108,3 +110,25 @@ export type RelationMetadata = // Re-export Getter so that users don't have to import from @loopback/context export {Getter} from '@loopback/context'; + +/** + * @returns An array of resolved values, the items must be ordered in the same + * way as `sourceEntities`. The resolved value can be one of: + * - `undefined` when no target model(s) were found + * - `Entity` for relations targeting a single model + * - `Entity[]` for relations targeting multiple models + */ +export type InclusionResolver = ( + /** + * List of source models as returned by the first database query. + */ + sourceEntities: S[], + /** + * Inclusion requested by the user (e.g. scope constraints to apply). + */ + inclusion: Inclusion, + /** + * Generic options object, e.g. carrying the Transaction object. + */ + options?: Options, +) => Promise<(T | undefined)[] | (T[] | undefined)[]>; diff --git a/packages/repository/src/repositories/legacy-juggler-bridge.ts b/packages/repository/src/repositories/legacy-juggler-bridge.ts index 03da88d77edf..e9a31d210897 100644 --- a/packages/repository/src/repositories/legacy-juggler-bridge.ts +++ b/packages/repository/src/repositories/legacy-juggler-bridge.ts @@ -28,6 +28,7 @@ import { HasManyRepositoryFactory, HasOneDefinition, HasOneRepositoryFactory, + InclusionResolver, } from '../relations'; import {IsolationLevel, Transaction} from '../transaction'; import {isTypeResolver, resolveType} from '../type-resolver'; @@ -100,6 +101,11 @@ export class DefaultCrudRepository< > implements EntityCrudRepository { modelClass: juggler.PersistedModelClass; + public readonly inclusionResolvers: Map< + string, + InclusionResolver + > = new Map(); + /** * Constructor of DefaultCrudRepository * @param entityClass - Legacy entity class diff --git a/packages/repository/src/repositories/repository.ts b/packages/repository/src/repositories/repository.ts index 0159d55fd782..7d96ecad9898 100644 --- a/packages/repository/src/repositories/repository.ts +++ b/packages/repository/src/repositories/repository.ts @@ -17,6 +17,7 @@ import {DataSource} from '../datasource'; import {EntityNotFoundError} from '../errors'; import {Entity, Model, ValueObject} from '../model'; import {Filter, Where} from '../query'; +import {InclusionResolver} from '../relations/relation.types'; import {IsolationLevel, Transaction} from '../transaction'; /* eslint-disable @typescript-eslint/no-unused-vars */ @@ -140,6 +141,7 @@ export interface EntityCrudRepository< > extends EntityRepository, CrudRepository { // entityClass should have type "typeof T", but that's not supported by TSC entityClass: typeof Entity & {prototype: T}; + inclusionResolvers: Map>; /** * Save an entity. If no id is present, create a new entity @@ -246,6 +248,10 @@ export interface EntityCrudRepository< export class CrudRepositoryImpl implements EntityCrudRepository { private connector: CrudConnector; + public readonly inclusionResolvers: Map< + string, + InclusionResolver + > = new Map(); constructor( public dataSource: DataSource,