Skip to content

Commit

Permalink
feat(repository): implement inclusionResolver for hasMany
Browse files Browse the repository at this point in the history
Co-authored-by: Nora <[email protected]>
  • Loading branch information
Agnes Lin and nabdelgadir committed Sep 17, 2019
1 parent 7bf6f12 commit 7243075
Show file tree
Hide file tree
Showing 12 changed files with 559 additions and 8 deletions.
92 changes: 92 additions & 0 deletions docs/site/HasMany-relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,3 +314,95 @@ 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

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.

Use the relation between `Customer` and `Order` we show above, a `Customer` has
many `Order`s.

After setting up the relation in the repository class, the inclusion resolver
allows users to retrieve all customers along with their related orders through
the following code:

```ts
customerRepo.find({include: [{relation: 'orders'}]});
```

### 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 `createHasManyRepositoryFactoryFor` 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
has-many relation 'orders':

```ts
export class CustomerRepository extends DefaultCrudRepository {
products: HasManyRepositoryFactory<Order, typeof Customer.prototype.id>;

constructor(
dataSource: juggler.DataSource,
orderRepositoryGetter: Getter<OrderRepository>,
) {
super(Customer, dataSource);

// we already have this line to create a HasManyRepository factory
this.orders = this.createHasManyRepositoryFactoryFor(
'orders',
orderRepositoryGetter,
);

// add this line to register inclusion resolver
this.registerInclusion('orders', this.orders.inclusionResolver);
}
}
```

- We can simply include the relation in queries via `find()`, `findOne()`, and
`findById()` methods. Example:

```ts
customerRepository.find({include: [{relation: 'orders'}]});
```

which returns:

```ts
[
{
id: 1,
name: 'Thor',
orders: [
{name: 'Mjolnir', customerId: 1},
{name: 'Rocket Raccoon', customerId: 1},
],
},
{
id: 2,
name: 'Captain',
orders: [{name: 'Shield', customerId: 2}],
},
];
```

- You can delete a relation from `inclusionResolvers` to disable the inclusion
for a certain relation. e.g
`customerRepository.inclusionResolvers.delete('orders')`

{% 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,273 @@
// 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 hasManyRelationAcceptance(
dataSourceOptions: DataSourceOptions,
repositoryClass: CrudRepositoryCtor,
features: CrudFeatures,
) {
skipIf<[(this: Suite) => void], void>(
!features.supportsInclusionResolvers,
describe,
'retrieve models including relations',
() => {
describe('HasMany inclusion resolvers - acceptance', () => {
before(deleteAllModelsInDefaultDataSource);
let customerRepo: CustomerRepository;
let orderRepo: OrderRepository;
let existingCustomerId: MixedIdType;

before(
withCrudCtx(async function setupRepository(ctx: CrudTestContext) {
// 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',
};
// this helper should create the inclusion resolvers for us
({customerRepo, orderRepo} = givenBoundCrudRepositories(
ctx.dataSource,
repositoryClass,
));
// inclusionResolvers should be defined. And resolver for each
// relation should be created by the hasManyFactory at this point.
expect(customerRepo.inclusionResolvers).to.not.be.undefined();
expect(orderRepo.inclusionResolvers).to.not.be.undefined();
expect(customerRepo.orders.inclusionResolver).to.not.be.undefined();
expect(
customerRepo.customers.inclusionResolver,
).to.not.be.undefined();
// inclusionResolvers shouldn't setup yet at this point
expect(customerRepo.inclusionResolvers).to.deepEqual(new Map());

await ctx.dataSource.automigrate([Customer.name, Order.name]);
}),
);

beforeEach(async () => {
customerRepo.inclusionResolvers.set(
'orders',
customerRepo.orders.inclusionResolver,
);
customerRepo.inclusionResolvers.set(
'customers',
customerRepo.customers.inclusionResolver,
);
await customerRepo.deleteAll();
await orderRepo.deleteAll();
});

it("defines a repository's inclusionResolvers property", () => {
expect(customerRepo.inclusionResolvers).to.not.be.undefined();
expect(orderRepo.inclusionResolvers).to.not.be.undefined();
});

it("throws an error if the repository doesn't have such relation names", async () => {
await orderRepo.create({
customerId: existingCustomerId,
description: 'Order from Order McForder, the hoarder of Mordor',
});
await expect(
customerRepo.find({include: [{relation: 'managers'}]}),
).to.be.rejectedWith(
`Invalid "filter.include" entries: {"relation":"managers"}`,
);
});

it('throws error if the target repository does not have the registered resolver', async () => {
await orderRepo.create({
customerId: existingCustomerId,
description: 'Order from Order McForder, the hoarder of Mordor',
});
// unregister the resolver
customerRepo.inclusionResolvers.delete('orders');

await expect(
customerRepo.find({include: [{relation: 'orders'}]}),
).to.be.rejectedWith(
`Invalid "filter.include" entries: {"relation":"orders"}`,
);
// reset
customerRepo.inclusionResolvers.set(
'orders',
customerRepo.orders.inclusionResolver,
);
});

it('simple has-many relation retrieve via find() method', async () => {
const c1 = await customerRepo.create({name: 'c1'});
const o1 = await orderRepo.create({
customerId: c1.id,
description: 'order from c1',
});
const result = await customerRepo.find({
include: [{relation: 'orders'}],
});

const expected = {
id: c1.id,
name: 'c1',
orders: [
{
id: o1.id,
description: 'order from c1',
customerId: c1.id,
isShipped: features.emptyValue,
// eslint-disable-next-line @typescript-eslint/camelcase
shipment_id: features.emptyValue,
},
],
parentId: features.emptyValue,
};
expect(toJSON(result)).to.deepEqual([toJSON(expected)]);
});

it('returns related instances to target models via find() method', async () => {
const c1 = await customerRepo.create({name: 'Thor'});
const c2 = await customerRepo.create({name: 'Hella'});
const o1 = await orderRepo.create({
customerId: c1.id,
description: 'Mjolnir',
});
const o2 = await orderRepo.create({
customerId: c1.id,
description: 'Pizza',
});
const o3 = await orderRepo.create({
customerId: c2.id,
description: 'Blade',
});

const result = await customerRepo.find({
include: [{relation: 'orders'}],
});

const expected = [
{
id: c1.id,
name: 'Thor',
orders: [
{
id: o1.id,
description: 'Mjolnir',
customerId: c1.id,
isShipped: features.emptyValue,
// eslint-disable-next-line @typescript-eslint/camelcase
shipment_id: features.emptyValue,
},
{
id: o2.id,
description: 'Pizza',
customerId: c1.id,
isShipped: features.emptyValue,
// eslint-disable-next-line @typescript-eslint/camelcase
shipment_id: features.emptyValue,
},
],
parentId: features.emptyValue,
},
{
id: c2.id,
name: 'Hella',
orders: [
{
id: o3.id,
description: 'Blade',
customerId: c2.id,
isShipped: features.emptyValue,
// eslint-disable-next-line @typescript-eslint/camelcase
shipment_id: features.emptyValue,
},
],
parentId: features.emptyValue,
},
];
expect(toJSON(result)).to.deepEqual(toJSON(expected));
});

it('returns related instances to target models via findById() method', async () => {
const c1 = await customerRepo.create({name: 'Thor'});
const c2 = await customerRepo.create({name: 'Hella'});
await orderRepo.create({
customerId: c1.id,
description: 'Mjolnir',
});
await orderRepo.create({
customerId: c1.id,
description: 'Pizza',
});
const o3 = await orderRepo.create({
customerId: c2.id,
description: 'Blade',
});

const result = await customerRepo.findById(c2.id, {
include: [{relation: 'orders'}],
});
const expected = {
id: c2.id,
name: 'Hella',
orders: [
{
id: o3.id,
description: 'Blade',
customerId: c2.id,
isShipped: features.emptyValue,
// eslint-disable-next-line @typescript-eslint/camelcase
shipment_id: features.emptyValue,
},
],
parentId: features.emptyValue,
};
expect(toJSON(result)).to.deepEqual(toJSON(expected));
});

it('throws when navigational properties are present when updating model instance', async () => {
const created = await customerRepo.create({name: 'c1'});
const customerId = created.id;

await orderRepo.create({
description: 'Pen',
customerId,
});

const found = await customerRepo.findById(customerId, {
include: [{relation: 'orders'}],
});
expect(found.orders).to.have.lengthOf(1);

found.name = 'updated name';
await expect(customerRepo.save(found)).to.be.rejectedWith(
'The `Customer` instance is not valid. Details: `orders` is not defined in the model (value: undefined).',
);
});
});
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,8 @@ export function createCustomerRepo(repoClass: CrudRepositoryCtor) {
addressRepositoryGetter: Getter<typeof repoClass.prototype>,
) {
super(Customer, db);
// create a has-many relation from this public method
const ordersMeta = this.entityClass.definition.relations['orders'];
// create a has-many relation through this public method
this.orders = createHasManyRepositoryFactory(
ordersMeta as HasManyDefinition,
orderRepositoryGetter,
Expand Down
7 changes: 6 additions & 1 deletion packages/repository-tests/src/crud/relations/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,5 +49,10 @@ export function givenBoundCrudRepositories(
async () => customerRepo,
);

return {customerRepo, orderRepo, shipmentRepo, addressRepo};
return {
customerRepo,
orderRepo,
shipmentRepo,
addressRepo,
};
}
Loading

0 comments on commit 7243075

Please sign in to comment.