From aa13a8b4fa08ea1a520047f194375399a7465d45 Mon Sep 17 00:00:00 2001 From: Agnes Lin Date: Wed, 11 Sep 2019 15:44:21 -0400 Subject: [PATCH] feat(repository): implement inclusion resolver for belongsTo relation Co-authored-by: Miroslav --- docs/site/BelongsTo-relation.md | 108 ++++++++++ .../repository-tests/src/crud-test-suite.ts | 1 + ....inclusion-resolver.relation.acceptance.ts | 195 ++++++++++++++++++ .../belongs-to.relation.acceptance.ts | 8 +- .../has-many.relation.acceptance.ts | 1 + .../acceptance/has-one.relation.acceptance.ts | 1 + .../src/crud/relations/helpers.ts | 22 +- .../src/types.repository-tests.ts | 8 + ...ets-of-one-to-one-relation.helpers.unit.ts | 89 ++++++++ .../relations-helpers-fixtures.ts | 71 ++++++- .../belongs-to/belongs-to-accessor.ts | 32 ++- .../belongs-to/belongs-to.helpers.ts | 8 +- .../belongs-to.inclusion-resolver.ts | 67 ++++++ .../src/relations/belongs-to/index.ts | 1 + .../src/relations/relation.helpers.ts | 31 ++- 15 files changed, 630 insertions(+), 13 deletions(-) create mode 100644 packages/repository-tests/src/crud/relations/acceptance/belongs-to.inclusion-resolver.relation.acceptance.ts create mode 100644 packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts create mode 100644 packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts diff --git a/docs/site/BelongsTo-relation.md b/docs/site/BelongsTo-relation.md index 7c4eaf928b0d..d84d3ce59d2e 100644 --- a/docs/site/BelongsTo-relation.md +++ b/docs/site/BelongsTo-relation.md @@ -267,3 +267,111 @@ DO NOT declare `@repository.getter(CategoryRepository) protected categoryRepositoryGetter: Getter` on constructor to avoid "Circular dependency" error (see [issue #2118](https://github.com/strongloop/loopback-next/issues/2118)) + +## Querying related models + +LoopBack 4 has the concept of an `inclusion resolver` in relations, which helps +to query data through an `include` filter. An inclusion resolver is a function +that can fetch target models for the given list of source model instances. +LoopBack 4 creates a different inclusion resolver for each relation type. + +The following is an example for using BelongsTo inclusion resolvers: + +Use the relation between `Customer` and `Order` we show above, an `Order` +belongs to a `Customer`. + +After setting up the relation in the repository class, the inclusion resolver +allows users to retrieve all orders along with their related customers through +the following code: + +```ts +orderRepo.find({include: [{relation: 'customer'}]}); +``` + +### Enable/disable the inclusion resolvers: + +- Base repository classes have a public property `inclusionResolvers`, which + maintains a map containing inclusion resolvers for each relation. +- The `inclusionResolver` of a certain relation is built when the source + repository class calls the `createBelongsToAccessorFor` function in the + constructor with the relation name. +- Call `registerInclusionResolver` to add the resolver of that relation to the + `inclusionResolvers` map. (As we realized in LB3, not all relations are + allowed to be traversed. Users can decide to which resolvers can be added.) + The first parameter is the name of the relation. + +The following code snippet shows how to register the inclusion resolver for the +belongsTo relation 'customer': + +```ts +export class OrderRepository extends DefaultCrudRepository { + customer: BelongsToAccessor; + + constructor( + dataSource: juggler.DataSource, + customerRepositoryGetter: Getter, + ) { + super(Order, dataSource); + + // we already have this line to create a BelongsToRepository factory + this.customer = this.createBelongsToAccessorFor( + 'customer', + customerRepositoryGetter, + ); + + // add this line to register inclusion resolver. + this.registerInclusion('customer', this.customer.inclusionResolver); + } +} +``` + +- We can simply include the relation in queries via `find()`, `findOne()`, and + `findById()` methods. Example: + + ```ts + orderRepository.find({include: [{relation: 'customer'}]}); + ``` + + which returns: + + ```ts + [ + { + id: 1, + description: 'Mjolnir', + customerId: 1, + customer: { + id: 12, + name: 'Thor', + }, + }, + { + id: 2, + description: 'Shield', + customer: { + id: 10, + name: 'Captain', + }, + }, + { + id: 3, + description: 'Rocket Raccoon', + customerId: 1, + customer: { + id: 12, + name: 'Thor', + }, + }, + ]; + ``` + +- You can delete a relation from `inclusionResolvers` to disable the inclusion + for a certain relation. e.g + `orderRepository.inclusionResolvers.delete('customer')` + +{% include note.html content=" +Inclusion with custom scope: +Besides specifying the relation name to include, it's also possible to specify additional scope constraints. +However, this feature is not supported yet. Check our GitHub issue for more information: +[Include related models with a custom scope](https://github.com/strongloop/loopback-next/issues/3453). +" %} diff --git a/packages/repository-tests/src/crud-test-suite.ts b/packages/repository-tests/src/crud-test-suite.ts index 6ea0f927d579..057edd762fc5 100644 --- a/packages/repository-tests/src/crud-test-suite.ts +++ b/packages/repository-tests/src/crud-test-suite.ts @@ -33,6 +33,7 @@ export function crudRepositoryTestSuite( freeFormProperties: true, emptyValue: undefined, supportsTransactions: true, + supportsInclusionResolvers: true, ...partialFeatures, }; diff --git a/packages/repository-tests/src/crud/relations/acceptance/belongs-to.inclusion-resolver.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/belongs-to.inclusion-resolver.relation.acceptance.ts new file mode 100644 index 000000000000..c2a7ed8a7d37 --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/belongs-to.inclusion-resolver.relation.acceptance.ts @@ -0,0 +1,195 @@ +// Copyright IBM Corp. 2019. All Rights Reserved. +// Node module: @loopback/repository-tests +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {expect, skipIf, toJSON} from '@loopback/testlab'; +import {Suite} from 'mocha'; +import { + CrudFeatures, + CrudRepositoryCtor, + CrudTestContext, + DataSourceOptions, +} from '../../..'; +import { + deleteAllModelsInDefaultDataSource, + MixedIdType, + withCrudCtx, +} from '../../../helpers.repository-tests'; +import { + Customer, + CustomerRepository, + Order, + OrderRepository, +} from '../fixtures/models'; +import {givenBoundCrudRepositories} from '../helpers'; + +export function belongsToInclusionResolverAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !features.supportsInclusionResolvers, + describe, + 'BelongsTo inclusion resolvers - acceptance', + suite, + ); + function suite() { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + let existingCustomerId: MixedIdType; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + // this helper should create the inclusion resolvers and also + // register inclusion resolvers for us + ({customerRepo, orderRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + features, + )); + expect(orderRepo.customer.inclusionResolver).to.be.Function(); + + await ctx.dataSource.automigrate([Customer.name, Order.name]); + }), + ); + + beforeEach(async () => { + await customerRepo.deleteAll(); + await orderRepo.deleteAll(); + }); + + it('throws an error if it tries to query nonexists relation names', async () => { + await orderRepo.create({ + description: 'shiba', + customerId: existingCustomerId, + }); + await expect( + orderRepo.find({include: [{relation: 'shipment'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"shipment"}`, + ); + }); + + it('returns single model instance including single related instance', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const order = await orderRepo.create({ + description: 'Mjolnir', + customerId: thor.id, + }); + const result = await orderRepo.find({ + include: [{relation: 'customer'}], + }); + + const expected = { + ...order, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + customer: { + ...thor, + parentId: features.emptyValue, + }, + }; + expect(toJSON(result)).to.deepEqual([toJSON(expected)]); + }); + + it('returns multiple model instances including related instances', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const odin = await customerRepo.create({name: 'Odin'}); + const thorOrder = await orderRepo.create({ + description: "Thor's Mjolnir", + customerId: thor.id, + }); + const odinOrder = await orderRepo.create({ + description: "Odin's Coffee Maker", + customerId: odin.id, + }); + + const result = await orderRepo.find({ + include: [{relation: 'customer'}], + }); + + const expected = [ + { + ...thorOrder, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + customer: { + ...thor, + parentId: features.emptyValue, + }, + }, + { + ...odinOrder, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + customer: { + ...odin, + parentId: features.emptyValue, + }, + }, + ]; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('returns a specified instance including its related model instances', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const odin = await customerRepo.create({name: 'Odin'}); + await orderRepo.create({ + description: "Thor's Mjolnir", + customerId: thor.id, + }); + const odinOrder = await orderRepo.create({ + description: "Odin's Coffee Maker", + customerId: odin.id, + }); + + const result = await orderRepo.findById(odinOrder.id, { + include: [{relation: 'customer'}], + }); + const expected = { + ...odinOrder, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + customer: { + ...odin, + parentId: features.emptyValue, + }, + }; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + // scope for inclusion is not supported yet + it('throws error if the inclusion query contains a non-empty scope', async () => { + await orderRepo.create({ + description: 'shiba', + customerId: existingCustomerId, + }); + await expect( + orderRepo.find({ + include: [{relation: 'customer', scope: {limit: 1}}], + }), + ).to.be.rejectedWith(`scope is not supported`); + }); + + it('throws error if the target repository does not have the registered resolver', async () => { + await orderRepo.create({ + description: 'shiba', + customerId: existingCustomerId, + }); + // unregister the resolver + orderRepo.inclusionResolvers.delete('customer'); + + await expect( + orderRepo.find({include: [{relation: 'customer'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"customer"}`, + ); + }); + } +} diff --git a/packages/repository-tests/src/crud/relations/acceptance/belongs-to.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/belongs-to.relation.acceptance.ts index 995e9a65c8c1..b65ef2b4a472 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/belongs-to.relation.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/belongs-to.relation.acceptance.ts @@ -52,6 +52,7 @@ export function belongsToRelationAcceptance( ({customerRepo, orderRepo, shipmentRepo} = givenBoundCrudRepositories( ctx.dataSource, repositoryClass, + features, )); const models = [Customer, Order, Shipment]; await ctx.dataSource.automigrate(models.map(m => m.name)); @@ -93,15 +94,20 @@ export function belongsToRelationAcceptance( }); it('throws EntityNotFound error when the related model does not exist', async () => { + const deletedCustomer = await customerRepo.create({ + name: 'Order McForder', + }); const order = await orderRepo.create({ - customerId: 999, // does not exist + customerId: deletedCustomer.id, // does not exist description: 'Order of a fictional customer', }); + await customerRepo.deleteAll(); await expect(findCustomerOfOrder(order.id)).to.be.rejectedWith( EntityNotFoundError, ); }); + // helpers function givenAccessor() { findCustomerOfOrder = createBelongsToAccessor( diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-many.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many.relation.acceptance.ts index 905aaacc57de..106cc3864049 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/has-many.relation.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many.relation.acceptance.ts @@ -40,6 +40,7 @@ export function hasManyRelationAcceptance( ({customerRepo, orderRepo} = givenBoundCrudRepositories( ctx.dataSource, repositoryClass, + features, )); await ctx.dataSource.automigrate([Customer.name, Order.name]); }), diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-one.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-one.relation.acceptance.ts index a8b791a9573d..acd97bf1de6f 100644 --- a/packages/repository-tests/src/crud/relations/acceptance/has-one.relation.acceptance.ts +++ b/packages/repository-tests/src/crud/relations/acceptance/has-one.relation.acceptance.ts @@ -41,6 +41,7 @@ export function hasOneRelationAcceptance( ({customerRepo, addressRepo} = givenBoundCrudRepositories( ctx.dataSource, repositoryClass, + features, )); const models = [Customer, Address]; await ctx.dataSource.automigrate(models.map(m => m.name)); diff --git a/packages/repository-tests/src/crud/relations/helpers.ts b/packages/repository-tests/src/crud/relations/helpers.ts index 902ddb16a886..44e93388e728 100644 --- a/packages/repository-tests/src/crud/relations/helpers.ts +++ b/packages/repository-tests/src/crud/relations/helpers.ts @@ -4,10 +4,12 @@ // License text available at https://opensource.org/licenses/MIT import {juggler} from '@loopback/repository'; -import {CrudRepositoryCtor} from '../..'; +import {CrudFeatures, CrudRepositoryCtor} from '../..'; import { + Address, AddressRepository, CustomerRepository, + Order, OrderRepository, ShipmentRepository, } from './fixtures/models'; @@ -21,7 +23,20 @@ import { export function givenBoundCrudRepositories( db: juggler.DataSource, repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, ) { + // when running the test suite on MongoDB, we don't really need to setup + // this config for mongo connector to pass the test. + // however real-world applications might have such config for MongoDB + // setting it up to check if it works fine as well + Order.definition.properties.customerId.type = features.idType; + Order.definition.properties.customerId.mongodb = { + dataType: 'ObjectID', + }; + Address.definition.properties.customerId.type = features.idType; + Address.definition.properties.customerId.mongodb = { + dataType: 'ObjectID', + }; // get the repository class and create a new instance of it const customerRepoClass = createCustomerRepo(repositoryClass); const customerRepo: CustomerRepository = new customerRepoClass( @@ -36,6 +51,11 @@ export function givenBoundCrudRepositories( async () => customerRepo, async () => shipmentRepo, ); + // register the inclusionResolvers here for orderRepo + orderRepo.inclusionResolvers.set( + 'customer', + orderRepo.customer.inclusionResolver, + ); const shipmentRepoClass = createShipmentRepo(repositoryClass); const shipmentRepo: ShipmentRepository = new shipmentRepoClass( diff --git a/packages/repository-tests/src/types.repository-tests.ts b/packages/repository-tests/src/types.repository-tests.ts index a37cc4e7b251..c41e335a53be 100644 --- a/packages/repository-tests/src/types.repository-tests.ts +++ b/packages/repository-tests/src/types.repository-tests.ts @@ -56,6 +56,14 @@ export interface CrudFeatures { * Default: `false` */ supportsTransactions: boolean; + + /** + * Does the repository provide `inclusionResolvers` object where resolvers + * can be registered? + * + * Default: `true` + */ + supportsInclusionResolvers: boolean; } /** diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-relation.helpers.unit.ts new file mode 100644 index 000000000000..643a5235f70b --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-one-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 {flattenTargetsOfOneToOneRelation} from '../../../..'; +import { + createCategory, + createManufacturer, + createProduct, +} from './relations-helpers-fixtures'; + +describe('flattenTargetsOfOneToOneRelation', () => { + describe('uses reduceAsSingleItem strategy for belongsTo relation', () => { + it('gets the result of passing in a single sourceId', () => { + const stationery = createCategory({id: 1, name: 'stationery'}); + const pen = createProduct({name: 'pen', categoryId: stationery.id}); + createProduct({name: 'eraser', categoryId: 2}); + + const result = flattenTargetsOfOneToOneRelation( + [pen.categoryId], + [stationery], + 'id', + ); + expect(result).to.eql([stationery]); + }); + + it('gets the result of passing in multiple sourceIds', () => { + const stationery = createCategory({id: 1, name: 'stationery'}); + const book = createCategory({id: 2, name: 'book'}); + const pen = createProduct({name: 'pen', categoryId: stationery.id}); + const pencil = createProduct({ + name: 'pencil', + categoryId: stationery.id, + }); + const erasers = createProduct({name: 'eraser', categoryId: book.id}); + // the order of sourceIds matters + const result = flattenTargetsOfOneToOneRelation( + [erasers.categoryId, pencil.categoryId, pen.categoryId], + [book, stationery, stationery], + 'id', + ); + expect(result).to.deepEqual([book, stationery, stationery]); + }); + }); + + describe('uses reduceAsSingleItem strategy for hasOne relation', () => { + it('gets the result of passing in a single sourceId', () => { + const pen = createProduct({id: 1, name: 'pen'}); + const penMaker = createManufacturer({ + name: 'Mr. Plastic', + productId: pen.id, + }); + + const result = flattenTargetsOfOneToOneRelation( + [pen.id], + [penMaker], + 'productId', + ); + expect(result).to.eql([penMaker]); + }); + + it('gets the result of passing in multiple sourceIds', () => { + const pen = createProduct({id: 1, name: 'pen'}); + const pencil = createProduct({id: 2, name: 'pencil'}); + const eraser = createProduct({id: 3, name: 'eraser'}); + const penMaker = createManufacturer({ + name: 'Mr. Plastic', + productId: pen.id, + }); + const pencilMaker = createManufacturer({ + name: 'Mr. Tree', + productId: pencil.id, + }); + const eraserMaker = createManufacturer({ + name: 'Mr. Rubber', + productId: eraser.id, + }); + // the order of sourceIds matters + const result = flattenTargetsOfOneToOneRelation( + [eraser.id, pencil.id, pen.id], + [penMaker, pencilMaker, eraserMaker], + 'productId', + ); + expect(result).to.deepEqual([eraserMaker, pencilMaker, penMaker]); + }); + }); +}); 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 index d6aa0f49cef2..c25d7010234c 100644 --- 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 @@ -11,17 +11,61 @@ import { Getter, hasMany, HasManyRepositoryFactory, + hasOne, + HasOneRepositoryFactory, juggler, model, property, } from '../../../..'; +@model() +export class Manufacturer extends Entity { + @property({id: true}) + id: number; + @property() + name: string; + @belongsTo(() => Product) + productId: number; + + constructor(data: Partial) { + super(data); + } +} +interface ManufacturerRelations { + products?: ProductWithRelations; +} +type ManufacturerWithRelations = Manufacturer & ManufacturerRelations; + +export class ManufacturerRepository extends DefaultCrudRepository< + Manufacturer, + typeof Manufacturer.prototype.id, + ManufacturerRelations +> { + public readonly product: BelongsToAccessor< + Product, + typeof Manufacturer.prototype.id + >; + constructor( + dataSource: juggler.DataSource, + productRepository?: Getter, + ) { + super(Manufacturer, dataSource); + if (productRepository) + this.product = this.createBelongsToAccessorFor( + 'product', + productRepository, + ); + } +} + @model() export class Product extends Entity { @property({id: true}) id: number; @property() name: string; + @hasOne(() => Manufacturer) + manufacturer: Manufacturer; @belongsTo(() => Category) categoryId: number; @@ -29,18 +73,29 @@ export class Product extends Entity { super(data); } } +interface ProductRelations { + manufacturer?: ManufacturerRelations; +} + +type ProductWithRelations = Product & ProductRelations; export class ProductRepository extends DefaultCrudRepository< Product, - typeof Product.prototype.id + typeof Product.prototype.id, + ProductRelations > { public readonly category: BelongsToAccessor< Category, typeof Product.prototype.id >; + public readonly manufacturer: HasOneRepositoryFactory< + Manufacturer, + typeof Product.prototype.id + >; constructor( dataSource: juggler.DataSource, categoryRepository?: Getter, + manufacturerRepository?: Getter, ) { super(Product, dataSource); if (categoryRepository) @@ -48,6 +103,11 @@ export class ProductRepository extends DefaultCrudRepository< 'category', categoryRepository, ); + if (manufacturerRepository) + this.manufacturer = this.createHasOneRepositoryFactoryFor( + 'manufacturer', + manufacturerRepository, + ); } } @@ -66,6 +126,7 @@ export class Category extends Entity { interface CategoryRelations { products?: Product[]; } +type CategoryWithRelations = Category & CategoryRelations; export class CategoryRepository extends DefaultCrudRepository< Category, @@ -94,9 +155,13 @@ export const testdb: juggler.DataSource = new juggler.DataSource({ }); export function createCategory(properties: Partial) { - return new Category(properties as Category); + return new Category(properties); } export function createProduct(properties: Partial) { - return new Product(properties as Product); + return new Product(properties); +} + +export function createManufacturer(properties: Partial) { + return new Manufacturer(properties); } diff --git a/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts b/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts index 23ecb636d0cc..84458227ebfe 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to-accessor.ts @@ -7,15 +7,28 @@ import * as debugFactory from 'debug'; import {DataObject} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories/repository'; -import {BelongsToDefinition, Getter} from '../relation.types'; +import { + BelongsToDefinition, + Getter, + InclusionResolver, +} from '../relation.types'; import {resolveBelongsToMetadata} from './belongs-to.helpers'; import {DefaultBelongsToRepository} from './belongs-to.repository'; +import {createBelongsToInclusionResolver} from './belongs-to.inclusion-resolver'; const debug = debugFactory('loopback:repository:belongs-to-accessor'); -export type BelongsToAccessor = ( - sourceId: SourceId, -) => Promise; +export interface BelongsToAccessor { + /** + * Invoke the function to obtain HasManyRepository. + */ + (sourceId: SourceId): Promise; + + /** + * Use `resolver` property to obtain an InclusionResolver for this relation. + */ + inclusionResolver: InclusionResolver; +} /** * Enforces a BelongsTo constraint on a repository @@ -32,7 +45,10 @@ export function createBelongsToAccessor< ): BelongsToAccessor { const meta = resolveBelongsToMetadata(belongsToMetadata); debug('Resolved BelongsTo relation metadata: %o', meta); - return async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { + const result: BelongsToAccessor< + Target, + SourceId + > = async function getTargetInstanceOfBelongsTo(sourceId: SourceId) { const foreignKey = meta.keyFrom; const primaryKey = meta.keyTo; const sourceModel = await sourceRepository.findById(sourceId); @@ -45,4 +61,10 @@ export function createBelongsToAccessor< ); return constrainedRepo.get(); }; + + result.inclusionResolver = createBelongsToInclusionResolver( + meta, + targetRepoGetter, + ); + return result; } diff --git a/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts b/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts index 13acf53b2cd8..27c21b496646 100644 --- a/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts +++ b/packages/repository/src/relations/belongs-to/belongs-to.helpers.ts @@ -2,10 +2,11 @@ // Node module: @loopback/repository // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT + import * as debugFactory from 'debug'; import {InvalidRelationError} from '../../errors'; import {isTypeResolver} from '../../type-resolver'; -import {BelongsToDefinition} from '../relation.types'; +import {BelongsToDefinition, RelationType} from '../relation.types'; const debug = debugFactory('loopback:repository:belongs-to-helpers'); @@ -23,6 +24,11 @@ export type BelongsToResolvedDefinition = BelongsToDefinition & {keyTo: string}; * @internal */ export function resolveBelongsToMetadata(relationMeta: BelongsToDefinition) { + if ((relationMeta.type as RelationType) !== RelationType.belongsTo) { + const reason = 'relation type must be BelongsTo'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.target)) { const reason = 'target must be a type resolver'; throw new InvalidRelationError(reason, relationMeta); diff --git a/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts new file mode 100644 index 000000000000..476399d98c9b --- /dev/null +++ b/packages/repository/src/relations/belongs-to/belongs-to.inclusion-resolver.ts @@ -0,0 +1,67 @@ +// 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 {AnyObject, Options} from '../../common-types'; +import {Entity} from '../../model'; +import {Filter, Inclusion} from '../../query'; +import {EntityCrudRepository} from '../../repositories/repository'; +import { + deduplicate, + findByForeignKeys, + flattenTargetsOfOneToOneRelation, + StringKeyOf, +} from '../relation.helpers'; +import { + BelongsToDefinition, + Getter, + InclusionResolver, +} from '../relation.types'; +import {resolveBelongsToMetadata} from './belongs-to.helpers'; + +/** + * Creates InclusionResolver for BelongsTo relation. + * Notice that this function only generates the inclusionResolver. + * It doesn't register it for the source repository. + * + * Notice: scope field for inclusion is not supported yet + * + * @param meta - resolved BelongsToMetadata + * @param getTargetRepo - target repository i.e where related instances are + */ +export function createBelongsToInclusionResolver< + Target extends Entity, + TargetID, + TargetRelations extends object +>( + meta: BelongsToDefinition, + getTargetRepo: Getter< + EntityCrudRepository + >, +): InclusionResolver { + const relationMeta = resolveBelongsToMetadata(meta); + + return async function fetchIncludedModels( + entities: Entity[], + inclusion: Inclusion, + options?: Options, + ): Promise<((Target & TargetRelations) | undefined)[]> { + if (!entities.length) return []; + + const sourceKey = relationMeta.keyFrom; + const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); + const targetKey = relationMeta.keyTo as StringKeyOf; + + const targetRepo = await getTargetRepo(); + const targetsFound = await findByForeignKeys( + targetRepo, + targetKey, + deduplicate(sourceIds), + inclusion.scope as Filter, + options, + ); + + return flattenTargetsOfOneToOneRelation(sourceIds, targetsFound, targetKey); + }; +} diff --git a/packages/repository/src/relations/belongs-to/index.ts b/packages/repository/src/relations/belongs-to/index.ts index 1541beeac649..6c5f1b1ecb35 100644 --- a/packages/repository/src/relations/belongs-to/index.ts +++ b/packages/repository/src/relations/belongs-to/index.ts @@ -6,3 +6,4 @@ export * from './belongs-to.decorator'; export * from './belongs-to.repository'; export * from './belongs-to-accessor'; +export * from './belongs-to.inclusion-resolver'; diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index 55b0cd9daa16..10063998e7db 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -141,6 +141,33 @@ function isInclusionAllowed( return allowed; } +/** + * Returns an array of instances. The order of arrays is based on + * the order of sourceIds + * + * @param sourceIds - One value or array of values of the target key + * @param targetEntities - target entities that satisfy targetKey's value (ids). + * @param targetKey - name of the target key + * + * @return + */ +export function flattenTargetsOfOneToOneRelation< + SourceWithRelations extends Entity, + Target extends Entity +>( + sourceIds: unknown[], + targetEntities: Target[], + targetKey: StringKeyOf, +): (Target | undefined)[] { + const lookup = buildLookupMap( + targetEntities, + targetKey, + reduceAsSingleItem, + ); + + return flattenMapByKeys(sourceIds, lookup); +} + /** * Returns an array of instances from the target map. The order of arrays is based on * the order of sourceIds @@ -235,8 +262,8 @@ export function reduceAsSingleItem(_acc: T | undefined, it: T) { /** * Dedupe an array - * @param {Array} input - an array of sourceIds - * @returns {Array} an array with unique items + * @param input - an array of sourceIds + * @returns an array with unique items */ export function deduplicate(input: T[]): T[] { const uniqArray: T[] = [];