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 <[email protected]>
  • Loading branch information
Agnes Lin and bajtos committed Sep 20, 2019
1 parent 0094ded commit 200f54a
Show file tree
Hide file tree
Showing 15 changed files with 614 additions and 11 deletions.
108 changes: 108 additions & 0 deletions docs/site/BelongsTo-relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,111 @@ DO NOT declare
`@repository.getter(CategoryRepository) protected categoryRepositoryGetter: Getter<CategoryRepository>`
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<Customer, typeof Order.prototype.id>;

constructor(
dataSource: juggler.DataSource,
customerRepositoryGetter: Getter<CustomerRepository>,
) {
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).
" %}
1 change: 1 addition & 0 deletions packages/repository-tests/src/crud-test-suite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function crudRepositoryTestSuite(
freeFormProperties: true,
emptyValue: undefined,
supportsTransactions: true,
supportsInclusionResolvers: true,
...partialFeatures,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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));
});

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"}`,
);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export function hasManyRelationAcceptance(
({customerRepo, orderRepo} = givenBoundCrudRepositories(
ctx.dataSource,
repositoryClass,
features,
));
await ctx.dataSource.automigrate([Customer.name, Order.name]);
}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
22 changes: 21 additions & 1 deletion packages/repository-tests/src/crud/relations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -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(
Expand Down
Loading

0 comments on commit 200f54a

Please sign in to comment.