Skip to content

Commit

Permalink
feat(repository): implement inclusion resolver for belongsTo relation
Browse files Browse the repository at this point in the history
Co-authored-by: Miroslav <mbajtos@cz.ibm.com>
  • Loading branch information
Agnes Lin and bajtos committed Sep 21, 2019
1 parent 4cf9a70 commit 72415c4
Show file tree
Hide file tree
Showing 7 changed files with 380 additions and 8 deletions.
84 changes: 84 additions & 0 deletions docs/site/hasOne-relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 show 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 {
products: HasOneRepositoryFactory<Account, typeof Supplier.prototype.id>;
constructor(
dataSource: juggler.DataSource,
accountRepositoryGetter: Getter<AccountRepository>,
) {
super(Customer, 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).
" %}
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,6 @@ export function belongsToRelationAcceptance(
});
await customerRepo.deleteAll();

await orderRepo.deleteAll();

await expect(findCustomerOfOrder(order.id)).to.be.rejectedWith(
EntityNotFoundError,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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"}`,
);
});
}
}
4 changes: 4 additions & 0 deletions packages/repository-tests/src/crud/relations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Target extends Entity, ForeignKeyType> = (
fkValue: ForeignKeyType,
) => HasOneRepository<Target>;
export interface HasOneRepositoryFactory<
Target extends Entity,
ForeignKeyType
> {
/**
* Invoke the function to obtain HasManyRepository.
*/
(fkValue: ForeignKeyType): HasOneRepository<Target>;

/**
* Use `resolver` property to obtain an InclusionResolver for this relation.
*/
inclusionResolver: InclusionResolver<Entity, Target>;
}
/**
* 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
Expand All @@ -40,7 +51,9 @@ export function createHasOneRepositoryFactory<
): HasOneRepositoryFactory<Target, ForeignKeyType> {
const meta = resolveHasOneMetadata(relationMetadata);
debug('Resolved HasOne relation metadata: %o', meta);
return function(fkValue: ForeignKeyType) {
const result: HasOneRepositoryFactory<Target, ForeignKeyType> = function(
fkValue: ForeignKeyType,
) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const constraint: any = {[meta.keyTo]: fkValue};
return new DefaultHasOneRepository<
Expand All @@ -49,4 +62,9 @@ export function createHasOneRepositoryFactory<
EntityCrudRepository<Target, TargetID>
>(targetRepositoryGetter, constraint as DataObject<Target>);
};
result.inclusionResolver = createHasOneInclusionResolver(
meta,
targetRepositoryGetter,
);
return result;
}
7 changes: 6 additions & 1 deletion packages/repository/src/relations/has-one/has-one.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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);
Expand Down
Loading

0 comments on commit 72415c4

Please sign in to comment.