Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,195 @@
// 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,
});

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a test case to cover scope? Such as:

{include: {relation: 'customer', scope: {fields: ['name']}}}

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));
});
// scope for inclusion is not supported yet
it('throws error if the inclusion query contains a non-empty scope', async () => {
await orderRepo.create({
description: 'shiba',
customerId: existingCustomerId,
});
await expect(
orderRepo.find({
include: [{relation: 'customer', scope: {limit: 1}}],
}),
).to.be.rejectedWith(`scope is not supported`);
});

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
Loading