From 6866dd7f1efb48f05408adc39157763ef4103fda Mon Sep 17 00:00:00 2001 From: Agnes Lin Date: Fri, 23 Aug 2019 14:23:19 -0400 Subject: [PATCH] feat(repository): implement inclusionResolver for hasMany Co-authored-by: Nora --- docs/site/HasMany-relation.md | 113 ++++++++ .../repository-tests/src/crud-test-suite.ts | 1 + ...-inclusion-resolver.relation.acceptance.ts | 273 ++++++++++++++++++ .../repositories/customer.repository.ts | 2 + .../src/crud/relations/helpers.ts | 7 +- .../src/types.repository-tests.ts | 8 + .../build-lookup-map.helpers.ts | 129 +++++++++ ...targets-of-one-to-many-relation-helpers.ts | 71 +++++ .../has-many/has-many-repository.factory.ts | 29 +- .../relations/has-many/has-many.helpers.ts | 22 +- .../has-many/has-many.inclusion-resolver.ts | 77 +++++ .../src/relations/has-many/index.ts | 1 + .../src/relations/relation.helpers.ts | 140 ++++++++- 13 files changed, 862 insertions(+), 11 deletions(-) create mode 100644 packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts create mode 100644 packages/repository/src/__tests__/unit/repositories/relations-helpers/build-lookup-map.helpers.ts create mode 100644 packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.ts create mode 100644 packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts diff --git a/docs/site/HasMany-relation.md b/docs/site/HasMany-relation.md index dcf1353eb6c5..57f4853f8f6d 100644 --- a/docs/site/HasMany-relation.md +++ b/docs/site/HasMany-relation.md @@ -314,3 +314,116 @@ certain properties from the JSON/OpenAPI spec schema built for the `requestBody` payload. See its [GitHub issue](https://github.com/strongloop/loopback-next/issues/1179) to follow the discussion. " %} + +## Querying related models + +Loopback4 has the concept of `inclusion resolver` in relations, which helps to +query data through include filter. An inclusion resolver is a function that can +fetch target models for the given list of source model instances. LB4 creates a +different inclusion resolver for each relation type. + +The following is an example for has-many inclusion resolver: + +- Model `Customer` +- Model `Order` +- `Customer` has mamy `Order` + +```ts +// import statements +class Customer extends Entity { + // property definition for id, name + @hasMany(() => Order, {keyTo: 'customerId'}) + orders?: Order[]; +} +``` + +```ts +// import statements +class Order extends Entity { + // property definition for id, name + @property({ + type: 'number', + }) + customerId?: number; + // constructor, relation, etc +} +``` + +The inclusion resolver allows users to retrieve all customers along with all +related orders by the following code: + +```ts +categoryRepo.find({include: [{relation: 'products'}]}); +``` + +### Enable/disable the inclusion resolvers: + +- Base repository classes have a public property `inclusionResolvers`, which + maintains a map containing inclusion resolvers for each relation. +- The inclusion resolver `inclusionResolver` of a certain relation is built when + the source repository class calls the `createHasManyRepositoryFactoryFor` + 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 following code snippet shows how it would look like for registering the +inclusion resolver for has many relation 'orders': + +```ts +export class CustomerRepository extends DefaultCrudRepository { + products: HasManyRepositoryFactory; + + constructor( + dataSource: juggler.DataSource, + orderRepositoryGetter: Getter, + ) { + super(Customer, dataSource); + + // we already have this line to create HasManyRepository factory + this.orders = this.createHasManyRepositoryFactoryFor( + 'orders', + orderRepositoryGetter, + ); + + // add this line to register inclusion resolver + this.registerInclusion('orders', this.orders.inclusionResolver); + } +} +``` + +- We can simply include the relation in queries via find(), findOne(), + findById() methods. Example: + +``` +customerRepository.find({include: [{relation: 'orders'}]}); +``` + +which returns: + +```ts +[ + { + id: 1, + name: 'Thor', + orders:[{name: 'Mjolnir', customerId: 1}, {name: 'Rocket Raccoon', customerId: 1}] + }, + { + id: 2, + name: 'Captain', + orders:[{name: 'Shield', customerId: 2}] + }, + ... +] +``` + +- You can delete a relation from `inclusionResolvers` to disable the inclusion + for a certain relation. e.g + `customerRepository.inclusionResolvers.delete('orders')` + +{% 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/has-many-inclusion-resolver.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts new file mode 100644 index 000000000000..6556e7bc8090 --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many-inclusion-resolver.relation.acceptance.ts @@ -0,0 +1,273 @@ +// 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 hasManyRelationAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !features.supportsInclusionResolvers, + describe, + 'retrieve models including relations', + () => { + describe('HasMany inclusion resolvers - acceptance', () => { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let orderRepo: OrderRepository; + let existingCustomerId: MixedIdType; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + // 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', + }; + // this helper should create the inclusion resolvers for us + ({customerRepo, orderRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + )); + // inclusionResolvers should be defined. And resolver for each + // relation should be created by the hasManyFactory at this point. + expect(customerRepo.inclusionResolvers).to.not.be.undefined(); + expect(orderRepo.inclusionResolvers).to.not.be.undefined(); + expect(customerRepo.orders.inclusionResolver).to.not.be.undefined(); + expect( + customerRepo.customers.inclusionResolver, + ).to.not.be.undefined(); + // inclusionResolvers shouldn't setup yet at this point + expect(customerRepo.inclusionResolvers).to.deepEqual(new Map()); + + await ctx.dataSource.automigrate([Customer.name, Order.name]); + }), + ); + + beforeEach(async () => { + customerRepo.inclusionResolvers.set( + 'orders', + customerRepo.orders.inclusionResolver, + ); + customerRepo.inclusionResolvers.set( + 'customers', + customerRepo.customers.inclusionResolver, + ); + await customerRepo.deleteAll(); + await orderRepo.deleteAll(); + }); + + it("defines a repository's inclusionResolvers property", () => { + expect(customerRepo.inclusionResolvers).to.not.be.undefined(); + expect(orderRepo.inclusionResolvers).to.not.be.undefined(); + }); + + it("throws an error if the repository doesn't have such relation names", async () => { + await orderRepo.create({ + customerId: existingCustomerId, + description: 'Order from Order McForder, the hoarder of Mordor', + }); + await expect( + customerRepo.find({include: [{relation: 'managers'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"managers"}`, + ); + }); + + it('throws error if the target repository does not have the registered resolver', async () => { + await orderRepo.create({ + customerId: existingCustomerId, + description: 'Order from Order McForder, the hoarder of Mordor', + }); + // unregister the resolver + customerRepo.inclusionResolvers.delete('orders'); + + await expect( + customerRepo.find({include: [{relation: 'orders'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"orders"}`, + ); + // reset + customerRepo.inclusionResolvers.set( + 'orders', + customerRepo.orders.inclusionResolver, + ); + }); + + it('simple has-many relation retrieve via find() method', async () => { + const c1 = await customerRepo.create({name: 'c1'}); + const o1 = await orderRepo.create({ + customerId: c1.id, + description: 'order from c1', + }); + const result = await customerRepo.find({ + include: [{relation: 'orders'}], + }); + + const expected = { + id: c1.id, + name: 'c1', + orders: [ + { + id: o1.id, + description: 'order from c1', + customerId: c1.id, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + }, + ], + parentId: features.emptyValue, + }; + expect(toJSON(result)).to.deepEqual([toJSON(expected)]); + }); + + it('returns related instances to target models via find() method', async () => { + const c1 = await customerRepo.create({name: 'Thor'}); + const c2 = await customerRepo.create({name: 'Hella'}); + const o1 = await orderRepo.create({ + customerId: c1.id, + description: 'Mjolnir', + }); + const o2 = await orderRepo.create({ + customerId: c1.id, + description: 'Pizza', + }); + const o3 = await orderRepo.create({ + customerId: c2.id, + description: 'Blade', + }); + + const result = await customerRepo.find({ + include: [{relation: 'orders'}], + }); + + const expected = [ + { + id: c1.id, + name: 'Thor', + orders: [ + { + id: o1.id, + description: 'Mjolnir', + customerId: c1.id, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + }, + { + id: o2.id, + description: 'Pizza', + customerId: c1.id, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + }, + ], + parentId: features.emptyValue, + }, + { + id: c2.id, + name: 'Hella', + orders: [ + { + id: o3.id, + description: 'Blade', + customerId: c2.id, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + }, + ], + parentId: features.emptyValue, + }, + ]; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('returns related instances to target models via findById() method', async () => { + const c1 = await customerRepo.create({name: 'Thor'}); + const c2 = await customerRepo.create({name: 'Hella'}); + await orderRepo.create({ + customerId: c1.id, + description: 'Mjolnir', + }); + await orderRepo.create({ + customerId: c1.id, + description: 'Pizza', + }); + const o3 = await orderRepo.create({ + customerId: c2.id, + description: 'Blade', + }); + + const result = await customerRepo.findById(c2.id, { + include: [{relation: 'orders'}], + }); + const expected = { + id: c2.id, + name: 'Hella', + orders: [ + { + id: o3.id, + description: 'Blade', + customerId: c2.id, + isShipped: features.emptyValue, + // eslint-disable-next-line @typescript-eslint/camelcase + shipment_id: features.emptyValue, + }, + ], + parentId: features.emptyValue, + }; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('throws when navigational properties are present when updating model instance', async () => { + const created = await customerRepo.create({name: 'c1'}); + const customerId = created.id; + + await orderRepo.create({ + description: 'Pen', + customerId, + }); + + const found = await customerRepo.findById(customerId, { + include: [{relation: 'orders'}], + }); + expect(found.orders).to.have.lengthOf(1); + + found.name = 'updated name'; + await expect(customerRepo.save(found)).to.be.rejectedWith( + 'The `Customer` instance is not valid. Details: `orders` is not defined in the model (value: undefined).', + ); + }); + }); + }, + ); +} diff --git a/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts b/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts index 606d34a75420..d5e204c17708 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/repositories/customer.repository.ts @@ -50,6 +50,8 @@ export function createCustomerRepo(repoClass: CrudRepositoryCtor) { ) { super(Customer, db); // create a has-many relation from this public method + // if the class extends from DefaultCrud, it can use getRelationDefinition() to + // check and get valid mata from entities. const ordersMeta = this.entityClass.definition.relations['orders']; this.orders = createHasManyRepositoryFactory( ordersMeta as HasManyDefinition, diff --git a/packages/repository-tests/src/crud/relations/helpers.ts b/packages/repository-tests/src/crud/relations/helpers.ts index 902ddb16a886..79a989b5fc39 100644 --- a/packages/repository-tests/src/crud/relations/helpers.ts +++ b/packages/repository-tests/src/crud/relations/helpers.ts @@ -49,5 +49,10 @@ export function givenBoundCrudRepositories( async () => customerRepo, ); - return {customerRepo, orderRepo, shipmentRepo, addressRepo}; + return { + customerRepo, + orderRepo, + shipmentRepo, + addressRepo, + }; } 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/build-lookup-map.helpers.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/build-lookup-map.helpers.ts new file mode 100644 index 000000000000..3f9c9fd83575 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/build-lookup-map.helpers.ts @@ -0,0 +1,129 @@ +// 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 { + buildLookupMap, + reduceAsArray, + findByForeignKeys, + reduceAsSingleItem, +} from '../../../..'; +import { + Category, + CategoryRepository, + Product, + ProductRepository, + testdb, +} from './relations-helpers-fixtures'; + +describe('buildLoopupMap', () => { + 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(); + }); + describe('get the result of using reduceAsArray strategy', async () => { + it('returns multiple instances in an array', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({ + name: 'pencils', + categoryId: 1, + }); + await productRepo.create({name: 'eraser', categoryId: 2}); + const products = await findByForeignKeys(productRepo, 'categoryId', 1); + + const result = buildLookupMap( + products, + 'categoryId', + reduceAsArray, + ); + const expected = new Map>(); + expected.set(1, [pens, pencils]); + expect(result).to.eql(expected); + }); + it('return instances in multiple arrays', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({ + name: 'pencils', + categoryId: 1, + }); + const erasers = await productRepo.create({name: 'eraser', categoryId: 2}); + const products = await findByForeignKeys(productRepo, 'categoryId', [ + 1, + 2, + ]); + + const result = buildLookupMap( + products, + 'categoryId', + reduceAsArray, + ); + const expected = new Map>(); + expected.set(1, [pens, pencils]); + expected.set(2, [erasers]); + expect(result).to.eql(expected); + }); + }); + describe('get the result of using reduceAsSingleItem strategy', async () => { + it('returns one instance when one target instance is passed in', async () => { + const cat = await categoryRepo.create({id: 1, name: 'angus'}); + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + //const pencils = await productRepo.create({name: 'pencils', categoryId: 1}); + await productRepo.create({name: 'eraser', categoryId: 2}); + // 'id' is the foreign key in Category in respect to Product when we tlak about belongsTo + const category = await findByForeignKeys( + categoryRepo, + 'id', + pens.categoryId, + ); + + const result = buildLookupMap( + category, + 'id', + reduceAsSingleItem, + ); + const expected = new Map(); + expected.set(1, cat); + //expected.set(2, [pencils]); + expect(result).to.eql(expected); + }); + it('returns multiple instances when multiple target instances are passed in', async () => { + const cat1 = await categoryRepo.create({id: 1, name: 'Angus'}); + const cat2 = await categoryRepo.create({id: 2, name: 'Nola'}); + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({ + name: 'pencils', + categoryId: 1, + }); + const erasers = await productRepo.create({ + name: 'erasers', + categoryId: 2, + }); + // 'id' is the foreign key in Category in respect to Product when we tlak about belongsTo + const category = await findByForeignKeys(categoryRepo, 'id', [ + pens.categoryId, + pencils.categoryId, + erasers.categoryId, + ]); + + const result = buildLookupMap( + category, + 'id', + reduceAsSingleItem, + ); + const expected = new Map(); + expected.set(1, cat1); + expected.set(2, cat2); + expect(result).to.eql(expected); + }); + }); +}); diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.ts new file mode 100644 index 000000000000..55b1d6557c32 --- /dev/null +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.ts @@ -0,0 +1,71 @@ +// 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 { + findByForeignKeys, + flattenTargetsOfOneToManyRelation, +} from '../../../..'; +import { + CategoryRepository, + ProductRepository, + testdb, +} from './relations-helpers-fixtures'; + +describe('flattenTargetsOfOneToManyRelation', () => { + 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(); + }); + describe('get the result of single sourceId for hasMany relation', async () => { + it('get the result of using reduceAsArray strategy', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({ + name: 'pencils', + categoryId: 1, + }); + await productRepo.create({name: 'eraser', categoryId: 2}); + const targetsFound = await findByForeignKeys( + productRepo, + 'categoryId', + 1, + ); + + const result = flattenTargetsOfOneToManyRelation( + [1], + targetsFound, + 'categoryId', + ); + expect(result).to.eql([[pens, pencils]]); + }); + it('get the result of multiple sourceIds for hasMany relation', async () => { + const pens = await productRepo.create({name: 'pens', categoryId: 1}); + const pencils = await productRepo.create({ + name: 'pencils', + categoryId: 1, + }); + const erasers = await productRepo.create({name: 'eraser', categoryId: 2}); + // use [2, 1] here to show the order of sourceIds matters + const targetsFound = await findByForeignKeys(productRepo, 'categoryId', [ + 2, + 1, + ]); + const result = flattenTargetsOfOneToManyRelation( + [2, 1], + targetsFound, + 'categoryId', + ); + expect(result).to.deepEqual([[erasers], [pens, pencils]]); + }); + }); +}); diff --git a/packages/repository/src/relations/has-many/has-many-repository.factory.ts b/packages/repository/src/relations/has-many/has-many-repository.factory.ts index 1e1c6c1717f6..e95972b8cec1 100644 --- a/packages/repository/src/relations/has-many/has-many-repository.factory.ts +++ b/packages/repository/src/relations/has-many/has-many-repository.factory.ts @@ -7,8 +7,9 @@ import * as debugFactory from 'debug'; import {DataObject} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories/repository'; -import {Getter, HasManyDefinition} from '../relation.types'; +import {Getter, HasManyDefinition, InclusionResolver} from '../relation.types'; import {resolveHasManyMetadata} from './has-many.helpers'; +import {createHasManyInclusionResolver} from './has-many.inclusion-resolver'; import { DefaultHasManyRepository, HasManyRepository, @@ -16,9 +17,20 @@ import { const debug = debugFactory('loopback:repository:has-many-repository-factory'); -export type HasManyRepositoryFactory = ( - fkValue: ForeignKeyType, -) => HasManyRepository; +export interface HasManyRepositoryFactory< + Target extends Entity, + ForeignKeyType +> { + /** + * Invoke the function to obtain HasManyRepository. + */ + (fkValue: ForeignKeyType): HasManyRepository; + + /** + * Use `resolver` property to obtain an InclusionResolver for this relation. + */ + inclusionResolver: InclusionResolver; +} /** * Enforces a constraint on a repository based on a relationship contract @@ -43,7 +55,9 @@ export function createHasManyRepositoryFactory< ): HasManyRepositoryFactory { const meta = resolveHasManyMetadata(relationMetadata); debug('Resolved HasMany relation metadata: %o', meta); - return function(fkValue: ForeignKeyType) { + const result: HasManyRepositoryFactory = function( + fkValue: ForeignKeyType, + ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const constraint: any = {[meta.keyTo]: fkValue}; return new DefaultHasManyRepository< @@ -52,4 +66,9 @@ export function createHasManyRepositoryFactory< EntityCrudRepository >(targetRepositoryGetter, constraint as DataObject); }; + result.inclusionResolver = createHasManyInclusionResolver( + meta, + targetRepositoryGetter, + ); + return result; } diff --git a/packages/repository/src/relations/has-many/has-many.helpers.ts b/packages/repository/src/relations/has-many/has-many.helpers.ts index dd09141dff0d..737b3bb3203a 100644 --- a/packages/repository/src/relations/has-many/has-many.helpers.ts +++ b/packages/repository/src/relations/has-many/has-many.helpers.ts @@ -7,7 +7,7 @@ import * as debugFactory from 'debug'; import {camelCase} from 'lodash'; import {InvalidRelationError} from '../../errors'; import {isTypeResolver} from '../../type-resolver'; -import {HasManyDefinition} from '../relation.types'; +import {HasManyDefinition, RelationType} from '../relation.types'; const debug = debugFactory('loopback:repository:has-many-helpers'); @@ -15,7 +15,10 @@ const debug = debugFactory('loopback:repository:has-many-helpers'); * Relation definition with optional metadata (e.g. `keyTo`) filled in. * @internal */ -export type HasManyResolvedDefinition = HasManyDefinition & {keyTo: string}; +export type HasManyResolvedDefinition = HasManyDefinition & { + keyFrom: string; + keyTo: string; +}; /** * Resolves given hasMany metadata if target is specified to be a resolver. @@ -27,6 +30,11 @@ export type HasManyResolvedDefinition = HasManyDefinition & {keyTo: string}; export function resolveHasManyMetadata( relationMeta: HasManyDefinition, ): HasManyResolvedDefinition { + if ((relationMeta.type as RelationType) !== RelationType.hasMany) { + const reason = 'relation type must be HasMany'; + throw new InvalidRelationError(reason, relationMeta); + } + if (!isTypeResolver(relationMeta.target)) { const reason = 'target must be a type resolver'; throw new InvalidRelationError(reason, relationMeta); @@ -49,6 +57,14 @@ export function resolveHasManyMetadata( throw new InvalidRelationError(reason, relationMeta); } + // TODO(bajtos) add test coverage (when keyTo is and is not set) + const keyFrom = sourceModel.getIdProperties()[0]; + + if (relationMeta.keyTo) { + // The explict cast is needed because of a limitation of type inference + return Object.assign(relationMeta, {keyFrom}) as HasManyResolvedDefinition; + } + debug( 'Resolved model %s from given metadata: %o', targetModel.modelName, @@ -62,5 +78,5 @@ export function resolveHasManyMetadata( throw new InvalidRelationError(reason, relationMeta); } - return Object.assign(relationMeta, {keyTo: defaultFkName}); + return Object.assign(relationMeta, {keyFrom, keyTo: defaultFkName}); } diff --git a/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts new file mode 100644 index 000000000000..65052dbd309b --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many.inclusion-resolver.ts @@ -0,0 +1,77 @@ +// 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 debugFactory from 'debug'; +import {AnyObject, Options} from '../../common-types'; +import {Entity} from '../../model'; +import {Filter, Inclusion} from '../../query'; +import {EntityCrudRepository} from '../../repositories/repository'; +import { + findByForeignKeys, + flattenTargetsOfOneToManyRelation, + StringKeyOf, +} from '../relation.helpers'; +import {Getter, HasManyDefinition, InclusionResolver} from '../relation.types'; +import {resolveHasManyMetadata} from './has-many.helpers'; + +const debug = debugFactory('loopback:repository:has-many-inclusion-resolver'); + +/** + * Creates hasMany resolver for the relation. + * + * @param meta - metadata of the hasMany relation + * @param getTargetRepo - Target repo + */ + +export function createHasManyInclusionResolver< + Target extends Entity, + TargetID, + TargetRelations extends object +>( + meta: HasManyDefinition, + getTargetRepo: Getter< + EntityCrudRepository + >, +): InclusionResolver { + const relationMeta = resolveHasManyMetadata(meta); + + return async function fetchHasManyModels( + entities: Entity[], + inclusion: Inclusion, + options?: Options, + ): Promise<((Target & TargetRelations)[] | undefined)[]> { + if (!entities.length) return []; + + debug('Fetching target models for entities:', entities); + debug('Relation metadata:', relationMeta); + + const sourceKey = relationMeta.keyFrom; + const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); + const targetKey = relationMeta.keyTo as StringKeyOf; + + debug('Parameters:', {sourceKey, sourceIds, targetKey}); + debug('sourceId types', sourceIds.map(i => typeof i)); + + const targetRepo = await getTargetRepo(); + const targetsFound = await findByForeignKeys( + targetRepo, + targetKey, + sourceIds, + inclusion.scope as Filter, + options, + ); + + debug('Targets found:', targetsFound); + + const result = flattenTargetsOfOneToManyRelation( + sourceIds, + targetsFound, + targetKey, + ); + + debug('fetchHasManyModels result', result); + return result; + }; +} diff --git a/packages/repository/src/relations/has-many/index.ts b/packages/repository/src/relations/has-many/index.ts index 0025021d819a..7780e8b4c018 100644 --- a/packages/repository/src/relations/has-many/index.ts +++ b/packages/repository/src/relations/has-many/index.ts @@ -6,3 +6,4 @@ export * from './has-many.decorator'; export * from './has-many.repository'; export * from './has-many-repository.factory'; +export * from './has-many.inclusion-resolver'; diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index a79674e7b63a..7a58c1dde1e0 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -46,7 +46,14 @@ export async function findByForeignKeys< if (Array.isArray(fkValues)) { if (fkValues.length === 0) return []; - value = fkValues.length === 1 ? fkValues[0] : {inq: fkValues}; + value = + fkValues.length === 1 + ? fkValues[0] + : { + // Create a copy to prevent query coercion algorithm + // inside connectors from modifying the original values + inq: [...fkValues], + }; } else { value = fkValues; } @@ -57,7 +64,7 @@ export async function findByForeignKeys< return targetRepository.find(targetFilter, options); } -type StringKeyOf = Extract; +export type StringKeyOf = Extract; /** * Returns model instances that include related models that have a registered @@ -132,3 +139,132 @@ function isInclusionAllowed( debug('isInclusionAllowed for %j (relation %s)? %s', include, allowed); return allowed; } + +/** + * Returns an array of arrays. Each nested array has one or more instances + * as a result of one to many relation. 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 flattenTargetsOfOneToManyRelation( + sourceIds: unknown[], + targetEntities: Target[], + targetKey: StringKeyOf, +): (Target[] | undefined)[] { + debug('flattenTargetsOfOneToManyRelation'); + debug('sourceIds', sourceIds); + debug('sourceId types', sourceIds.map(i => typeof i)); + debug('targetEntities', targetEntities); + debug('targetKey', targetKey); + + const lookup = buildLookupMap( + targetEntities, + targetKey, + reduceAsArray, + ); + + debug('lookup map', lookup); + + return flattenMapByKeys(sourceIds, lookup); +} + +/** + * Returns an array of instances from the target map. The order of arrays is based on + * the order of sourceIds + * + * @param sourceIds - One value or array of values (of the target key) + * @param targetMap - a map that matches sourceIds with instances + */ +export function flattenMapByKeys( + sourceIds: unknown[], + targetMap: Map, +): (T | undefined)[] { + const result: (T | undefined)[] = new Array(sourceIds.length); + // mongodb: use string as key of targetMap, and convert sourceId to strings + // to make sure it gets the related instances. + sourceIds.forEach((id, index) => { + const key = normalizeKey(id); + const target = targetMap.get(key); + result[index] = target; + }); + + return result; +} + +/** + * Returns a map which maps key values(ids) to instances. The instances can be + * grouped by different strategies. + * + * @param list - an array of instances + * @param keyName - key name of the source + * @param reducer - a strategy to reduce inputs to single item or array + */ +export function buildLookupMap( + list: InType[], + keyName: StringKeyOf, + reducer: (accumulator: OutType | undefined, current: InType) => OutType, +): Map { + const lookup = new Map(); + for (const entity of list) { + // get a correct key value + const key = getKeyValue(entity, keyName) as Key; + // these 3 steps are to set up the map, the map differs according to the reducer. + const original = lookup.get(key); + const reduced = reducer(original, entity); + lookup.set(key, reduced); + } + return lookup; +} + +/** + * Returns value of a keyName. Aims to resolve ObjectId problem of Mongo. + * + * @param model - target model + * @param keyName - target key that gets the value from + */ +export function getKeyValue(model: AnyObject, keyName: string) { + return normalizeKey(model[keyName]); +} + +/** + * Workaround for MongoDB, where the connector returns ObjectID + * values even for properties configured with "type: string". + * + * @param rawKey + */ +function normalizeKey(rawKey: unknown) { + if ( + typeof rawKey === 'object' && + rawKey && + rawKey.constructor.name === 'ObjectID' + ) { + return rawKey.toString(); + } + return rawKey; +} + +/** + * Returns an array of instances. For HasMany relation usage. + * + * @param acc + * @param it + */ +export function reduceAsArray(acc: T[] | undefined, it: T) { + if (acc) acc.push(it); + else acc = [it]; + return acc; +} +/** + * Returns a single of an instance. For HasOne and BelongsTo relation usage. + * + * @param _acc + * @param it + */ +export function reduceAsSingleItem(_acc: T | undefined, it: T) { + return it; +}