Skip to content

Commit 642c4b6

Browse files
Agnes Linnabdelgadirbajtos
committed
feat(repository): add inclusionResolvers to DefaultCrudRepository
- add registerInclusionResolver method to DefaultCrudRepository - add includeRelatedModels method to DefaultCrudRepository - modify find* methods to use includeRelatedModels helper Co-authored-by: Nora <nora.abdelgadir@ibm.com> Co-authored-by: Miroslav Bajtoš <mbajtoss@gmail.com>
1 parent d9c31c4 commit 642c4b6

File tree

2 files changed

+283
-9
lines changed

2 files changed

+283
-9
lines changed

packages/repository/src/__tests__/unit/repositories/legacy-juggler-bridge.unit.ts

Lines changed: 215 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
// This file is licensed under the MIT License.
44
// License text available at https://opensource.org/licenses/MIT
55

6-
import {expect} from '@loopback/testlab';
6+
import {expect, toJSON} from '@loopback/testlab';
77
import {
88
bindModel,
99
DefaultCrudRepository,
@@ -15,6 +15,21 @@ import {
1515
ModelDefinition,
1616
property,
1717
} from '../../..';
18+
import {
19+
belongsTo,
20+
BelongsToAccessor,
21+
BelongsToDefinition,
22+
createBelongsToAccessor,
23+
createHasManyRepositoryFactory,
24+
createHasOneRepositoryFactory,
25+
hasMany,
26+
HasManyDefinition,
27+
HasManyRepositoryFactory,
28+
hasOne,
29+
HasOneDefinition,
30+
HasOneRepositoryFactory,
31+
InclusionResolver,
32+
} from '../../../relations';
1833
import {CrudConnectorStub} from '../crud-connector.stub';
1934
const TransactionClass = require('loopback-datasource-juggler').Transaction;
2035

@@ -274,6 +289,190 @@ describe('DefaultCrudRepository', () => {
274289
});
275290
});
276291

292+
context('find* methods including relations', () => {
293+
@model()
294+
class Author extends Entity {
295+
@property({id: true})
296+
id?: number;
297+
@property()
298+
name: string;
299+
@belongsTo(() => Folder)
300+
folderId: number;
301+
}
302+
303+
@model()
304+
class Folder extends Entity {
305+
@property({id: true})
306+
id?: number;
307+
@property()
308+
name: string;
309+
@hasMany(() => File)
310+
files: File[];
311+
@hasOne(() => Author)
312+
author: Author;
313+
}
314+
315+
@model()
316+
class File extends Entity {
317+
@property({id: true})
318+
id?: number;
319+
@property()
320+
title: string;
321+
@belongsTo(() => Folder)
322+
folderId: number;
323+
}
324+
325+
let folderRepo: DefaultCrudRepository<Folder, unknown, {}>;
326+
let fileRepo: DefaultCrudRepository<File, unknown, {}>;
327+
let authorRepo: DefaultCrudRepository<Author, unknown, {}>;
328+
329+
let folderFiles: HasManyRepositoryFactory<File, typeof Folder.prototype.id>;
330+
let fileFolder: BelongsToAccessor<Folder, typeof File.prototype.id>;
331+
let folderAuthor: HasOneRepositoryFactory<
332+
Author,
333+
typeof Folder.prototype.id
334+
>;
335+
336+
before(() => {
337+
ds = new juggler.DataSource({
338+
name: 'db',
339+
connector: 'memory',
340+
});
341+
folderRepo = new DefaultCrudRepository(Folder, ds);
342+
fileRepo = new DefaultCrudRepository(File, ds);
343+
authorRepo = new DefaultCrudRepository(Author, ds);
344+
});
345+
346+
before(() => {
347+
// using a variable instead of a repository property
348+
folderFiles = createHasManyRepositoryFactory(
349+
Folder.definition.relations.files as HasManyDefinition,
350+
async () => fileRepo,
351+
);
352+
folderAuthor = createHasOneRepositoryFactory(
353+
Folder.definition.relations.author as HasOneDefinition,
354+
async () => authorRepo,
355+
);
356+
fileFolder = createBelongsToAccessor(
357+
File.definition.relations.folder as BelongsToDefinition,
358+
async () => folderRepo,
359+
fileRepo,
360+
);
361+
});
362+
363+
beforeEach(async () => {
364+
await folderRepo.deleteAll();
365+
await fileRepo.deleteAll();
366+
await authorRepo.deleteAll();
367+
});
368+
369+
it('implements Repository.find() with included models', async () => {
370+
const createdFolders = await folderRepo.createAll([
371+
{name: 'f1', id: 1},
372+
{name: 'f2', id: 2},
373+
]);
374+
const files = await fileRepo.createAll([
375+
{id: 1, title: 'file1', folderId: 1},
376+
{id: 2, title: 'file2', folderId: 3},
377+
]);
378+
379+
folderRepo.registerInclusionResolver('files', hasManyResolver);
380+
381+
const folders = await folderRepo.find({include: [{relation: 'files'}]});
382+
383+
expect(toJSON(folders)).to.deepEqual([
384+
{...createdFolders[0].toJSON(), files: [toJSON(files[0])]},
385+
{...createdFolders[1].toJSON(), files: []},
386+
]);
387+
});
388+
389+
it('implements Repository.findById() with included models', async () => {
390+
const folders = await folderRepo.createAll([
391+
{name: 'f1', id: 1},
392+
{name: 'f2', id: 2},
393+
]);
394+
const createdFile = await fileRepo.create({
395+
id: 1,
396+
title: 'file1',
397+
folderId: 1,
398+
});
399+
400+
fileRepo.registerInclusionResolver('folder', belongsToResolver);
401+
402+
const file = await fileRepo.findById(1, {
403+
include: [{relation: 'folder'}],
404+
});
405+
406+
expect(file.toJSON()).to.deepEqual({
407+
...createdFile.toJSON(),
408+
folder: folders[0].toJSON(),
409+
});
410+
});
411+
412+
it('implements Repository.findOne() with included models', async () => {
413+
const folders = await folderRepo.createAll([
414+
{name: 'f1', id: 1},
415+
{name: 'f2', id: 2},
416+
]);
417+
const createdAuthor = await authorRepo.create({
418+
id: 1,
419+
name: 'a1',
420+
folderId: 1,
421+
});
422+
423+
folderRepo.registerInclusionResolver('author', hasOneResolver);
424+
425+
const folder = await folderRepo.findOne({
426+
include: [{relation: 'author'}],
427+
});
428+
429+
expect(folder!.toJSON()).to.deepEqual({
430+
...folders[0].toJSON(),
431+
author: createdAuthor.toJSON(),
432+
});
433+
});
434+
435+
// stub resolvers
436+
437+
const hasManyResolver: InclusionResolver<Folder, File> = async entities => {
438+
const files = [];
439+
for (const entity of entities) {
440+
const file = await folderFiles(entity.id).find();
441+
files.push(file);
442+
}
443+
444+
return files;
445+
};
446+
447+
const belongsToResolver: InclusionResolver<
448+
File,
449+
Folder
450+
> = async entities => {
451+
const folders = [];
452+
453+
for (const file of entities) {
454+
const folder = await fileFolder(file.folderId);
455+
folders.push(folder);
456+
}
457+
458+
return folders;
459+
};
460+
461+
const hasOneResolver: InclusionResolver<
462+
Folder,
463+
Author
464+
> = async entities => {
465+
const authors = [];
466+
467+
for (const folder of entities) {
468+
const author = await folderAuthor(folder.id).get();
469+
authors.push(author);
470+
}
471+
472+
return authors;
473+
};
474+
});
475+
277476
it('implements Repository.delete()', async () => {
278477
const repo = new DefaultCrudRepository(Note, ds);
279478
const note = await repo.create({title: 't3', content: 'c3'});
@@ -419,6 +618,21 @@ describe('DefaultCrudRepository', () => {
419618
'execute() must be implemented by the connector',
420619
);
421620
});
621+
622+
it('has the property inclusionResolvers', () => {
623+
const repo = new DefaultCrudRepository(Note, ds);
624+
expect(repo.inclusionResolvers).to.be.instanceof(Map);
625+
});
626+
627+
it('implements Repository.registerInclusionResolver()', () => {
628+
const repo = new DefaultCrudRepository(Note, ds);
629+
const resolver: InclusionResolver<Note, Entity> = async entities => {
630+
return entities;
631+
};
632+
repo.registerInclusionResolver('notes', resolver);
633+
const setResolver = repo.inclusionResolvers.get('notes');
634+
expect(setResolver).to.eql(resolver);
635+
});
422636
});
423637

424638
describe('DefaultTransactionalRepository', () => {

packages/repository/src/repositories/legacy-juggler-bridge.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
} from '../common-types';
1818
import {EntityNotFoundError} from '../errors';
1919
import {Entity, Model, PropertyType} from '../model';
20-
import {Filter, Where} from '../query';
20+
import {Filter, Inclusion, Where} from '../query';
2121
import {
2222
BelongsToAccessor,
2323
BelongsToDefinition,
@@ -28,6 +28,7 @@ import {
2828
HasManyRepositoryFactory,
2929
HasOneDefinition,
3030
HasOneRepositoryFactory,
31+
includeRelatedModels,
3132
InclusionResolver,
3233
} from '../relations';
3334
import {IsolationLevel, Transaction} from '../transaction';
@@ -359,32 +360,51 @@ export class DefaultCrudRepository<
359360
filter?: Filter<T>,
360361
options?: Options,
361362
): Promise<(T & Relations)[]> {
363+
const include = filter && filter.include;
362364
const models = await ensurePromise(
363-
this.modelClass.find(filter as legacy.Filter, options),
365+
this.modelClass.find(this.normalizeFilter(filter), options),
364366
);
365-
return this.toEntities(models);
367+
const entities = this.toEntities(models);
368+
return this.includeRelatedModels(entities, include, options);
366369
}
367370

368-
async findOne(filter?: Filter<T>, options?: Options): Promise<T | null> {
371+
async findOne(
372+
filter?: Filter<T>,
373+
options?: Options,
374+
): Promise<(T & Relations) | null> {
369375
const model = await ensurePromise(
370-
this.modelClass.findOne(filter as legacy.Filter, options),
376+
this.modelClass.findOne(this.normalizeFilter(filter), options),
371377
);
372378
if (!model) return null;
373-
return this.toEntity(model);
379+
const entity = this.toEntity(model);
380+
const include = filter && filter.include;
381+
const resolved = await this.includeRelatedModels(
382+
[entity],
383+
include,
384+
options,
385+
);
386+
return resolved[0];
374387
}
375388

376389
async findById(
377390
id: ID,
378391
filter?: Filter<T>,
379392
options?: Options,
380393
): Promise<T & Relations> {
394+
const include = filter && filter.include;
381395
const model = await ensurePromise(
382-
this.modelClass.findById(id, filter as legacy.Filter, options),
396+
this.modelClass.findById(id, this.normalizeFilter(filter), options),
383397
);
384398
if (!model) {
385399
throw new EntityNotFoundError(this.entityClass, id);
386400
}
387-
return this.toEntity<T & Relations>(model);
401+
const entity = this.toEntity(model);
402+
const resolved = await this.includeRelatedModels(
403+
[entity],
404+
include,
405+
options,
406+
);
407+
return resolved[0];
388408
}
389409

390410
update(entity: T, options?: Options): Promise<void> {
@@ -474,6 +494,46 @@ export class DefaultCrudRepository<
474494
protected toEntities<R extends T>(models: juggler.PersistedModel[]): R[] {
475495
return models.map(m => this.toEntity<R>(m));
476496
}
497+
498+
/**
499+
* Register an inclusion resolver for the related model name.
500+
*
501+
* @param relationName - Name of the relation defined on the source model
502+
* @param resolver - Resolver function for getting related model entities
503+
*/
504+
registerInclusionResolver(
505+
relationName: string,
506+
resolver: InclusionResolver<T, Entity>,
507+
) {
508+
this.inclusionResolvers.set(relationName, resolver);
509+
}
510+
511+
/**
512+
* Returns model instances that include related models of this repository
513+
* that have a registered resolver.
514+
*
515+
* @param entities - An array of entity instances or data
516+
* @param include -Inclusion filter
517+
* @param options - Options for the operations
518+
*/
519+
protected async includeRelatedModels(
520+
entities: T[],
521+
include?: Inclusion<T>[],
522+
options?: Options,
523+
): Promise<(T & Relations)[]> {
524+
return includeRelatedModels<T, Relations>(this, entities, include, options);
525+
}
526+
527+
/**
528+
* Removes juggler's "include" filter as it does not apply to LoopBack 4
529+
* relations.
530+
*
531+
* @param filter - Query filter
532+
*/
533+
protected normalizeFilter(filter?: Filter<T>): legacy.Filter | undefined {
534+
if (!filter) return undefined;
535+
return {...filter, include: undefined} as legacy.Filter;
536+
}
477537
}
478538

479539
/**

0 commit comments

Comments
 (0)