Playing Next Lesson In
seconds

Let's Learn AdonisJS 7 #3.1

Models, Schema Classes, & Relationships

In This Lesson

Learn AdonisJS models and generated schema classes. We'll discuss model hooks, getters, computed properties, relationships, and how models can use schema classes as a base to automatically keep model columns up-to-date with our database.

Created by
@tomgobich
Published

In the last lesson, we saw that AdonisJS generates schema classes when we run and rollback our migrations. So, what are schemas?

What Are Schemas Classes?

New in AdonisJS 7, schema classes are generated from a migration-first approach to represent the tables in our database after migration. Though they're auto-generated, they are meant to be used and don't merely represent types. For that reason, they aren't housed within our .adonisjs folder, but instead within database/schema.ts.

This schema.ts file will contain exported schema classes for all the tables within our database. Each class describes a table's columns and the column's types. For example, here is our UserSchema describing our users table.

export class UserSchema extends BaseModel {
  static $columns = ['id', 'roleId', 'fullName', 'email', 'password', 'createdAt', 'updatedAt'] as const
  $columns = UserSchema.$columns
  @column({ isPrimary: true })
  declare id: number
  @column()
  declare roleId: number
  @column()
  declare fullName: string | null
  @column()
  declare email: string
  @column({ serializeAs: null })
  declare password: string
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime
  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}
Copied!
  • database
  • schema.ts

The @column() decorators on top of each property notes that the property represents a column in our database. Any properties without the @column() decorator are then what we call not-mapped, meaning they aren't mapped to the database directly. Our database convention is to use snake case, but the JavaScript convention is to use camelCase in code. Lucid is already expecting both of these conventions and will handle that transition from snake case in our database to camel case in our models without issue.

Nullable columns are noted via their type.

@column()
declare fullName: string | null
Copied!

The primary key column is specially noted for the ORM's convenience.

@column({ isPrimary: true })
declare id: number
Copied!

We can omit a property from all serializations by setting the serializeAs property of the @column() decorator's config to null.

@column({ serializeAs: null })
declare password: string
Copied!

Finally, we can mutate the timestamp columns to Luxon DateTimes by adding @column.dateTime().

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
Copied!

On these, setting autoCreate to true will auto-set the value to the current date and time when a record is created or inserted into our table. Then, setting autoUpdate to true will auto-update the value to the current date and time when a record is updated within the table.

You might've also noticed these schema classes are extending a BaseModel class. This BaseModel is the powerhouse of the ORM, taking this mapping and making it usable so we can query or mutate data within the table using what will eventually be our models.

What Are Models?

Models are a specialized class that represents a single table within our database. Properties within the class represent columns of the table. Finally, an instance of the class represents a single row of data within the table.

These model representations act as a translator, allowing us to describe the queries or operations in TypeScript. It then converts it to SQL, executes it, and translates the results back into TypeScript for us to use.

Let's use the Ace CLI's make:model command to create a model for our roles table.

node ace make:model role
# DONE:    create app/models/role.ts
Copied!

Since an instance of a model represents a single row, the convention is to use a singular name for the model.

Also, with the make:model command, we can create additional things like migrations, controllers, and factories!

> $ node ace make:model --help

Description:
  Make a new Lucid model

Usage:
  node ace make:model [options] [--] <name>

Arguments:
  name              Name of the model class

Options:
  -m, --migration   Generate the migration for the model
  -c, --controller  Generate the controller for the model
  -f, --factory     Generate a factory for the model
Copied!

As noted by our DONE statement from our make:model command, we can find our models within the app/models/ directory. If we open our Role model, we should see the following.

import { RoleSchema } from '#database/schema'

export default class Role extends RoleSchema {
}
Copied!
  • app
  • models
  • role.ts

As you can see, it's merely a class extending our RoleSchema.

export class RoleSchema extends BaseModel {
  static $columns = ['id', 'name', 'createdAt', 'updatedAt'] as const
  $columns = RoleSchema.$columns
  @column({ isPrimary: true })
  declare id: number
  @column()
  declare name: string
  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime | null
  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime | null
}
Copied!
  • database
  • schema.ts

The RoleSchema's job is to describe the columns of our roles table. It's housed within a class of its own, so it can be easily auto-generated without breaking the other mappings we'll add to our model. If you have any columns that need to be altered from their auto-generated schema representation, they can be overridden inside our model.

For example, say our role's name column was role_name in the database, but we wanted to just call it name in the model, we could do the following.

import { RoleSchema } from '#database/schema'
import { column, hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Role extends RoleSchema {
  @column({ columnName: 'role_name' })
  declare name: string

  @hasMany(() => User)
  declare users: HasMany<typeof User>
}
Copied!
  • app
  • models
  • role.ts

Model Relationships

So, what are those other mappings we'll add to our model? One mapping we'll have is for relationships. Relationship mappings allow us to describe that a user has one role and a role can have many users, for example.

This ultimately will allow us to then query a user record and load it along with its role record, so we can display the role via user.role.name. Or, conversely, a role with an array of its users.

In our Role model, we can do this by adding a users property and decorating it with Lucid's @hasMany() decorator. This decorator is then passed in, via callback, the related model (our User). Each relationship also has its own type that accepts a generic of the type of the related model as well.

import { RoleSchema } from '#database/schema'
import { hasMany } from '@adonisjs/lucid/orm'
import type { HasMany } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Role extends RoleSchema {
  @hasMany(() => User)
  declare users: HasMany<typeof User>
}
Copied!

AdonisJS has a convention it follows for defaults on our relationship, all of which can be overwritten by providing them as options in the second argument of the decorator.

  • localKey - the local primary key, defaulting to the primary key of the parent model, id in our case.

  • foreignKey - the related primary key, defaulting to a camel case concatenation of the parent model name followed by its primary key, roleId in our case.

There are five different types of relationships:

  1. hasOne - a user has one profile

  2. hasMany - a role has many users

  3. belongsTo - a profile belongs to a user

  4. manyToMany - a challenge has many users, and a user can have many challenges

  5. hasManyThrough - a role can have created many challenges through its user

All of these relationship options, except those prefixed with pivot, like pivotForeignKey, should use our model's name for the column (roleId) instead of the table's name (role_id). The pivot options are an exception because, as we'll see in a bit, pivot tables are represented by a many-to-many relationship instead of a model.

Let's go ahead and jump over to our User model to describe the inverse side of this relationship. You'll notice there's more going on with our User model. That's okay, for now, let's focus on the task at hand!

import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
import { belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
  @belongsTo(() => Role)
  declare role: BelongsTo<typeof Role>

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first && last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}
Copied!
  • app
  • models
  • user.ts

Typically, if the relationship ID is described on the model, it'll be a BelongsTo relationship. Since our Users model consists of a roleId column, that's exactly the case here.

Getters & Computed Properties

Okay, while we're on our User model, let's talk about get initials(). This is a getter, and it'll act just like a property, though executing the method when used to return the value. These are non-mapped properties, meaning they aren't present in our database, that we can add onto our models to aid in development. Note, getters cannot be asynchronous; this is a limitation of the language, not the framework.

Like getters, we can define whatever properties or methods we want on our models. So long as the @column() and relationship decorators are omitted, they'll be treated as non-mapped properties.

They are, however, omitted when the model is serialized by default. So, when our models are converted to JSON, for example, these properties will be omitted. If we want them to be included, we can decorate them with the @computed() decorator.

import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
import { belongsTo, computed } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
  @belongsTo(() => Role)
  declare role: BelongsTo<typeof Role>

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
  @computed()
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first && last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}
Copied!
  • app
  • models
  • user.ts

With this decorator added to the property, it informs AdonisJS to keep the property within the serialized result.

Great, while we're here, let's create our remaining two models and fill in their relationships.

node ace make:model profile
# DONE:    create app/models/profile.ts
Copied!
node ace make:model challenges
# DONE:    create app/models/challenge.ts
Copied!

Again, we won't be creating a model for our challenge_user pivot table. Pivot tables can be omitted and worked with directly via their many-to-many relationship within the ORM.

Our Profile model has the userId column on it, so our profile belongs to a user.

import { ProfileSchema } from '#database/schema'
import { belongsTo } from '@adonisjs/lucid/orm'
import User from './user.ts'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'

export default class Profile extends ProfileSchema {
  @belongsTo(() => User)
  declare user: BelongsTo<typeof User>
}
Copied!

Then, for our Challenge model, challenges belong to a user in two different ways.

  1. They're created by a user, tracked via the creatorId

  2. They have users who've joined the challenge via our challenge_user pivot table

import { ChallengeSchema } from '#database/schema'
import { belongsTo, manyToMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, ManyToMany } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Challenge extends ChallengeSchema {
  @belongsTo(() => User, {
    foreignKey: 'creatorId',
  })
  declare createdBy: BelongsTo<typeof User>

  @manyToMany(() => User)
  declare participants: ManyToMany<typeof User>
}
Copied!
  • app
  • models
  • challenge.ts

Since we're straying from the default naming convention of userId (parent model name + primary key) with our creatorId, we need to manually specify that as the foreign key for our BelongsTo relationship. Also, we can call the property name whatever we'd like for relationships, which gives us nice flexibility to be descriptive.

Then, for our ManyToMany, the options look a little different, should you need to use them.

  • localKey - primary key of the current model

  • pivotForeignKey - pivot table's foreign key for current model (use the table column name)

  • relatedKey - primary key of the related model

  • pivotRelatedForeignKey - pivot table's foreign key for the related model (use the table column name)

We don't need to, because we match the default convention, but if we manually describe this relationship's keys, it'd look like this:

export default class Challenge extends ChallengeSchema {
  // ...

  @manyToMany(() => User, {
    localKey: 'id',
    pivotForeignKey: 'challenge_id',
    relatedKey: 'id',
    pivotRelatedForeignKey: 'user_id',
  })
  declare participants: ManyToMany<typeof User>
}
Copied!
  • app
  • models
  • challenge.ts

Finally, we can fill in the remaining relationships of our User model.

import { UserSchema } from '#database/schema'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
import { belongsTo, computed, hasMany, hasOne, manyToMany } from '@adonisjs/lucid/orm'
import type { BelongsTo, HasMany, HasOne, ManyToMany } from '@adonisjs/lucid/types/relations'
import Challenge from './challenge.ts'
import Profile from './profile.ts'
import Role from './role.ts'

/**
 * User model represents a user in the application.
 * It extends UserSchema and includes authentication capabilities
 * through the withAuthFinder mixin.
 */
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
  @belongsTo(() => Role)
  declare role: BelongsTo<typeof Role>

  @hasOne(() => Profile)
  declare profile: HasOne<typeof Profile>

  @hasMany(() => Challenge, {
    foreignKey: 'creatorId',
  })
  declare createdChallenges: HasMany<typeof Challenge>

  @manyToMany(() => Challenge)
  declare challenges: ManyToMany<typeof Challenge>

  /**
   * Get the user's initials from their full name or email.
   * Returns the first letter of first and last name if available,
   * otherwise returns the first two characters of the email username.
   */
  @computed()
  get initials() {
    const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
    if (first && last) {
      return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
    }
    return `${first.slice(0, 2)}`.toUpperCase()
  }
}
Copied!

Again, since our creatorId goes against the default naming convention, we'll specify the foreign key as creatorId here.

Model Hooks

Finally, although we haven't talked about create, read, update, or delete (CRUD) operations, let's take a moment to learn about hooks. They are specialized static methods defined on our models that can hook into certain operations being performed with our models. For example, we could hook into our Profile model to mutate it before it is persisted to the database.

import { ProfileSchema } from '#database/schema'
import { beforeSave, belongsTo } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import User from './user.ts'

export default class Profile extends ProfileSchema {
  @belongsTo(() => User)
  declare user: BelongsTo<typeof User>

  @beforeSave()
  static onBeforeSave(profile: Profile) {
    if (profile.$dirty.bio && !profile.bio) {
      profile.bio = '[no bio]'
    }
  }
}
Copied!

The name of the method doesn't matter; all that matters is that the method is static and is decorated with @beforeSave(). With that, Lucid will run the method before creating or updating a profile record in our database.

A little redundant, but we can also use special properties on our model, like $dirty for example, to see if one of our columns has been altered at all.

Available Hooks

All of the below hooks can be async, and all are provided the model instance/row being mutated.

  • @beforeSave() - run before an insert or update operation

  • @afterSave() - run after an insert or update operation

  • @beforeCreate() - run before an insert operation

  • @afterCreate() - run after an insert operation

  • @beforeUpdate() - run before an update operation

  • @afterUpdate() - run after an update operation

  • @beforeDelete() - run before a delete operation

  • @afterDelete() - run after a delete operation

There are also query-based hooks that receive either the query (before) or the results of the query (after).

  • @beforeFind() - run before find queries

  • @afterFind() - run after find queries

  • @beforeFetch() - run before fetch queries

  • @afterFetch() - run after fetch queries

  • @beforePaginate() - run before pagination queries

  • @afterPaginate() - run after pagination queries

Join the Discussion 0 comments

Create a free account to join in on the discussion
robot comment bubble

Be the first to comment!