diff --git a/docs/site/hasOne-relation.md b/docs/site/hasOne-relation.md index af2eae079cbb..e63b9bd02324 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 + +We introduce the concept of `inclusion resolver` in `Has Many Relation` and +`Belongs To Relation`. `Has One relation` supports `inclusion resolver` as well. + +Use the relation between `Supplier` and `Account` we have shown above, a +`Supplier` has one `Account`s. + +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..0a1e3435ef8e --- /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 nonexists 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..5597c0f154de 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 HasManyRepository. + */ + (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); + }; +}