From 0e1b02e1418f19ef343ca78b6c30f01e0784b6b2 Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 4 Sep 2020 17:33:53 -0400 Subject: [PATCH] feat(repository): implement hasManyThrough resolver Signed-off-by: Nora --- docs/site/HasManyThrough-relation.md | 120 +++++++- docs/site/Relation-generator.md | 4 - .../has-many-through-relation.generator.js | 8 +- packages/cli/generators/relation/index.js | 1 - ....has-many-through.integration.snapshots.js | 121 +++++--- .../relation.has-many-through.integration.js | 58 +++- ...y-through-inclusion-resolver.acceptance.ts | 263 ++++++++++++++++++ .../crud/relations/fixtures/models/index.ts | 2 +- .../src/crud/relations/helpers.ts | 10 +- ...ts-of-one-to-many-relation-helpers.unit.ts | 9 + .../has-many-through-repository.factory.ts | 23 +- .../has-many-through.inclusion.resolver.ts | 135 +++++++++ .../src/relations/relation.helpers.ts | 2 +- 13 files changed, 700 insertions(+), 56 deletions(-) create mode 100644 packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts create mode 100644 packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts diff --git a/docs/site/HasManyThrough-relation.md b/docs/site/HasManyThrough-relation.md index e1919426a1e8..a75a1748e318 100644 --- a/docs/site/HasManyThrough-relation.md +++ b/docs/site/HasManyThrough-relation.md @@ -302,7 +302,7 @@ export class DoctorRepository extends DefaultCrudRepository< appointmentRepositoryGetter: Getter, ) { super(Doctor, db); - this.patient = this.createHasManyThroughRepositoryFactoryFor( + this.patients = this.createHasManyThroughRepositoryFactoryFor( 'patients', patientRepositoryGetter, appointmentRepositoryGetter, @@ -443,6 +443,121 @@ export class UserRepository extends DefaultCrudRepository< } ``` +## Querying related models + +In contrast with LB3, LB4 creates a different inclusion resolver for each +relation type to query related models. Each **relation** has its own inclusion +resolver `inclusionResolver`. And each **repository** has a built-in property +`inclusionResolvers` as a registry for its inclusionResolvers. + +A `hasManyThrough` relation has an `inclusionResolver` function as a property. +It fetches target models for the given list of source model instances via a +through model. + +Using the models from above, a `Doctor` has many `Patient`s through +`Appointments`. + +After setting up the relation in the repository class, the inclusion resolver +allows users to retrieve all doctors along with their related patients through +the following code at the repository level: + +```ts +doctorRepository.find({include: [{relation: 'patients'}]}); +``` + +or use APIs with controllers: + +``` +GET http://localhost:3000/doctors?filter[include][][relation]=patients +``` + +### 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 `createHasManyThroughRepositoryFactoryFor` 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 through relation 'patients': + +```ts +export class DoctorRepository extends DefaultCrudRepository< + Doctor, + typeof Doctor.prototype.id, + DoctorRelations +> { + public readonly patients: HasManyThroughRepositoryFactory< + Patient, + typeof Patient.prototype.pid, + Appointment, + typeof Doctor.prototype.id + >; + constructor( + @inject('datasources.db') protected db: juggler.DataSource, + @repository.getter('PatientRepository') + patientRepositoryGetter: Getter, + @repository.getter('AppointmentRepository') + appointmentRepositoryGetter: Getter, + ) { + super(Doctor, db); + // we already have this line to create a HasManyThroughRepository factory + this.patients = this.createHasManyThroughRepositoryFactoryFor( + 'patients', + patientRepositoryGetter, + appointmentRepositoryGetter, + ); + + // add this line to register inclusion resolver + this.registerInclusionResolver('patients', this.patients.inclusionResolver); + } +} +``` + +- We can simply include the relation in queries via `find()`, `findOne()`, and + `findById()` methods. For example, these queries return all doctors with their + patients: + + if you process data at the repository level: + + ```ts + doctorRepository.find({include: [{relation: 'patients'}]}); + ``` + + this is the same as the url: + + ``` + GET http://localhost:3000/doctors?filter[include][][relation]=patients + ``` + + which returns: + + ```ts + [ + { + id: 1, + name: 'Doctor Mario', + patients: [{name: 'Luigi'}, {name: 'Peach'}], + }, + { + id: 2, + name: 'Doctor Link', + patients: [{name: 'Zelda'}], + }, + ]; + ``` + +{% include note.html content="The query syntax is a slightly different from LB3. We are also working on simplifying the query syntax. Check our GitHub issue for more information: +[Simpler Syntax for Inclusion](https://github.com/strongloop/loopback-next/issues/3205)" %} + +- You can delete a relation from `inclusionResolvers` to disable the inclusion + for a certain relation. e.g + `doctorRepository.inclusionResolvers.delete('patients')` + ## Using hasManyThrough constrained repository in a controller Once the hasManyThrough relation has been defined and configured, controller @@ -494,7 +609,6 @@ issue](https://github.com/strongloop/loopback-next/issues/1179) to follow the di ## Features on the way As an experimental feature, there are some functionalities of `hasManyThrough` -are not yet being implemented: +that are not yet being implemented: -- [inclusionResolver](https://github.com/strongloop/loopback-next/issues/5946) - customize `keyFrom` and/or `keyTo` for hasManyThrough diff --git a/docs/site/Relation-generator.md b/docs/site/Relation-generator.md index d4313d8d9384..f2445da55dbb 100644 --- a/docs/site/Relation-generator.md +++ b/docs/site/Relation-generator.md @@ -98,10 +98,6 @@ lb4 relation --sourceModel= - `` - Property on the through model that references the primary key property of the target model. -Notice that the inclusion resolver is not supported in HasManyThrough relation -yet. See -[GitHub issue #5946](https://github.com/strongloop/loopback-next/issues/5946). - ### Interactive Prompts The tool will prompt you for: diff --git a/packages/cli/generators/relation/has-many-through-relation.generator.js b/packages/cli/generators/relation/has-many-through-relation.generator.js index 0e478328abba..d2dd8dd8adb8 100644 --- a/packages/cli/generators/relation/has-many-through-relation.generator.js +++ b/packages/cli/generators/relation/has-many-through-relation.generator.js @@ -319,6 +319,12 @@ module.exports = class HasManyThroughRelationGenerator extends BaseRelationGener } _registerInclusionResolverForRelation(classConstructor, options) { - /* not implemented yet */ + const relationPropertyName = this._getRepositoryRelationPropertyName(); + if (options.registerInclusionResolver) { + const statement = + `this.registerInclusionResolver(` + + `'${relationPropertyName}', this.${relationPropertyName}.inclusionResolver);`; + classConstructor.insertStatements(2, statement); + } } }; diff --git a/packages/cli/generators/relation/index.js b/packages/cli/generators/relation/index.js index acbf5f8691f3..642cf678af8f 100644 --- a/packages/cli/generators/relation/index.js +++ b/packages/cli/generators/relation/index.js @@ -615,7 +615,6 @@ module.exports = class RelationGenerator extends ArtifactGenerator { async promptRegisterInclusionResolver() { if (this.shouldExit()) return false; - if (this.artifactInfo.relationType === 'hasManyThrough') return; const props = await this.prompt([ { type: 'confirm', diff --git a/packages/cli/snapshots/integration/generators/relation.has-many-through.integration.snapshots.js b/packages/cli/snapshots/integration/generators/relation.has-many-through.integration.snapshots.js index 6805191f699b..b890e9eed284 100644 --- a/packages/cli/snapshots/integration/generators/relation.has-many-through.integration.snapshots.js +++ b/packages/cli/snapshots/integration/generators/relation.has-many-through.integration.snapshots.js @@ -7,9 +7,62 @@ 'use strict'; -exports[ - `lb4 relation HasManyThrough checks if the controller file created answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} controller file has been created with hasManyThrough relation 1` -] = ` +exports[`lb4 relation HasManyThrough checks generated source class repository answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","registerInclusionResolver":false} generates Doctor repository file with different inputs 1`] = ` +import {DefaultCrudRepository, repository, HasManyThroughRepositoryFactory} from '@loopback/repository'; +import {Doctor, Patient, Appointment} from '../models'; +import {DbDataSource} from '../datasources'; +import {inject, Getter} from '@loopback/core'; +import {AppointmentRepository} from './appointment.repository'; +import {PatientRepository} from './patient.repository'; + +export class DoctorRepository extends DefaultCrudRepository< + Doctor, + typeof Doctor.prototype.id +> { + + public readonly patients: HasManyThroughRepositoryFactory; + + constructor(@inject('datasources.db') dataSource: DbDataSource, @repository.getter('AppointmentRepository') protected appointmentRepositoryGetter: Getter, @repository.getter('PatientRepository') protected patientRepositoryGetter: Getter,) { + super(Doctor, dataSource); + this.patients = this.createHasManyThroughRepositoryFactoryFor('patients', patientRepositoryGetter, appointmentRepositoryGetter,); + } +} + +`; + + +exports[`lb4 relation HasManyThrough checks generated source class repository answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} generates Doctor repository file with different inputs 1`] = ` +import {DefaultCrudRepository, repository, HasManyThroughRepositoryFactory} from '@loopback/repository'; +import {Doctor, Patient, Appointment} from '../models'; +import {DbDataSource} from '../datasources'; +import {inject, Getter} from '@loopback/core'; +import {AppointmentRepository} from './appointment.repository'; +import {PatientRepository} from './patient.repository'; + +export class DoctorRepository extends DefaultCrudRepository< + Doctor, + typeof Doctor.prototype.id +> { + + public readonly patients: HasManyThroughRepositoryFactory; + + constructor(@inject('datasources.db') dataSource: DbDataSource, @repository.getter('AppointmentRepository') protected appointmentRepositoryGetter: Getter, @repository.getter('PatientRepository') protected patientRepositoryGetter: Getter,) { + super(Doctor, dataSource); + this.patients = this.createHasManyThroughRepositoryFactoryFor('patients', patientRepositoryGetter, appointmentRepositoryGetter,); + this.registerInclusionResolver('patients', this.patients.inclusionResolver); + } +} + +`; + + +exports[`lb4 relation HasManyThrough checks if the controller file is created answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} controller file has been created with hasManyThrough relation 1`] = ` import { Count, CountSchema, @@ -123,9 +176,8 @@ export class DoctorPatientController { `; -exports[ - `lb4 relation HasManyThrough checks if the controller file created answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} controller file has been created with hasManyThrough relation 1` -] = ` + +exports[`lb4 relation HasManyThrough checks if the controller file is created answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} controller file has been created with hasManyThrough relation 1`] = ` import { Count, CountSchema, @@ -239,9 +291,8 @@ export class DoctorPatientController { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 1`] = ` import {Entity, model, property, hasMany} from '@loopback/repository'; import {Patient} from './patient.model'; import {Appointment} from './appointment.model'; @@ -270,9 +321,8 @@ export class Doctor extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 2` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 2`] = ` import {Entity, model, property} from '@loopback/repository'; @model() @@ -306,9 +356,8 @@ export class Appointment extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom"} add custom keyTo and/or keyFrom to the through model 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom"} add custom keyTo and/or keyFrom to the through model 1`] = ` import {Entity, model, property, hasMany} from '@loopback/repository'; import {Patient} from './patient.model'; import {Appointment} from './appointment.model'; @@ -337,9 +386,8 @@ export class Doctor extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom"} add custom keyTo and/or keyFrom to the through model 2` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","sourceKeyOnThrough":"customKeyFrom"} add custom keyTo and/or keyFrom to the through model 2`] = ` import {Entity, model, property} from '@loopback/repository'; @model() @@ -373,9 +421,8 @@ export class Appointment extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 1`] = ` import {Entity, model, property, hasMany} from '@loopback/repository'; import {Patient} from './patient.model'; import {Appointment} from './appointment.model'; @@ -404,9 +451,8 @@ export class Doctor extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 2` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom keyFrom and/or keyTo answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","targetKeyOnThrough":"customKeyTo"} add custom keyTo and/or keyFrom to the through model 2`] = ` import {Entity, model, property} from '@loopback/repository'; @model() @@ -440,9 +486,8 @@ export class Appointment extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom relation name answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} relation name should be myPatients 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom relation name answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} relation name should be myPatients 1`] = ` import {Entity, model, property, hasMany} from '@loopback/repository'; import {Patient} from './patient.model'; import {Appointment} from './appointment.model'; @@ -471,9 +516,8 @@ export class Doctor extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with custom relation name answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} relation name should be myPatients 2` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with custom relation name answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment","relationName":"myPatients"} relation name should be myPatients 2`] = ` import {DefaultCrudRepository, repository, HasManyThroughRepositoryFactory} from '@loopback/repository'; import {Doctor, Patient, Appointment} from '../models'; import {DbDataSource} from '../datasources'; @@ -494,14 +538,14 @@ export class DoctorRepository extends DefaultCrudRepository< constructor(@inject('datasources.db') dataSource: DbDataSource, @repository.getter('AppointmentRepository') protected appointmentRepositoryGetter: Getter, @repository.getter('PatientRepository') protected patientRepositoryGetter: Getter,) { super(Doctor, dataSource); this.myPatients = this.createHasManyThroughRepositoryFactoryFor('myPatients', patientRepositoryGetter, appointmentRepositoryGetter,); + this.registerInclusionResolver('myPatients', this.myPatients.inclusionResolver); } } `; -exports[ - `lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct default foreign keys 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct default foreign keys 1`] = ` import {Entity, model, property} from '@loopback/repository'; @model() @@ -535,9 +579,8 @@ export class Appointment extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct imports and relation name patients 1` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct imports and relation name patients 1`] = ` import {Entity, model, property, hasMany} from '@loopback/repository'; import {Patient} from './patient.model'; import {Appointment} from './appointment.model'; @@ -566,9 +609,8 @@ export class Doctor extends Entity { `; -exports[ - `lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct imports and relation name patients 2` -] = ` + +exports[`lb4 relation HasManyThrough generates model relation with default values answers {"relationType":"hasManyThrough","sourceModel":"Doctor","destinationModel":"Patient","throughModel":"Appointment"} has correct imports and relation name patients 2`] = ` import {DefaultCrudRepository, repository, HasManyThroughRepositoryFactory} from '@loopback/repository'; import {Doctor, Patient, Appointment} from '../models'; import {DbDataSource} from '../datasources'; @@ -589,6 +631,7 @@ export class DoctorRepository extends DefaultCrudRepository< constructor(@inject('datasources.db') dataSource: DbDataSource, @repository.getter('AppointmentRepository') protected appointmentRepositoryGetter: Getter, @repository.getter('PatientRepository') protected patientRepositoryGetter: Getter,) { super(Doctor, dataSource); this.patients = this.createHasManyThroughRepositoryFactoryFor('patients', patientRepositoryGetter, appointmentRepositoryGetter,); + this.registerInclusionResolver('patients', this.patients.inclusionResolver); } } diff --git a/packages/cli/test/integration/generators/relation.has-many-through.integration.js b/packages/cli/test/integration/generators/relation.has-many-through.integration.js index 59a6f1a1f1bf..23f413d1bf7c 100644 --- a/packages/cli/test/integration/generators/relation.has-many-through.integration.js +++ b/packages/cli/test/integration/generators/relation.has-many-through.integration.js @@ -204,7 +204,7 @@ describe('lb4 relation HasManyThrough', /** @this {Mocha.Suite} */ function () { } }); - context('checks if the controller file created ', () => { + context('checks if the controller file is created ', () => { const promptArray = [ { relationType: 'hasManyThrough', @@ -251,4 +251,60 @@ describe('lb4 relation HasManyThrough', /** @this {Mocha.Suite} */ function () { }); } }); + + context('checks generated source class repository', () => { + const promptArray = [ + { + relationType: 'hasManyThrough', + sourceModel: 'Doctor', + destinationModel: 'Patient', + throughModel: 'Appointment', + }, + { + relationType: 'hasManyThrough', + sourceModel: 'Doctor', + destinationModel: 'Patient', + throughModel: 'Appointment', + registerInclusionResolver: false, + }, + ]; + + const sourceClassNames = ['Doctor', 'Doctor']; + + promptArray.forEach(function (multiItemPrompt, i) { + describe('answers ' + JSON.stringify(multiItemPrompt), () => { + suite(multiItemPrompt, i); + }); + }); + + function suite(multiItemPrompt, i) { + before(async function runGeneratorWithAnswers() { + await sandbox.reset(); + await testUtils + .executeGenerator(generator) + .inDir(sandbox.path, () => + testUtils.givenLBProject(sandbox.path, { + additionalFiles: SANDBOX_FILES, + }), + ) + .withPrompts(multiItemPrompt); + }); + + it( + 'generates ' + + sourceClassNames[i] + + ' repository file with different inputs', + async () => { + const sourceFilePath = path.join( + sandbox.path, + REPOSITORY_APP_PATH, + repositoryFileName, + ); + + assert.file(sourceFilePath); + expectFileToMatchSnapshot(sourceFilePath); + }, + ); + } + }); }); diff --git a/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts b/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts new file mode 100644 index 000000000000..1abff48f966c --- /dev/null +++ b/packages/repository-tests/src/crud/relations/acceptance/has-many-through-inclusion-resolver.acceptance.ts @@ -0,0 +1,263 @@ +// Copyright IBM Corp. 2020. 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, + withCrudCtx, +} from '../../../helpers.repository-tests'; +import { + CartItem, + CartItemRepository, + Customer, + CustomerCartItemLink, + CustomerCartItemLinkRepository, + CustomerRepository, + User, + UserLink, + UserLinkRepository, + UserRepository, +} from '../fixtures/models'; +import {givenBoundCrudRepositories} from '../helpers'; + +export function hasManyThroughInclusionResolverAcceptance( + dataSourceOptions: DataSourceOptions, + repositoryClass: CrudRepositoryCtor, + features: CrudFeatures, +) { + skipIf<[(this: Suite) => void], void>( + !features.supportsInclusionResolvers, + describe, + 'HasManyThrough inclusion resolvers - acceptance', + suite, + ); + function suite() { + describe('HasManyThrough inclusion resolver', () => { + before(deleteAllModelsInDefaultDataSource); + let customerRepo: CustomerRepository; + let cartItemRepo: CartItemRepository; + let customerCartItemLinkRepo: CustomerCartItemLinkRepository; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + // this helper should create the inclusion resolvers and also + // register inclusion resolvers for us + ({ + customerRepo, + cartItemRepo, + customerCartItemLinkRepo, + } = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + features, + )); + expect(customerRepo.cartItems.inclusionResolver).to.be.Function(); + + await ctx.dataSource.automigrate([ + Customer.name, + CartItem.name, + CustomerCartItemLink.name, + ]); + }), + ); + + beforeEach(async () => { + await customerRepo.deleteAll(); + await cartItemRepo.deleteAll(); + await customerCartItemLinkRepo.deleteAll(); + }); + + it('throws an error if tries to query nonexistent relation names', async () => { + const customer = await customerRepo.create({name: 'customer'}); + await customerRepo + .cartItems(customer.id) + .create({description: 'crown'}); + + await expect( + customerRepo.find({include: [{relation: 'crown'}]}), + ).to.be.rejectedWith( + `Invalid "filter.include" entries: {"relation":"crown"}`, + ); + }); + + it('returns single model instance including single related instance', async () => { + const zelda = await customerRepo.create({name: 'Zelda'}); + const cartItem = await customerRepo + .cartItems(zelda.id) + .create({description: 'crown'}); + + const result = await customerRepo.find({ + include: [{relation: 'cartItems'}], + }); + + expect(toJSON(result)).to.deepEqual([ + toJSON({ + ...zelda, + parentId: features.emptyValue, + cartItems: [cartItem], + }), + ]); + }); + + it('returns multiple model instances including related instances', async () => { + const link = await customerRepo.create({name: 'Link'}); + const sword = await customerRepo + .cartItems(link.id) + .create({description: 'master sword'}); + const shield = await customerRepo + .cartItems(link.id) + .create({description: 'shield'}); + const hat = await customerRepo + .cartItems(link.id) + .create({description: 'green hat'}); + + const result = await customerRepo.find({ + include: [{relation: 'cartItems'}], + }); + + const expected = [ + { + ...link, + parentId: features.emptyValue, + cartItems: [sword, shield, hat], + }, + ]; + + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('returns a specified instance including its related model instances', async () => { + const link = await customerRepo.create({name: 'Link'}); + const zelda = await customerRepo.create({name: 'Zelda'}); + + const zeldaCart = await customerRepo + .cartItems(zelda.id) + .create({description: 'crown'}); + await customerRepo.cartItems(link.id).create({description: 'shield'}); + await customerRepo + .cartItems(link.id) + .create({description: 'green hat'}); + + const result = await customerRepo.findById(zelda.id, { + include: [{relation: 'cartItems'}], + }); + const expected = { + ...zelda, + parentId: features.emptyValue, + cartItems: [zeldaCart], + }; + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('honours field scope when returning a model', async () => { + const link = await customerRepo.create({name: 'Link'}); + const sword = await customerRepo + .cartItems(link.id) + .create({description: 'master sword'}); + const shield = await customerRepo + .cartItems(link.id) + .create({description: 'shield'}); + + const result = await customerRepo.find({ + include: [{relation: 'cartItems', scope: {fields: {id: false}}}], + }); + + const expected = [ + { + ...link, + parentId: features.emptyValue, + cartItems: [ + {description: sword.description}, + {description: shield.description}, + ], + }, + ]; + + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + + it('honours limit scope when returning a model', async () => { + const link = await customerRepo.create({name: 'Link'}); + await customerRepo + .cartItems(link.id) + .create({description: 'master sword'}); + await customerRepo.cartItems(link.id).create({description: 'shield'}); + + const result = await customerRepo.find({ + include: [{relation: 'cartItems', scope: {limit: 1}}], + }); + + expect(result.length).to.eql(1); + expect(result[0].cartItems.length).to.eql(1); + }); + }); + + describe('HasManyThrough inclusion resolver - self through', () => { + before(deleteAllModelsInDefaultDataSource); + let userRepo: UserRepository; + let userLinkRepo: UserLinkRepository; + + before( + withCrudCtx(async function setupRepository(ctx: CrudTestContext) { + ({userRepo, userLinkRepo} = givenBoundCrudRepositories( + ctx.dataSource, + repositoryClass, + features, + )); + expect(userRepo.users.inclusionResolver).to.be.Function(); + await ctx.dataSource.automigrate([User.name, UserLink.name]); + }), + ); + + beforeEach(async () => { + await userRepo.deleteAll(); + await userLinkRepo.deleteAll(); + }); + + it('returns single model instance including single related instance', async () => { + const link = await userRepo.create({name: 'Link'}); + const zelda = await userRepo.users(link.id).create({name: 'zelda'}); + + const result = await userRepo.findById(link.id, { + include: [{relation: 'users'}], + }); + + expect(toJSON(result)).to.deepEqual( + toJSON({ + ...link, + users: [zelda], + }), + ); + }); + + it('returns multiple model instances including related instances', async () => { + const link = await userRepo.create({name: 'Link'}); + const zelda = await userRepo.users(link.id).create({name: 'zelda'}); + const ganon = await userRepo.users(link.id).create({name: 'ganon'}); + const hilda = await userRepo.users(link.id).create({name: 'hilda'}); + + const result = await userRepo.find({ + include: [{relation: 'users'}], + }); + + const expected = [ + {...link, users: [zelda, ganon, hilda]}, + {...zelda}, + {...ganon}, + {...hilda}, + ]; + + expect(toJSON(result)).to.deepEqual(toJSON(expected)); + }); + }); + } +} diff --git a/packages/repository-tests/src/crud/relations/fixtures/models/index.ts b/packages/repository-tests/src/crud/relations/fixtures/models/index.ts index c59d6c65c9de..73cd7dd65c72 100644 --- a/packages/repository-tests/src/crud/relations/fixtures/models/index.ts +++ b/packages/repository-tests/src/crud/relations/fixtures/models/index.ts @@ -9,5 +9,5 @@ export * from './customer-cart-item-link.model'; export * from './customer.model'; export * from './order.model'; export * from './shipment.model'; -export * from './user.model'; export * from './user-link.model'; +export * from './user.model'; diff --git a/packages/repository-tests/src/crud/relations/helpers.ts b/packages/repository-tests/src/crud/relations/helpers.ts index c3921e14017f..5dea87fe57e3 100644 --- a/packages/repository-tests/src/crud/relations/helpers.ts +++ b/packages/repository-tests/src/crud/relations/helpers.ts @@ -19,9 +19,9 @@ import { Shipment, ShipmentRepository, User, - UserRepository, UserLink, UserLinkRepository, + UserRepository, } from './fixtures/models'; import { createAddressRepo, @@ -30,8 +30,8 @@ import { createCustomerRepo, createOrderRepo, createShipmentRepo, - createUserRepo, createUserLinkRepo, + createUserRepo, } from './fixtures/repositories'; export function givenBoundCrudRepositories( @@ -98,6 +98,10 @@ export function givenBoundCrudRepositories( 'address', customerRepo.address.inclusionResolver, ); + customerRepo.inclusionResolvers.set( + 'cartItems', + customerRepo.cartItems.inclusionResolver, + ); const orderRepoClass = createOrderRepo(repositoryClass); const orderRepo: OrderRepository = new orderRepoClass( @@ -147,6 +151,8 @@ export function givenBoundCrudRepositories( const userLinkRepoClass = createUserLinkRepo(repositoryClass); const userLinkRepo: UserLinkRepository = new userLinkRepoClass(db); + userRepo.inclusionResolvers.set('users', userRepo.users.inclusionResolver); + return { customerRepo, orderRepo, diff --git a/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.unit.ts b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.unit.ts index 7030c810faab..0bdf00c59708 100644 --- a/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.unit.ts +++ b/packages/repository/src/__tests__/unit/repositories/relations-helpers/flatten-targets-of-one-to-many-relation-helpers.unit.ts @@ -33,5 +33,14 @@ describe('flattenTargetsOfOneToManyRelation', () => { ); expect(result).to.deepEqual([[eraser], [pen, pencil]]); }); + it('returns undefined if a source id yields no results', () => { + const pen = createProduct({name: 'pen', categoryId: 1}); + const result = flattenTargetsOfOneToManyRelation( + [1, 2], + [pen], + 'categoryId', + ); + expect(result).to.deepEqual([[pen], undefined]); + }); }); }); diff --git a/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts b/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts index 7c4be06b56ea..6fc2f649e16e 100644 --- a/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts +++ b/packages/repository/src/relations/has-many/has-many-through-repository.factory.ts @@ -2,12 +2,14 @@ // Node module: @loopback/repository // This file is licensed under the MIT License. // License text available at https://opensource.org/licenses/MIT + import { DataObject, Entity, EntityCrudRepository, Getter, HasManyDefinition, + InclusionResolver, } from '../..'; import { createTargetConstraintFromThrough, @@ -17,6 +19,7 @@ import { getTargetKeysFromThroughModels, resolveHasManyThroughMetadata, } from './has-many-through.helpers'; +import {createHasManyThroughInclusionResolver} from './has-many-through.inclusion.resolver'; import { DefaultHasManyThroughRepository, HasManyThroughRepository, @@ -35,9 +38,18 @@ export type HasManyThroughRepositoryFactory< TargetID, ThroughEntity extends Entity, SourceID -> = ( - fkValue: SourceID, -) => HasManyThroughRepository; +> = { + (fkValue: SourceID): HasManyThroughRepository< + TargetEntity, + TargetID, + ThroughEntity + >; + + /** + * Use `resolver` property to obtain an InclusionResolver for this relation. + */ + inclusionResolver: InclusionResolver; +}; export function createHasManyThroughRepositoryFactory< Target extends Entity, @@ -100,5 +112,10 @@ export function createHasManyThroughRepositoryFactory< getThroughConstraintFromTarget, ); }; + result.inclusionResolver = createHasManyThroughInclusionResolver( + meta, + throughRepositoryGetter, + targetRepositoryGetter, + ); return result; } diff --git a/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts b/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts new file mode 100644 index 000000000000..87a25377fd2f --- /dev/null +++ b/packages/repository/src/relations/has-many/has-many-through.inclusion.resolver.ts @@ -0,0 +1,135 @@ +// Copyright IBM Corp. 2020. All Rights Reserved. +// Node module: @loopback/repository +// This file is licensed under the MIT License. +// License text available at https://opensource.org/licenses/MIT + +import {Filter, Inclusion} from '@loopback/filter'; +import debugFactory from 'debug'; +import {AnyObject, Options} from '../../common-types'; +import {Entity} from '../../model'; +import {EntityCrudRepository} from '../../repositories/repository'; +import { + findByForeignKeys, + flattenTargetsOfOneToManyRelation, + StringKeyOf, +} from '../relation.helpers'; +import {Getter, HasManyDefinition, InclusionResolver} from '../relation.types'; +import {resolveHasManyMetadata} from './has-many.helpers'; + +const debug = debugFactory( + 'loopback:repository:has-many-through-inclusion-resolver', +); + +/** + * Creates InclusionResolver for HasManyThrough relation. + * Notice that this function only generates the inclusionResolver. + * It doesn't register it for the source repository. + * + * + * @param meta - metadata of the hasMany relation (including through) + * @param getThroughRepo - through repository getter i.e. where through + * instances are + * @param getTargetRepo - target repository getter i.e where target instances + * are + */ +export function createHasManyThroughInclusionResolver< + Through extends Entity, + ThroughID, + ThroughRelations extends object, + Target extends Entity, + TargetID, + TargetRelations extends object +>( + meta: HasManyDefinition, + getThroughRepo: Getter< + EntityCrudRepository + >, + getTargetRepo: Getter< + EntityCrudRepository + >, +): InclusionResolver { + const relationMeta = resolveHasManyMetadata(meta); + + return async function fetchHasManyThroughModels( + entities: Entity[], + inclusion: Inclusion, + options?: Options, + ): Promise<((Target & TargetRelations)[] | undefined)[]> { + if (!entities.length) return []; + + debug('Fetching target models for entities:', entities); + debug('Relation metadata:', relationMeta); + + const sourceKey = relationMeta.keyFrom; + const sourceIds = entities.map(e => (e as AnyObject)[sourceKey]); + const targetKey = relationMeta.keyTo as StringKeyOf; + if (!relationMeta.through) { + throw new Error( + `relationMeta.through must be defined on ${relationMeta}`, + ); + } + const throughKeyTo = relationMeta.through.keyTo as StringKeyOf; + const throughKeyFrom = relationMeta.through.keyFrom as StringKeyOf; + + debug('Parameters:', { + sourceKey, + sourceIds, + targetKey, + throughKeyTo, + throughKeyFrom, + }); + + debug( + 'sourceId types', + sourceIds.map(i => typeof i), + ); + + const throughRepo = await getThroughRepo(); + const targetRepo = await getTargetRepo(); + + // find through models + const throughFound = await findByForeignKeys( + throughRepo, + throughKeyFrom, + sourceIds, + {}, // scope will be applied at the target level + options, + ); + + const throughResult = flattenTargetsOfOneToManyRelation( + sourceIds, + throughFound, + throughKeyFrom, + ); + + const result = []; + + // convert from through entities to the target entities + for (const entityList of throughResult) { + if (entityList) { + // get target ids from the through entities by foreign key + const targetIds = entityList.map(entity => entity[throughKeyTo]); + + // the explicit types and casts are needed + const targetEntityList = await findByForeignKeys< + Target, + TargetRelations, + StringKeyOf + >( + targetRepo, + targetKey, + (targetIds as unknown) as [], + inclusion.scope as Filter, + options, + ); + result.push(targetEntityList); + } else { + // no entities found, add undefined to results + result.push(entityList); + } + } + + debug('fetchHasManyThroughModels result', result); + return result; + }; +} diff --git a/packages/repository/src/relations/relation.helpers.ts b/packages/repository/src/relations/relation.helpers.ts index e9ea38479687..cd04f05ae61f 100644 --- a/packages/repository/src/relations/relation.helpers.ts +++ b/packages/repository/src/relations/relation.helpers.ts @@ -24,7 +24,7 @@ const debug = debugFactory('loopback:repository:relation-helpers'); * @param targetRepository - The target repository where the related model instances are found * @param fkName - Name of the foreign key * @param fkValues - One value or array of values of the foreign key to be included - * @param scope - Additional scope constraints (not currently supported) + * @param scope - Additional scope constraints * @param options - Options for the operations */ export async function findByForeignKeys<