Let's Learn Adonis 5: Defining Model Relationships

In this lesson, we'll learn about the different types of database relationships, how Adonis supports these relationship types, and how to define these relationships.

Published
Feb 06, 21
Duration
16m 38s

Developer, dog lover, and burrito eater. Currently teaching AdonisJS, a fully featured NodeJS framework, and running Adocasts where I post new lessons weekly. Professionally, I work with JavaScript, .Net C#, and SQL Server.

Adocasts

Burlington, KY

Defining relationships within our models unlocks a whole lot of power when it comes to querying and persisting data within our database. We'll be able to reference our relationships using related models without needing to define queries for each related model we need to populate.

Model Relationship Options

Within our model, we'll use both decorators and types to define a relationship. Adonis provides a decorator and a type for each type of relationship.

  • Decorator: @hasOne
    Type: HasOne
    Purpose: Used to define a one-to-one relationship

  • Decorator: @hasMany
    Type: HasMany
    Purpose: Used to define a many-to-one relationship

  • Decorator: @belongsTo
    Type: BelongsTo
    Purpose: Used to define the inverse of a HasOne or HasMany relationship

  • Decorator: @manyToMany
    Type: ManyToMany
    Purpose: Used to define a many-to-many relationship

So, to start, let's first understand the relationships we need to define for our users, then we'll take a look at the options Lucid provides to us, and finally, we'll put the two together.

Defining Our User's Relationships

If we inspect our User model you'll see we don't have a single relatable column defined. However, if we inspect our other models you'll see both Project and Task contain columns that related back to our User. Let's walk through these relationships and their relationship types.

  • tasks. created_by
    This will be a many-to-one relationship with our User. Our users can create many tasks, but a task can only be created by one user.

  • tasks.assigned_to
    This will also be a many-to-one relationship with our User. Our users can have many tasks assigned to them, but a task can only be assigned to one user.

  • project_users.user_id
    This will be a many-to-many. A user can be a member of many projects and a project will have many users.

How Do I Know Which To Use?

Recalling my college days, understanding database relationship types were always a struggling point. That's why I really like the way Adonis handles its relationship naming. In most cases, in non-technical terms, we can describe our relationships and have them correlate pretty closely to one of Adonis' relationship names.

So, let's walk through one of our relationships here to see how to determine which relationship type to use for a given relationship.

  1. Describe your relationship in non-technical terms.
    My users can have many tasks, and a task belongs to a specific user.

  2. Ask yourself which bucket your description fits into
    Since our user has many tasks but a task can only have one user, we can discern this is a many-to-one relationship type. If we pay attention to the terms we used to describe our relationship, we can also see these correlate pretty darn close to Adonis' relationship verbiage.

    "My user can have many tasks" - So we'll use HasMany to define this relationship from our User.

    "My task belongs to a specific user" - So we'll use BelongsTo to define the relationship from our Task.

    Then we'd need to define both sides of this relationship, using HasMany on our User model and BelongsTo on our Task model.

One thing I've found that is typically true is when a model contains an id column for the relationship it's usually the side that gets the BelongsTo. So, for the user-to-task relationship we just walked through, our createdBy column is on the Task model. Therefore, it'll be the side that gets the BelongsTo relationship definition. Note that for many-to-many relationships that's never the case since both sides will use ManyToMany.

How To Define A Relationship

Now that we've described our user's relationships and what our relationship options are, let's learn how to define our relationships. As I mentioned, to define a relationship we'll need to use both a decorator and a type for the relationship we need. So, for our user's relationship with task's created_by column, we would define the many-to-one relationship like so:

import { hasMany, HasMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import Task from 'App/Models/Task'

export default class User extends BaseModel {
  /* other fields */

  @hasMany(() => Task, {
    foreignKey: 'createdBy'
  })
  public tasks: HasMany<typeof Task>
}

So, as you can see we use the hasMany decorator to define the relationship type. We'll then pass a callback function with the model we're relating it to. Then we use the HasMany type, which we pass the typeof the model we're relating it to.

Now, here we're also passing an object as our decorator's second parameter with a foreignKey property. By default, Lucid will look for a userId property on our Task model to define the relationship. It'll default to a camel-cased concatenation of the model's name and the model's defined primary key column, which is typically an id. For this use case, it would default to looking for a userId.

However, this is not the case with our schema. This relationship is using the createdBy property on our Task model to define the related user. Therefore, we need to explicitly define the key the relationship needs to use. We use foreignKey to describe this here because the createdBy column exists on the other model, not our User.

Now that we have one side of our relationship defined, we need to hop over to our Task model and define the other side of the relationship. This will also let Lucid know our relationship context regardless if we're pulling the relationship from our User or our Task.

import { column, belongsTo, BelongsTo, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from 'App/Models/User'

export default class Task extends BaseModel {
  /* other fields */

  @column()
  public createdBy: number

  @belongsTo(() => User, {
    foreignKey: 'createdBy'
  })
  public user: BelongsTo<typeof User>
}

Just like with our User side of the relationship, Lucid will default to looking for a camel-cased concatenation of the related model name and its defined primary key column name, userId in our case. Since we're not using userId to describe this relationship, we need to manually define which property to use.

I'd also like to note that if our task's used a userId column to define the relationship, we wouldn't need to explicitly define a localKey or foreignKey on our relationship, since this is the default behavior. So, for example, our relationship would've looked like this:

export default class Task extends BaseModel {
  @column()
  public userId: number

  @belongsTo(() => User)
  public user: BelongsTo<typeof User>
}

The code structure here is the same whether you're defining a HasMany, HasOne, BelongsTo, or ManyToMany relationship. Additionally, as we did with our createdBy relationships, we can explicitly define the relationship fields for each as well. As you'll see a little later in this lesson, we can also define which intermediary table (pivot table) to use and which columns to auto-populate for our ManyToMany relationships when we query the relationship.

Completing User Relations

So, we have one of our User relationships done and dusted, let's wrap up the remaining. Our assignedTo relationship is going to be practically identical to our createdBy, so let's define that next.

Assigned To (Many-To-One)

Let's jump back into our User model, and let's copy and paste our tasks definition and change the name of the foreignKey from createdBy to assignedTo for our newly pasted definition. Now, whenever it comes to the naming of our relationship properties, we're welcome to use whatever makes sense. This is thanks to the relationship itself being defined by the decorator. However, we can't have duplicates, since that would lead to overwriting a relationship. So, for our assignedTo definition let's change the property name from tasks to assignedTasks.

import { hasMany, HasMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import Task from 'App/Models/Task'

export default class User extends BaseModel {
  /* other fields */

  @hasMany(() => Task, {
    foreignKey: 'createdBy'
  })
  public tasks: HasMany<typeof Task>

  @hasMany(() => Task, {
    foreignKey: 'assignedTo'
  })
  public assignedTasks: HasMany<typeof Task>
}

Now our User has a relationship defined for both tasks the user has created and tasks the user is assigned to.

Next, let's define the other side of this relationship. So, let's jump over to our Task model. Again, let's copy and paste our user property relationship definition and change the foreignKey from createdBy to assignedTo.

import { column, belongsTo, BelongsTo, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from 'App/Models/User'

export default class Task extends BaseModel {
  /* other fields */

  @column()
  public createdBy: number

  @column()
  public assignedTo: number

  @belongsTo(() => User, {
    foreignKey: 'createdBy'
  })
  public user: BelongsTo<typeof User>

  @belongsTo(() => User, {
    foreignKey: 'assignedTo'
  })
  public user: BelongsTo<typeof User>
}

Again, we can change these property names for our relationships to whatever makes sense. So, here it would probably make the most sense to change our createdBy's user property name to creator and our assignedTo's user property name to assignee.

import { column, belongsTo, BelongsTo, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from 'App/Models/User'

export default class Task extends BaseModel {
  /* other fields */

  @column()
  public createdBy: number

  @column()
  public assignedTo: number

  @belongsTo(() => User, {
    foreignKey: 'createdBy'
  })
  public creator: BelongsTo<typeof User>

  @belongsTo(() => User, {
    foreignKey: 'assignedTo'
  })
  public assignee: BelongsTo<typeof User>
}

User Projects (Many-To-Many)

The last relationship we need to define for our User is the many-to-many relationship for our user-to-project relationship.

In our User model, very similar to our other relationships, let's define a relationship for a projects property using the @manyToMany decorator and the ManyToMany type.

import { hasMany, HasMany, manyToMany, ManyToMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import Task from 'App/Models/Task'
import Project from 'App/Models/Project'

export default class User extends BaseModel {
  /* other fields */

  @hasMany(() => Task, {
    foreignKey: 'createdBy'
  })
  public tasks: HasMany<typeof Task>

  @hasMany(() => Task, {
    foreignKey: 'assignedTo'
  })
  public assignedTasks: HasMany<typeof Task>

  @manyToMany(() => Project)
  public projects: ManyToMany<typeof Project>
}

Now, by default on many-to-many relationships adonis will lowercase and alpha sort the two related model names, then snake case the two. This results in the table name Adonis will use to search for the intermediary table joining the many-to-many relationship.

So, here Adonis will default to looking for a table called project_users. Thankfully, that's what we're using. However, if that weren't what we were using we could explicitly define which intermediary to use via a pivotTable property on our relationship's options.

CORRECTION
Adonis will actually default to looking for the singular form of our snake-cased table concatenation instead of a plural form. So, Adonis will actually default to looking for a table called project_user. In a later lesson, we'll correct our migration to account for this.

export default class User extends BaseModel {
  /* other fields */

  @manyToMany(() => Project, {
    pivotTable: 'project_users'
  })
  public projects: ManyToMany<typeof Project>
}

For this relationship, Adonis will look for four different columns between these three tables. All of which can be explicitly defined in the relationship's options object.

  • localKey
    Purpose: An id column that's local (on the same model) to the relationship definition.
    Usage: localKey: 'id'
    Default: Model's defined primary key, which is by default id.

  • relatedKey
    Purpose: An id column for the related table.
    Usage: relatedKey: 'id'
    Default: Model's defined primary key, which is by default id.

  • pivotForiegnKey
    Purpose: An id column on the pivot table that relates to the localKey table.
    Usage: pivotForeignKey: 'user_id'
    Default: Snake-cased concatenation of the model name and the model's primary key. For our use case, the default here would be user_id.

  • pivotRelatedForeignKey
    Purpose: An id column on the pivot table that relates to the relatedKey table.
    Usage: pivotForeignKey: 'project_id'
    Default: Snake-cased concatenation of the model name and the model's primary key. For our use case, the default here would be project_id

Additionally, we can also define columns on the intermediary table (pivotTable) that we'd like to be included every time we query the many-to-many relationship. We can do this by an array of the columns we'd like to include to the pivotColumns property on our relationship definition's options.

The full rundown of all these many-to-many options would look like the below. Thankfully, in most cases, a lot or all of these will make use of the default values and won't need explicitly defined.

export default class User extends BaseModel {
  /* other fields */

  @manyToMany(() => Project, {
    pivotTable: 'project_users',
    pivotColumns: ['role_id'],
    pivotForeignKey: 'user_id',
    pivotRelatedForeignKey: 'project_id',
    localKey: 'id',
    relatedKey: 'id'
  })
  public projects: ManyToMany<typeof Project>
}

For our use-case, all of these are default except for the pivotColumns, so we can get rid of everything but the pivotColumns option on our user's projects relationship definition. So, at the end of the day here, our User model relationships should look like this:

import { hasMany, HasMany, manyToMany, ManyToMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import Task from 'App/Models/Task'
import Project from 'App/Models/Project'

export default class User extends BaseModel {
  /* other fields */

  @hasMany(() => Task, {
    foreignKey: 'createdBy'
  })
  public tasks: HasMany<typeof Task>

  @hasMany(() => Task, {
    foreignKey: 'assignedTo'
  })
  public assignedTasks: HasMany<typeof Task>

  @manyToMany(() => Project, {
    pivotColumns: ['role_id']
  })
  public projects: ManyToMany<typeof Project>
}

Lastly, we just need to define the inverse side of this relationship on our Project model in the same fashion.

import { ManyToMany, manyToMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from './User'

export default class Project extends BaseModel {
  /* other fields */
  
  @manyToMany(() => User, {
    pivotColumns: ['role_id']
  })
  public users: ManyToMany<typeof User>
}

Project Tasks (Many-To-Many)

Our last relationship is for our project tasks, and similar to our project users, this will also be a many-to-many. So, while we're in our Project model, let's go ahead and define our many-to-many relationship to our tasks.

import { ManyToMany, manyToMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from './User'
import Task from './Task'

export default class Project extends BaseModel {
  /* other fields */
  
  @manyToMany(() => User, {
    pivotColumns: ['role_id']
  })
  public users: ManyToMany<typeof User>

  @manyToMany(() => Task, {
    pivotColumns: ['sort_order']
  })
  public tasks: ManyToMany<typeof User>
}

Nothing new here that we didn't cover with our project users, the main difference is we'll want to include the sort_order column from our intermediary table.

Next, let's define the inverse side of this many-to-many relationship on our Task model.

import { column, belongsTo, BelongsTo, manyToMany, ManyToMany, /* ... */ } from '@ioc:Adonis/Lucid/Orm'
import User from 'App/Models/User'

export default class Task extends BaseModel {
  /* other fields */

  @column()
  public createdBy: number

  @column()
  public assignedTo: number

  @belongsTo(() => User, {
    foreignKey: 'createdBy'
  })
  public creator: BelongsTo<typeof User>

  @belongsTo(() => User, {
    foreignKey: 'assignedTo'
  })
  public assignee: BelongsTo<typeof User>

  @manyToMany(() => Project, {
    pivotColumns: ['sort_order']
  })
  public projects: ManyToMany<typeof Project>
}

Next Up

We should now have all of our relationships defined without our models. Next up is learning how to make use of them. However, before we learn how to make use of relationships, we must first learn how to make use of the models themselves. In the next lesson, we'll do just that, by learning how to persist data into our database using models.

Join The Discussion! (0 Comments)

Please sign in or sign up for free to join in on the dicussion.

robot comment bubble

Be the first to Comment!