Skip to content

Commit

Permalink
feat(repository): implement inclusion resolver for belongsTo relation
Browse files Browse the repository at this point in the history
  • Loading branch information
Agnes Lin committed Sep 17, 2019
1 parent 88d5494 commit 9e4f4e5
Show file tree
Hide file tree
Showing 13 changed files with 762 additions and 9 deletions.
102 changes: 102 additions & 0 deletions docs/site/BelongsTo-relation.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,105 @@ 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 addresses along with their related customers
through the following code:

```ts
addressRepo.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 AddressRepository extends DefaultCrudRepository {
customer: BelongsToAccessor<Customer, typeof Address.prototype.id>;

constructor(
dataSource: juggler.DataSource,
customerRepositoryGetter: Getter<CustomerRepository>,
) {
super(Address, dataSource);

// we already have this line to create a HasManyRepository 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
addressRepository.find({include: [{relation: 'customer'}]});
```

which returns:

```ts
[
{
id: 1,
street: 'Warden Rd',
city: 'Thrudheim',
province: 'Asgard',
customer: {
id: 12,
name: 'Thor',
},
},
{
id: 2,
street: 'AgentOfShield',
city: 'Culver',
province: 'Cali',
customer: {
id: 10,
name: 'Captain',
},
},
];
```

- You can delete a relation from `inclusionResolvers` to disable the inclusion
for a certain relation. e.g
`addressRepository.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,221 @@
// 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,
Address,
AddressRepository,
} 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('BelongsTo inclusion resolvers - acceptance', () => {
before(deleteAllModelsInDefaultDataSource);
let customerRepo: CustomerRepository;
let addressRepo: AddressRepository;
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
Address.definition.properties.customerId.type = features.idType;
Address.definition.properties.customerId.mongodb = {
dataType: 'ObjectID',
};
// this helper should create the inclusion resolvers for us
({customerRepo, addressRepo} = givenBoundCrudRepositories(
ctx.dataSource,
repositoryClass,
));
// inclusionResolvers should be defined. And resolver for each
// relation should be created by the belongsToFactory at this point.
expect(customerRepo.inclusionResolvers).to.not.be.undefined();
expect(addressRepo.inclusionResolvers).to.not.be.undefined();
expect(
addressRepo.customer!.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, Address.name]);
}),
);

beforeEach(async () => {
addressRepo.inclusionResolvers.set(
'customer',
addressRepo.customer!.inclusionResolver,
);
await customerRepo.deleteAll();
await addressRepo.deleteAll();
});

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

it("throws an error if the repository doesn't have such relation names", async () => {
await addressRepo.create({
street: 'home of Thor Rd.',
city: 'Thrudheim',
province: 'Asgard',
zipcode: '8200',
customerId: existingCustomerId,
});
await expect(
addressRepo.find({include: [{relation: 'home'}]}),
).to.be.rejectedWith(
`Invalid "filter.include" entries: {"relation":"home"}`,
);
});

it('throws error if the target repository does not have the registered resolver', async () => {
await addressRepo.create({
street: 'home of Thor Rd.',
city: 'Thrudheim',
province: 'Asgard',
zipcode: '8200',
customerId: existingCustomerId,
});
// unregister the resolver
addressRepo.inclusionResolvers.delete('customer');

await expect(
addressRepo.find({include: [{relation: 'customer'}]}),
).to.be.rejectedWith(
`Invalid "filter.include" entries: {"relation":"customer"}`,
);
});

it('simple belongs-to relation retrieve via find() method', async () => {
const thor = await customerRepo.create({name: 'Thor'});
const address = await addressRepo.create({
street: 'home of Thor Rd.',
city: 'Thrudheim',
province: 'Asgard',
zipcode: '8200',
customerId: thor.id,
});
const result = await addressRepo.find({
include: [{relation: 'customer'}],
});

const expected = {
...address,
customer: {
id: thor.id,
name: 'Thor',
parentId: features.emptyValue,
},
};
expect(toJSON(result)).to.deepEqual([toJSON(expected)]);
});

it('returns related instances to target models via find() method', async () => {
const thor = await customerRepo.create({name: 'Thor'});
const odin = await customerRepo.create({name: 'Odin'});
const addr1 = await addressRepo.create({
street: 'home of Thor Rd.',
city: 'Thrudheim',
province: 'Asgard',
zipcode: '999',
customerId: thor.id,
});
const addr2 = await addressRepo.create({
street: 'home of Odin Rd.',
city: 'Valhalla',
province: 'Asgard',
zipcode: '000',
customerId: odin.id,
});

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

const expected = [
{
...addr1,
customer: {
id: thor.id,
name: 'Thor',
parentId: features.emptyValue,
},
},
{
...addr2,
customer: {
id: odin.id,
name: 'Odin',
parentId: features.emptyValue,
},
},
];
expect(toJSON(result)).to.deepEqual(toJSON(expected));
});

it('returns related instances to target models via findById() method', 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 addr2 = await addressRepo.create({
street: 'home of Odin Rd.',
city: 'Valhalla',
province: 'Asgard',
zipcode: '000',
customerId: odin.id,
});

const result = await addressRepo.findById(addr2.id, {
include: [{relation: 'customer'}],
});
const expected = {
...addr2,
customer: {
id: odin.id,
name: 'Odin',
parentId: features.emptyValue,
},
};
expect(toJSON(result)).to.deepEqual(toJSON(expected));
});
});
},
);
}
8 changes: 8 additions & 0 deletions packages/repository-tests/src/types.repository-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export interface CrudFeatures {
* Default: `false`
*/
supportsTransactions: boolean;

/**
* Does the repository provide `inclusionResolvers` object where resolvers
* can be registered?
*
* Default: `true`
*/
supportsInclusionResolvers: boolean;
}

/**
Expand Down
Loading

0 comments on commit 9e4f4e5

Please sign in to comment.