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.tsCopied!
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.
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 }Copied!
- app
- Models
- User.ts
So, there are a few things to note here.
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
columndecorator.We define our primary keys using the
columndecorator. It's also worth noting we could do this outside the decorator as aprimaryKeyproperty on ourUsermodel's class.As you can see with our
createdAtandupdatedAtproperties, thecolumndecorator can be used to describedateTime, which also accepts whether the value should be auto-populated on insert and auto-updated on update.Adonis automatically serializes our snake-cased
created_atandupdated_atcolumns 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:
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 }Copied!
- app
- Models
- User.ts
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: stringCopied!
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) } }Copied!
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.
beforeSavecalled before a record is created or updatedafterSavecalled after a record is created or updatedbeforeCreatecalled before a record is createdafterCreatecalled after a record is createdbeforeUpdatecalled before a record is updatedafterUpdatecalled after a record is updatedbeforeDeletecalled before a record is deletedafterDeletecalled after a record is deletedbeforeFindcalled just before a single-item query is kicked-offafterFindcalled just after a single-item query returnsbeforeFetchcalled just before a multi-item query is kicked-offafterFetchcalled 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.
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) } }Copied!
- app
- Models
- User.ts
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) }Copied!
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.tsCopied!
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.
-mdesignates that we'd like a migration with our model-cdesignates 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.
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) { } }Copied!
- app
- Controllers
- Http
- TasksController.ts
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.tsCopied!
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.
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 }Copied!
- app
- Models
- Task.ts
Project
As for our Project model, there really isn't anything new going on here. It should end up looking like the following.
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 }Copied!
- app
- Models
- Project.ts
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.
enum Status { IDLE = 1, IN_PROGRESS = 2, COMPLETE = 3 } export default Status;Copied!
- contracts
- enums
- Status.ts
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.
import Status from 'contracts/enums/Status' export default class Task extends BaseModel { // ... other fields @column() public statusId: Status // ... other fields }Copied!
- app
- Models
- Task.ts
import Status from 'contracts/enums/Status' export default class Project extends BaseModel { // ... other fields @column() public statusId: Status // ... other fields }Copied!
- app
- Models
- Project.ts
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.