Page Contents

Overview

A hasManyThrough relation denotes a many-to-many connection with another model. The referential integrity is enforced by foreign key constraints on the through model which usually references primary keys on the source model and the target model. This relation indicates that the declaring model can be matched with zero or more instances of another model by proceeding through a third model. For example, in an application for a medical practice where patients make appointments to see doctors, the relevant relation declarations are illustrated in the diagram below.

hasManyThrough relation illustration

The diagram shows through model Appointment has a property doctorId as the foreign key to reference the source model Doctor’s primary key id and a property patientId as the foreign key to reference the target model Patient’s primary key pid.

To add a hasManyThrough relation to your LoopBack application and expose its related routes, you need to perform the following steps:

  1. Add a property to define the relation to your model to access related model instances.
  2. Add a foreign key property in the through model referring to the source model’s id.
  3. Add a foreign key property in the through model referring to the target model’s id.
  4. Modify the source model repository class to provide access to a constrained target model repository.
  5. Call the constrained target model repository CRUD APIs in your controller methods.

Defining a hasManyThrough Relation

This section describes how to define a hasManyThrough relation at the model level using the @hasMany decorator (in LoopBack, hasManyThrough is considered being part of hasMany). Instead of constraining the target repository by the foreign key property on the target model, it uses a through model that has two foreign keys that reference the source model and target model, respectively. The following example shows how to define a hasManyThrough relation on a source model Doctor and a target model Patient through model Appointment.

/src/models/doctor.model.ts

import {Patient} from './patient.model';
import {Appointment} from './appointment.model';
import {Entity, property, hasMany} from '@loopback/repository';

export class Doctor extends Entity {
  @property({
    type: 'number',
    id: true,
  })
  id: number;

  @property({
    type: 'string',
    required: true,
  })
  name: string;

  @hasMany(() => Patient, {through: {model: () => Appointment}})
  patients: Patient[];

  constructor(data: Partial<Doctor>) {
    super(data);
  }
}

/src/models/appointment.model.ts

import {Entity, property, hasMany} from '@loopback/repository';

export class Appointment extends Entity {
  // id property and others

  @property({
    type: 'number',
  })
  doctorId?: number;

  @property({
    type: 'number',
  })
  patientId?: number;

  constructor(data: Partial<Appointment>) {
    super(data);
  }
}

/src/models/patient.model.ts

import {Entity, property, hasMany} from '@loopback/repository';

export class Patient extends Entity {
  @property({
    type: 'number',
    id: true,
  })
  pid: number;

  // other properties
}

The definition of the hasManyThrough relation is inferred by using the @hasMany decorator. The decorator takes in a function resolving the relation metadata. Except for the target model, you will need to specify the through model and optionally foreign keys that infer source and target models.

LB4 also provides an CLI tool lb4 relation to generate hasManyThrough relation for you. Before you check out the Relation Generator page, read on to learn how you can define relations to meet your requirements.

Relation metadata

There are several fields we care when defining a hasManyThrough. The decorated property name is used as the relation name and stored as part of the source model definition’s relation metadata.

Field Name Description Default Value Example
name the name of the relation decorated property name Doctor.patients
keyFrom the primary key of the source model the id property of the source model Doctor.id
keyTo the primary key of the target model the id property of the target model Patient.pid
through.model the name of the through model N/A. The through model name is needed for defining a hasManyThrough relation. Appointment
through.keyFrom the foreign key that references the source model on the through model the source model name appended with Id in camel case Appointment.doctorId
through.keyTo the foreign key of the target model the target model name appended with Id in camel case Appointment.patientId

The two foreign keys on through model can only reference the primary keys of source and target models. Customization of keyFrom and keyTo is not supported yet. However, custom foreign keys on through model is possible. A usage of the decorator with custom foreign keys name for the above example is as follows:

/src/models/doctor.model.ts

// import statements
class Doctor extends Entity {
  // constructor, properties, etc.
  @hasMany(() => Patient, {
    through: {
      model: () => Appointment,
      keyFrom: 'myDoctor',
      keyTo: 'myPatient',
    },
  })
  patients: Patient[];
}

/src/models/appointment.model.ts

import {Entity, property, hasMany} from '@loopback/repository';

export class Appointment extends Entity {
  // id property and others

  @property({
    type: 'number',
  })
  myDoctor?: number; // custom name, refers to Doctor.id

  @property({
    type: 'number',
  })
  myPatient?: number; // custom name, refers to Patient.pid
  // ...
}

If you need to use different names for models and database columns, to use my_patients as db column name other than patients for example, the following setting would allow you to do so:

// import statements
@model()
export class Doctor extends Entity {
  // constructor, properties, etc.
  @hasMany(() => Patient, {_relationMetadata_}, {name: 'my_patients'})
  patients: Patient[];
}

Notice: the name field in the third parameter is not part of the relation metadata. It’s part of property definition.

Configuring a hasManyThrough relation

The configuration and resolution of a hasManyThrough relation takes place at the repository level. Once the relation is defined on the source model, then there are a couple of steps involved to configure it and use it. On the source repository, the following are required:

  • In the constructor of your source repository class, use Dependency Injection to receive getter functions for obtaining an instance of the target repository and through repository.
  • Declare a property with the factory function type HasManyThroughRepositoryFactory<targetModel, typeof targetModel.prototype.id, throughModel, typeof sourceModel.prototype.id> on the source repository class.
  • call the createHasManyThroughRepositoryFactoryFor function in the constructor of the source repository class with the relation name (decorated relation property on the source model), target repository getter, and through repository getter.

The following code snippet shows how it would look like:

/src/repositories/doctor.repository.ts

import {Patient, Doctor, DoctorRelations, Appointment} from '../models';
import {PatientRepository, AppointmentRepository} from '../repositories';
import {
  DefaultCrudRepository,
  juggler,
  HasManyThroughRepositoryFactory,
  repository,
} from '@loopback/repository';
import {inject, Getter} from '@loopback/core';

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<PatientRepository>,
    @repository.getter('AppointmentRepository')
    appointmentRepositoryGetter: Getter<AppointmentRepository>,
  ) {
    super(Doctor, db);
    this.patients = this.createHasManyThroughRepositoryFactoryFor(
      'patients',
      patientRepositoryGetter,
      appointmentRepositoryGetter,
    );
  }
}

Available CRUD APIs

  • create for creating a target model instance belonging to source model instance (API Docs)
  • find finding target model instance(s) belonging to source model instance (API Docs)
  • delete for deleting target model instance(s) belonging to source model instance (API Docs)
  • patch for patching target model instance(s) belonging to source model instance (API Docs)
  • link for linking a target model instance to source model instance (API Docs)
  • unlink for unlinking a target model instance from source model instance (API Docs)
  • unlinkAll for unlinking all target model instances from source model instance (API Docs)

Here are examples of applying CRUD APIs with constrained target repository factory patients for instances of doctorRepository:

  • creation:
const myDoctor = await doctorRepository.create({id: 1, name: 'Rachael'});
const patientData = {pid: 1, name: 'Batty'};
// create the related patient
doctorRepository.patients(myDoctor.id).create(patientData);
  • deletion: doctorRepository.patients(myDoctor.id).delete() deletes all patients relate to myDoctor.
  • link: doctorRepository.patients(myDoctor.id).link(anotherPatient.pid) links anotherPatient to myDoctor.

Self through

In some cases, you may want to define a relationship from a model to itself. For example, consider a social media application where users can follow other users. In this case, a user may follow many other users and may be followed by many other users. The setup is mostly the same. Please make sure to define your own two foreign key names on the through model to avoid duplicate name errors.

The code below shows how this might be defined in models, along with corresponding repository setups:

/src/models/user.model.ts

// import statements
class User extends Entity {
  @property(
    type: 'number',
    id: true
  )
  uid: number;

  @property(
    type: 'string',
  )
  name: string;

  @hasMany(() => User, {
    through: {
      model: () => UserLink,
      keyFrom: 'followerId',
      keyTo: 'followeeId',
    },
  })
  users: User[];
  // constructor, properties, etc.
}

/src/models/userLink.model.ts

// imports
export class UserLink extends Entity {
  // id property and others

  @property({
    type: 'number',
  })
  followerId?: number;

  @property({
    type: 'number',
  })
  followeeId?: number;
  // ...
}

/src/repositories/user.repository.ts

// imports
export class UserRepository extends DefaultCrudRepository<
  User,
  typeof User.prototype.id,
  UserRelations
> {
  public readonly users: HasManyThroughRepositoryFactory<
    User,
    typeof User.prototype.pid,
    UserLink,
    typeof User.prototype.id
  >;
  constructor(
    @inject('datasources.db') protected db: juggler.DataSource,
    @repository.getter('UserLinkRepository')
    protected userLinkRepositoryGetter: Getter<UserLinkRepository>,
  ) {
    super(User, dataSource);
    this.users = this.createHasManyThroughRepositoryFactoryFor(
      'users',
      Getter.fromValue(this), // getter for self repository
      userLinkRepositoryGetter,
    );
  }
}

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 Patients 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:

doctorRepository.find({include: ['patients']});

or use APIs with controllers:

GET http://localhost:3000/doctors?filter[include][]=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’:

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<PatientRepository>,
    @repository.getter('AppointmentRepository')
    appointmentRepositoryGetter: Getter<AppointmentRepository>,
  ) {
    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:

    doctorRepository.find({include: ['patients']});
    

    this is the same as the url:

    GET http://localhost:3000/doctors?filter[include][]=patients
    

    which returns:

    [
      {
        id: 1,
        name: 'Doctor Mario',
        patients: [{name: 'Luigi'}, {name: 'Peach'}],
      },
      {
        id: 2,
        name: 'Doctor Link',
        patients: [{name: 'Zelda'}],
      },
    ];
    
  • 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 methods can call the underlying constrained repository CRUD APIs and expose them as routes once decorated with Route decorators. It will require the value of the foreign key and, depending on the request method, a value for the target model instance as demonstrated below.

src/controllers/doctor-patient.controller.ts

import {post, param, requestBody} from '@loopback/rest';
import {DoctorRepository} from '../repositories/';
import {Doctor, Patient} from '../models/';
import {repository} from '@loopback/repository';

export class DoctorPatientController {
  constructor(
    @repository(DoctorRepository)
    protected doctorRepository: DoctorRepository,
  ) {}

  @post('/doctors/{id}/patient')
  async createPatient(
    @param.path.number('id') id: typeof Doctor.prototype.id,
    @requestBody() patientData: Patient,
  ): Promise<Patient> {
    return this.doctorRepository.patients(id).create(patientData);
  }
}

We recommend to create a new controller for each relation in LoopBack 4. First, it keeps controller classes smaller. Second, it creates a logical separation of ordinary repositories and relational repositories and thus the controllers which use them. Therefore, as shown above, don’t add patient-related methods to DoctorController, but instead create a new DoctorPatientController class for them.

Features on the way

As an experimental feature, there are some functionalities of hasManyThrough that are not yet being implemented:

  • customize keyFrom and/or keyTo for hasManyThrough