Let's Learn Adonis 5: Intro to Models

This lesson is all about models. We'll learn what they are, how to define them, what they're used for, and some of the extended capabilities they provide.

Published
Jan 30, 21
Duration
16m 0s

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

Now that we've got our database populated with our schema using migrations, we're now ready to define our schema to Lucid using Models. Once we define our schema to Lucid, we'll be all set to start persisting and querying data from our database.

What Are Models?

Models are what we use to define our schema via TypeScript to Lucid. So, in most cases, we'll have a model for each table in our database. Then, within each model we'll describe the columns to Lucid, using TypeScript.

In addition to defining a table's columns via TypeScript in our models, we can also define computed properties, save hooks, query scopes, and serialization behavior. We'll dig into these later on.

Models Describe Relationships

We'll also use models to describe relationships to Lucid. Within our model, we can describe one-to-one, one-to-many, many-to-one, and many-to-many relationships. Thanks to this, in most cases we can skip creating models for intermediary tables altogether since they'll be described via the related model's relationship definition.

Models Can Mutate Data

We can also use models to persist, query, and delete data from the table the model represents. We can also reach through and do the same to related models as well. We'll be digging into this a little later in this series.

Creating Our First Model

So, let's go ahead and create our first model. Let's jump into our terminal and run:

$ node ace make:model User
# CREATE: app/Models/User.ts

This will create our User model at /app/Models/User.ts and it'll start us off with our User class, which extends a BaseModel from Adonis. Inside our User model, Adonis will start us with some defaults; an id primary key, a createdAt column, and an updatedAt column.

// app/Models/User.ts

import { DateTime } from 'luxon'
import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class User extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}

So, there are a few things to note here.

  1. Adonis makes use of decorators as a way to describe a property. If the property is a column in the table we can use the column decorator.

  2. We define our primary keys using the column decorator. It's also worth noting we could do this outside the decorator as a primaryKey property on our User model's class.

  3. As you can see with our createdAt and updatedAt properties, the column decorator can be used to describe dateTime, which also accepts whether the value should be auto-populated on insert and auto-updated on update.

  4. Adonis automatically serializes our snake-cased created_at and updated_at columns into a camel-case syntax. This behavior is consistent for all snake cased database columns.

Luxon Conversion

Adonis will also do something nice for us with our dateTime defined columns. When we grab records from our database, Adonis will convert the DateTime strings from our database record into full DateTime Luxon objects.

Defining Our User

Now that we understand a little about what's going on in our model, let's continue onward to describing the remainder of our user columns. So, we'll define our username, email, password, and remembermetoken columns as:

// app/Models/User.ts

export default class User extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public username: string

  @column()
  public email: string

  @column()
  public password: string

  @column()
  public rememberMeToken?: string

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}

Now, since our remembermetoken column is nullable, we'll want to reflect that in our model by making its property optional by using ?. By doing this, we'll remind ourselves when we're using our model that this column may not have a value.

Securing Our Password

Another thing models are responsible for is defining how data is serialized. We can utilize this to make sure our password never reaches client-side code by defining our password's serializing as null.

@column({ serializeAs: null })
public password: string

Next, we'll also want to ensure we're not storing plain text passwords in our database. To do this, we can make use of the beforeSave hook within our model to hash our password, if it's been changed, anytime a user is created or updated.

import { beforeSave, /* ... */ } from '@ioc:Adonis/Lucid/Orm'

@beforeSave()
public static async hashPassword(user: User) {
  if (user.$dirty.password) {
    user.password = await Hash.make(user.password)
  }
}

It's also worth noting, had we already set up our authentication, Adonis would have taken care of this for us. I don't want to cover authentication until we have an understanding of controllers, migrations, models, and data persisting because authentication encapsulates all four concepts.

So, that should do it for now for our User model. In the next lesson, we'll be expanding upon models further by learning how to define our relationships.

Save Hooks

So, similar to the beforeSave save hook we utilized to hash our user's password anytime it's being changed in the database, we also have a number of other save hooks available to us.

  • beforeSave called before a record is created or updated

  • afterSave called after a record is created or updated

  • beforeCreate called before a record is created

  • afterCreate called after a record is created

  • beforeUpdate called before a record is updated

  • afterUpdate called after a record is updated

  • beforeDelete called before a record is deleted

  • afterDelete called after a record is deleted

  • beforeFind called just before a single-item query is kicked-off

  • afterFind called just after a single-item query returns

  • beforeFetch called just before a multi-item query is kicked-off

  • afterFetch called just before a multi-item query returns

Computed Properties

Now, we don't need a computed property just yet for our User model, but while we're learning about models I want to touch base with what they are and how to define them.

Computed properties are properties on our model that don't exist in our database at all, they aren't a column and they aren't related to anything else. They're properties we define and provide a value for within our models. For example, if you wanted to have an avatar for your user that's pulling from Gravatar, a computed property would be great for this! It would look something like the below.

// app/Models/User.ts

import gravatar from 'gravatar'
import { computed, /* ... other imports */ } from '@ioc:Adonis/Lucid/Orm'

export default class User extends BaseModel {
  // ... other properties

  @column()
  public email: string

  @computed()
  public get avatar() {
    return gravatar.url(this.email)
  }
}

Inside our computed property method, we'll have access to whatever user record is currently being propagated. Anytime we query a user or list of users, Lucid will compute our avatar property and add it onto our user's data just the same as if it were actually stored in the database.

Computed Property Serialization

One thing to note here is that by providing the @computed decorator we're telling Lucid we want this computed property to be serialized to JSON. If this isn't something you need or want for a computed property, it's perfectly valid to leave this off.

public get avatar() {
  return gravatar.url(this.email)
}

Defining Our Task & Project Models

So, now that we know the basics of models, let's continue onward by quickly filling out our Task and Project models.

Ace CLI Tip

In the past, we've been individually creating our models, controllers, and migrations as we need them. However, we can create all three for a single table in one blow using some flags on our Ace CLI commands.

Although we already have a migration creating our tasks table, for demonstration purposes, let's create a model, controller, and migration for our tasks with a single command.

$ node ace make:model Task -mc
# CREATE: database/migrations/TIMESTAMP_tasks.ts
# CREATE: app/Controllers/Http/TasksController.ts
# CREATE: app/Models/Task.ts

As you can see, now we have a new migration, controller, and model for our tasks table. This is all thanks to two flags we added to the end of our make:model command.

  • -m designates that we'd like a migration with our model

  • -c designates that we'd like a controller with our model

If you run this command, please delete the newly generated tasks migration.

Additionally, if you actually inspect our newly created TasksController you'll see Adonis started us off with a resource controller from the get-go.

// app/Controllers/Http/TasksController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'

export default class TasksController {
  public async index ({}: HttpContextContract) {
  }

  public async create ({}: HttpContextContract) {
  }

  public async store ({}: HttpContextContract) {
  }

  public async show ({}: HttpContextContract) {
  }

  public async edit ({}: HttpContextContract) {
  }

  public async update ({}: HttpContextContract) {
  }

  public async destroy ({}: HttpContextContract) {
  }
}

What about our case, where we already have our migrations setup? All we'd need to do is leave off the -m from our command. So, for our projects, we could run the below to get just a model and controller.

$ node ace make:model Project -c
# CREATE: app/Controllers/Http/ProjectsController.ts
# CREATE: app/Models/Project.ts

Again, since Adonis has context around the usage of our controller and knows it's bound to a model it'll start us off with a resource controller for our newly created ProjectsController.

Task

For our Task model, there will be just a couple of things to note. We have two relationships columns on our table, created_by and assigned_to. Although we'll be covering relationships in the next lesson, we can go ahead and define the id representation of those relationships directly on our model by defining createdBy and assignedTo as numbers. We'll also want to make sure our assignedTo is nullable since it's nullable in the database.

Additionally, we have a timestamp column on our database called due_at. Unlike our created_at and updated_at columns, we won't want due_at to be auto-assigned or auto-updated. This should specifically be user-defined. Therefore, we can just use @column.dateTime() without an options object.

Your Task models should end up looking like the below.

// app/Models/Task.ts

import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class Task extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public createdBy: number

  @column()
  public assignedTo?: number

  @column()
  public name: string

  @column()
  public description?: string

  @column.dateTime()
  public dueAt?: DateTime

  @column()
  public statusId: number

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}

Project

As for our Project model, there really isn't anything new going on here. It should end up looking like the following.

// app/Models/Project.ts

import { BaseModel, column } from '@ioc:Adonis/Lucid/Orm'

export default class Project extends BaseModel {
  @column({ isPrimary: true })
  public id: number

  @column()
  public name: string

  @column()
  public description?: string

  @column()
  public statusId: number

  @column.dateTime({ autoCreate: true })
  public createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  public updatedAt: DateTime
}

Now for our Task and Project models, you might've noticed we're setting the statusId to a type of number. We can actually kick this up a notch, by defining our Status enum and using that as the type for our statusId. This will give us some nice TypeScript support for our statusId in the future.

Status Enum

So, as you may recall from our directory structure lesson, the contracts directory is a place where we can store TypeScript related items, like enums. So, for our Status enum, let's go ahead and create a new folder inside our contracts folder called enums. Then, within the enums folder create a new file called Status.ts. This is where our Status enum will reside.

Next, define your status enum as the following. We'll only have three statuses, but you can extend them later as you wish.

// contracts/enums/Status.ts

enum Status {
  IDLE = 1,
  IN_PROGRESS = 2,
  COMPLETE = 3
}

export default Status;

Like everything else in our project, we'll be able to conveniently import this by referencing the path to the file from our project root.

Now, let's apply our new Status enum as our Task and Project statusId type.

// app/Models/Task.ts

import Status from 'contracts/enums/Status'

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

  @column()
  public statusId: Status

  // ... other fields
}
// app/Models/Project.ts

import Status from 'contracts/enums/Status'

export default class Project extends BaseModel {
  // ... other fields

  @column()
  public statusId: Status

  // ... other fields
}

Great! That should get our models into a great state to move forward in the next lesson with relationships.

Next Up

So, we've learned how to define our models and some of the capabilities models bring to the table. In the next lesson, we'll learn how to define our relationships within our models. We'll also discuss when to use a one-to-one, one-to-many, or a many-to-many relationship and some of the options we have when defining these.

Join The Discussion! (10 Comments)

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

  1. Commented 3 years ago
    What does the ? in the model properties do, since you are only setting it on some of the properties? Ex: @column() public description?: string
    0

    Please sign in or sign up for free to reply

    1. Commented 3 years ago
      The ? in the model properties is a TypeScript feature. It states the property is allowed to not have a value (is nullable). Another way to think about it is by leaving off the ? on the property we're saying that field is required. By adding a ? we're making that field optional. So, for example: @column() public description?: string This means the description is optional and may be null instead of a string. @column() public description: string This means the description is required and the value will be a string.
      0

      Please sign in or sign up for free to reply

  2. Commented 2 years ago

    What about boolean types on Sqlite3 ?
    Adding the serialize decorator
    `
      @column({
        serialize: (value?: Number) => {
          return Boolean(value)
        },
      })
      public isClaimed: boolean
    `
    when I store the record on db it save 0/1 (because sqlite3 manage boolean as integers)
    but when I get the value 0/1 from db it fail on the returned json.
    I have a video to illustrate the problem but I can’t attached here.

    0

    Please sign in or sign up for free to reply

    1. Commented 2 years ago

      I’m not familiar with Sqlite at all, so there may be a better answer than what I’m about to provide. However, the serialize function is only going to be called when the query results are converted from Lucid data into JSON. This can occur by explicitly calling the serialize() method on a model record, stringifying, or returning the data as a JSON response.

      For this, I think it’d be better to use prepare and consume. Prepare will prepare the value for storage within the db. I’m not sure if this would be needed or if the db would auto transform true/false into 1/0. Consume will conversely transform the value when it’s queried from the db but before it’s set as the value on the model record.

      @column({
        consume: (value: number) => Boolean(value),
        prepare: (value: boolean) => value ? 1 : 0
      })
      public isClaimed: boolean

      You can find the corresponding documentation for this here:
      https://docs.adonisjs.com/reference/orm/decorators#column

      0

      Please sign in or sign up for free to reply

  3. Commented 2 years ago

    Hi Tom,

    how would you handle the following case:


    1. I have a resource that when created/updated receives a field in array/object structure as part of the request body:

    // request body with more fields..
    "triggerItems": [
      { foo: 'bar' }
      // potentially more objects...
    ]


    2. Now, I defined this field in my model and migration like this:

    this.schema.createTable(this.tableName, (table) => {
      // more definitions...
      table.json('triggerItems').nullable()
    })
    export default class Test extends BaseModel {
      // more definitions...
      @column()
      public triggerItems: string | null
    }


    3. At first I assumed that this would be enough and the field would automatically be serialized when attempting to save it to the database. However, I got errors at first. I could solve it by serializing the value manually like this:

    export default class Test extends BaseModel {
      @beforeSave()
      public static stringifyProperties(test: Test) {
        test.triggerItems = JSON.stringify(test.triggerItems)
      }
    }


    Now I’m wondering if this is the correct way to handle JSON fields or if I’m missing some better solution here? :)

    0

    Please sign in or sign up for free to reply

    1. Commented 2 years ago

      Hey! So sorry, for some reason I thought I had answered this.

      I have only worked with a JSON column in AdonisJS once so far, but I’ve had good luck defining its column in the model like so, note my:

      @column()
      public triggerItems: object | string | null

      This may not be the best way, I want to investigate this further and create a lesson on it as I’ve seen many questions about how to handle it and everyone seems to have a different answer.

      However, in my case, by providing object as a type on the column, I’ve found the item itself is automatically parsed when I query the model. For example, my JSON column is called bodyBlocks. Below is a Repl output for the column when queried.

      > const post = await models.Post.query().orderBy('createdAt', 'desc').first()
      
      > post.bodyBlocks
      {
        time: 1643114154268,
        blocks: [
          { id: 'nVF5BD-Q1o', data: [Object], type: 'paragraph' },
          { id: 'hWXKjlkua2', data: [Object], type: 'code' },
          { id: 'aehFit-4uN', data: [Object], type: 'paragraph' },
          { id: 'YRHeNn2WRo', data: [Object], type: 'paragraph' },
          { id: 'D745YWKU-Q', data: [Object], type: 'header' },
          { id: 'exMuwMTHHh', data: [Object], type: 'paragraph' },
          { id: 'iaqE7ROOoY', data: [Object], type: 'paragraph' },
          { id: 'PgCq227-9d', data: [Object], type: 'code' },
          { id: 'WGPv6JDb5i', data: [Object], type: 'paragraph' },
          { id: 'fO1O4DaMOY', data: [Object], type: 'code' }
        ],
        version: '2.22.2'
      }
      > post.bodyBlocks.time
      1643114154268

      Same, when it comes to setting the value. Since it’s an object | string I can set the value as either stringified or a non-stringified object.

      Hope this helps, and again sorry for the delayed response!

      0

      Please sign in or sign up for free to reply

      1. Commented 2 years ago

        Hey Tom, thank you for your response!

        I tried to change my models as you suggested but I still can’t get it to work without the beforeSave() workaround. :(

        I created a minimal example of the issue: https://github.com/MrAvantiC/Adonis-JSON-serialization. Issue can be seen with curl --location --request POST 'localhost:3333/test'

        1. As long as I keep the beforeSave() hook inside the model, the column gets serialized just fine…

        2. …however, when the record is returned in the response, for example right after creating it, the column also doesn’t get transformed back to JSON but is returned as a string.

        I also tried to change the column type from Array<object> to just <object> but that doesn’t change anything.

        I do know that other frameworks/ORMs support a way of automatically encoding/decoding data (e.g. https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/types.html#json) and I’m not sure what I’m missing here with Adonis… ^^

        0

        Please sign in or sign up for free to reply

        1. Commented 2 years ago

          Okay, so it looks like what I was seeing is something KnexJS is doing and not AdonisJS and it seems to only work when the variable root is an object, doesn’t seem to care what’s inside beyond that.

          For example, if you change your data to:

          { 
            triggerItems: {
              items: [{ foo: 'bar' }, { foo: 'baz' }] 
            }
          }

          It seems to insert a-okay without needing to stringify.
          I played around with it for a bit and search online for a bit and everyone with arrays at the root level seems to be stringifying prior to inserting.

          So, if you do need triggerItems to be an array, you can use the @beforeSave hook. Alternatively, you can add to the column decorator a prepare method. This is a property-specific version of the @beforeSave hook.

          @column({
            prepare: (value: Array<object>) => JSON.stringify(value)
          })
          public triggerItems: Array<object>

          So, after testing, one benefit of using prepare over the @beforeSave hook to stringify your array is that you can remove the string type off your model property since you no longer need to set a stringified item onto the property directly, instead, Adonis will take care of it using your prepare method as a callback.

          Lastly, I just tested this as well, you can give yourself intellisense on triggerItems by directly setting the type.

          // contracts/test.ts
          
          export interface TestItem {
            foo: string
          }
          // app/Models/Test.ts
          
          export default class Test extends BaseModel {
            @column({
              prepare: (value: Array<TestItem>) => JSON.stringify(value)
            })
            public triggerItems: Array<TestItem>
          }
          // app/Controllers/Http/PostsController.ts
          
          export default class TestsController {
            public async index({}: HttpContextContract) {
              const test = await Test.firstOrFail()
          
              return test.triggerItems[0].foo // bar
            }
          }
          0

          Please sign in or sign up for free to reply

          1. Commented 2 years ago

            Hey Tom,

            thanks for your effort to evaluate this!

            I like your approach better than the beforeSave() hook, so I refactored it like this:

              @column({
                prepare: (value: Array<object>) => JSON.stringify(value),
                consume: (value: string) => JSON.parse(value),
              })
              public triggerItems: Array<object>

            I also tried to pass just the function reference…

              @column({
                prepare: JSON.stringify,
                consume: JSON.parse,
              })

            …but then Typescript isn’t happy, even though it still works.. ;)

            It would be great if there was some built-in function to achieve this. When researching, I found a package that seems to achieve something similar: https://github.com/watzon/adonis-jsonable
            But it hasn’t received updates for quite a while, so I was initially assuming that Adonis now does this out-of-the-box. Guess not. ;)

            Thanks for your help - again! :)

            0

            Please sign in or sign up for free to reply

            1. Commented 2 years ago

              Anytime! Happy you were able to settle on a solution :). I’m sure a package will come along in the future that’ll help with this!

              0

              Please sign in or sign up for free to reply