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.
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.We define our primary keys using the
column
decorator. It's also worth noting we could do this outside the decorator as aprimaryKey
property on ourUser
model's class.As you can see with our
createdAt
andupdatedAt
properties, thecolumn
decorator 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_at
andupdated_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 updatedafterSave
called after a record is created or updatedbeforeCreate
called before a record is createdafterCreate
called after a record is createdbeforeUpdate
called before a record is updatedafterUpdate
called after a record is updatedbeforeDelete
called before a record is deletedafterDelete
called after a record is deletedbeforeFind
called just before a single-item query is kicked-offafterFind
called just after a single-item query returnsbeforeFetch
called just before a multi-item query is kicked-offafterFetch
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.