diff --git a/docs/site/BelongsTo-relation.md b/docs/site/BelongsTo-relation.md index d84d3ce59d2e..3cdafd4d05c9 100644 --- a/docs/site/BelongsTo-relation.md +++ b/docs/site/BelongsTo-relation.md @@ -270,12 +270,8 @@ on constructor to avoid "Circular dependency" error (see ## 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: +A `belongsTo` relation has an `inclusionResolver` function as a property. It +fetches target models for the given list of source model instances. Use the relation between `Customer` and `Order` we show above, an `Order` belongs to a `Customer`. diff --git a/docs/site/HasMany-relation.md b/docs/site/HasMany-relation.md index 3da5c5b91c30..88d5108a2a97 100644 --- a/docs/site/HasMany-relation.md +++ b/docs/site/HasMany-relation.md @@ -317,10 +317,8 @@ issue](https://github.com/strongloop/loopback-next/issues/1179) to follow the di ## 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. +A `hasMany` relation has an `inclusionResolver` function as a property. It +fetches target models for the given list of source model instances. Use the relation between `Customer` and `Order` we show above, a `Customer` has many `Order`s. diff --git a/docs/site/Relations.md b/docs/site/Relations.md index 9aacd6025451..d81baf32a381 100644 --- a/docs/site/Relations.md +++ b/docs/site/Relations.md @@ -31,7 +31,10 @@ introduction of [repositories](Repositories.md), we aim to simplify the approach to relations by creating constrained repositories. This means that certain constraints need to be honoured by the target model repository based on the relation definition, and thus we produce a constrained version of it as a -navigational property on the source repository. +navigational property on the source repository. Additionally, we also introduce +the concept of the `inclusion resolver` in relations, which helps to query data +over different relations. LoopBack 4 creates a different inclusion resolver for +each relation type. Here are the currently supported relations: diff --git a/docs/site/hasOne-relation.md b/docs/site/hasOne-relation.md index af2eae079cbb..d6bc61c25025 100644 --- a/docs/site/hasOne-relation.md +++ b/docs/site/hasOne-relation.md @@ -295,3 +295,87 @@ 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 + +A `hasOne` relation has an `inclusionResolver` function as a property. It +fetches target models for the given list of source model instances. + +Using the relation between `Supplier` and `Account` we have shown above, a +`Supplier` has one `Account`. + +After setting up the relation in the repository class, the inclusion resolver +allows users to retrieve all suppliers along with their related account +instances through the following code: + +```ts +supplierRepository.find({include: [{relation: 'account'}]}); +``` + +### 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 `createHasOneRepositoryFactoryFor` 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 to register the inclusion resolver for the +hasOne relation 'account': + +```ts +export class SupplierRepository extends DefaultCrudRepository { + account: HasOneRepositoryFactory; + constructor( + dataSource: juggler.DataSource, + accountRepositoryGetter: Getter, + ) { + super(Supplier, dataSource); + // we already have this line to create a HasOneRepository factory + this.account = this.createHasOneRepositoryFactoryFor( + 'account', + accountRepositoryGetter, + ); + // add this line to register inclusion resolver + this.registerInclusion('account', this.account.inclusionResolver); + } +} +``` + +- We can simply include the relation in queries via `find()`, `findOne()`, and + `findById()` methods. Example: + + ```ts + supplierRepository.find({include: [{relation: 'account'}]}); + ``` + + which returns: + + ```ts + [ + { + id: 1, + name: 'Thor', + account: {accountManager: 'Odin', supplierId: 1}, + }, + { + id: 5, + name: 'Loki', + account: {accountManager: 'Frigga', supplierId: 5}, + }, + ]; + ``` + +- You can delete a relation from `inclusionResolvers` to disable the inclusion + for a certain relation. e.g + `supplierRepository.inclusionResolvers.delete('account')` + +{% 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/relations/acceptance/belongs-to.relation.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/belongs-to.relation.acceptance.ts index e9690125ef56..c7d29d1ceeae 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 @@ -103,8 +103,6 @@ export function belongsToRelationAcceptance( }); await customerRepo.deleteAll(); - await orderRepo.deleteAll(); - await expect(findCustomerOfOrder(order.id)).to.be.rejectedWith( EntityNotFoundError, ); diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts new file mode 100644 index 000000000000..a645568089cf --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/has-one.inclusion-resolver.acceptance.ts @@ -0,0 +1,201 @@ +// 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, + withCrudCtx, +} from '../../../helpers.repository-tests'; +import { + Address, + AddressRepository, + Customer, + CustomerRepository, +} from '../fixtures/models'; +import {givenBoundCrudRepositories} from '../helpers'; + +export function hasOneInclusionResolverAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !features.supportsInclusionResolvers, + describe, + 'HasOne inclusion resolvers - acceptance', + suite, + ); + function suite() { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let addressRepo: AddressRepository; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + // this helper should create the inclusion resolvers and also + // register inclusion resolvers for us + ({customerRepo, addressRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + features, + )); + expect(customerRepo.address.inclusionResolver).to.be.Function(); + + await ctx.dataSource.automigrate([Customer.name, Address.name]); + }), + ); + + beforeEach(async () => { + await customerRepo.deleteAll(); + await addressRepo.deleteAll(); + }); + + it('throws an error if it tries to query nonexistent relation names', async () => { + const customer = await customerRepo.create({name: 'customer'}); + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: customer.id, + }); + await expect( + customerRepo.find({include: [{relation: 'home'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"home"}`, + ); + }); + + it('returns single model instance including single related instance', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const thorAddress = await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: thor.id, + }); + const result = await customerRepo.find({ + include: [{relation: 'address'}], + }); + + const expected = { + ...thor, + parentId: features.emptyValue, + address: thorAddress, + }; + 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 thorAddress = await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '999', + customerId: thor.id, + }); + const odinAddress = await addressRepo.create({ + street: 'home of Odin Rd.', + city: 'Valhalla', + province: 'Asgard', + zipcode: '000', + customerId: odin.id, + }); + + const result = await customerRepo.find({ + include: [{relation: 'address'}], + }); + + const expected = [ + { + ...thor, + parentId: features.emptyValue, + address: thorAddress, + }, + { + ...odin, + parentId: features.emptyValue, + address: odinAddress, + }, + ]; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('returns a specified instance including its related model instance', async () => { + const thor = await customerRepo.create({name: 'Thor'}); + const odin = await customerRepo.create({name: 'Odin'}); + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '999', + customerId: thor.id, + }); + const odinAddress = await addressRepo.create({ + street: 'home of Odin Rd.', + city: 'Valhalla', + province: 'Asgard', + zipcode: '000', + customerId: odin.id, + }); + + const result = await customerRepo.findById(odin.id, { + include: [{relation: 'address'}], + }); + const expected = { + ...odin, + parentId: features.emptyValue, + address: odinAddress, + }; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + // scope field for inclusion is not supported yet + it('throws error if the inclusion query contains a non-empty scope', async () => { + const customer = await customerRepo.create({name: 'customer'}); + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: customer.id, + }); + await expect( + customerRepo.find({ + include: [{relation: 'address', scope: {limit: 1}}], + }), + ).to.be.rejectedWith(`scope is not supported`); + }); + + it('throws error if the target repository does not have the registered resolver', async () => { + const customer = await customerRepo.create({name: 'customer'}); + await addressRepo.create({ + street: 'home of Thor Rd.', + city: 'Thrudheim', + province: 'Asgard', + zipcode: '8200', + customerId: customer.id, + }); + // unregister the resolver + customerRepo.inclusionResolvers.delete('address'); + + await expect( + customerRepo.find({include: [{relation: 'address'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"address"}`, + ); + }); + } +} diff --git a/packages/repository-tests/src/crud/relations/helpers.ts b/packages/repository-tests/src/crud/relations/helpers.ts index 77c65eb9940e..93d02d3105dd 100644 --- a/packages/repository-tests/src/crud/relations/helpers.ts +++ b/packages/repository-tests/src/crud/relations/helpers.ts @@ -54,6 +54,10 @@ export function givenBoundCrudRepositories( 'customers', customerRepo.customers.inclusionResolver, ); + customerRepo.inclusionResolvers.set( + 'address', + customerRepo.address.inclusionResolver, + ); const orderRepoClass = createOrderRepo(repositoryClass); const orderRepo: OrderRepository = new orderRepoClass( diff --git a/packages/repository/src/relations/has-one/has-one-repository.factory.ts b/packages/repository/src/relations/has-one/has-one-repository.factory.ts index e6e07e2e81ea..d5c48961b8d1 100644 --- a/packages/repository/src/relations/has-one/has-one-repository.factory.ts +++ b/packages/repository/src/relations/has-one/has-one-repository.factory.ts @@ -7,16 +7,27 @@ import * as debugFactory from 'debug'; import {DataObject} from '../../common-types'; import {Entity} from '../../model'; import {EntityCrudRepository} from '../../repositories/repository'; -import {Getter, HasOneDefinition} from '../relation.types'; +import {Getter, HasOneDefinition, InclusionResolver} from '../relation.types'; import {resolveHasOneMetadata} from './has-one.helpers'; +import {createHasOneInclusionResolver} from './has-one.inclusion-resolver'; import {DefaultHasOneRepository, HasOneRepository} from './has-one.repository'; const debug = debugFactory('loopback:repository:has-one-repository-factory'); -export type HasOneRepositoryFactory = ( - fkValue: ForeignKeyType, -) => HasOneRepository; +export interface HasOneRepositoryFactory< + Target extends Entity, + ForeignKeyType +> { + /** + * Invoke the function to obtain HasOneRepository. + */ + (fkValue: ForeignKeyType): HasOneRepository; + /** + * Use `resolver` property to obtain an InclusionResolver for this relation. + */ + inclusionResolver: InclusionResolver; +} /** * Enforces a constraint on a repository based on a relationship contract * between models. For example, if a Customer model is related to an Address model @@ -40,7 +51,9 @@ export function createHasOneRepositoryFactory< ): HasOneRepositoryFactory { const meta = resolveHasOneMetadata(relationMetadata); debug('Resolved HasOne relation metadata: %o', meta); - return function(fkValue: ForeignKeyType) { + const result: HasOneRepositoryFactory = function( + fkValue: ForeignKeyType, + ) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const constraint: any = {[meta.keyTo]: fkValue}; return new DefaultHasOneRepository< @@ -49,4 +62,9 @@ export function createHasOneRepositoryFactory< EntityCrudRepository >(targetRepositoryGetter, constraint as DataObject); }; + result.inclusionResolver = createHasOneInclusionResolver( + meta, + targetRepositoryGetter, + ); + return result; } diff --git a/packages/repository/src/relations/has-one/has-one.helpers.ts b/packages/repository/src/relations/has-one/has-one.helpers.ts index b91f1643d67a..97ab052ae2db 100644 --- a/packages/repository/src/relations/has-one/has-one.helpers.ts +++ b/packages/repository/src/relations/has-one/has-one.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 {HasOneDefinition} from '../relation.types'; +import {HasOneDefinition, RelationType} from '../relation.types'; const debug = debugFactory('loopback:repository:has-one-helpers'); @@ -30,6 +30,11 @@ export type HasOneResolvedDefinition = HasOneDefinition & { export function resolveHasOneMetadata( relationMeta: HasOneDefinition, ): HasOneResolvedDefinition { + if ((relationMeta.type as RelationType) !== RelationType.hasOne) { + const reason = 'relation type must be HasOne'; + 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/has-one/has-one.inclusion-resolver.ts b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts new file mode 100644 index 000000000000..79f40fb385d6 --- /dev/null +++ b/packages/repository/src/relations/has-one/has-one.inclusion-resolver.ts @@ -0,0 +1,62 @@ +// 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 { + findByForeignKeys, + flattenTargetsOfOneToOneRelation, + StringKeyOf, +} from '../relation.helpers'; +import {Getter, HasOneDefinition, InclusionResolver} from '../relation.types'; +import {resolveHasOneMetadata} from './has-one.helpers'; + +/** + * Creates InclusionResolver for HasOne 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 + * @param getTargetRepo + */ +export function createHasOneInclusionResolver< + Target extends Entity, + TargetID, + TargetRelations extends object +>( + meta: HasOneDefinition, + getTargetRepo: Getter< + EntityCrudRepository + >, +): InclusionResolver { + const relationMeta = resolveHasOneMetadata(meta); + + return async function fetchHasOneModel( + 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, + sourceIds, + inclusion.scope as Filter, + options, + ); + + return flattenTargetsOfOneToOneRelation(sourceIds, targetsFound, targetKey); + }; +}