Adding a points system
From the very beginning, I mentioned this project is all about competition, which means we need a way to say a user has a certain amount of points. In this chapter, we are going to create the simplest and dumbest point system so we can use this as an excuse to learn more about what Phoenix auth generator created for us.
The modeling is pretty simple: we are going to add a points
column to the users
table and in the meantime, we are going to learn some Ecto.
What are migrations?
If you know what migrations are, skip this section, it's meant for beginners.
Development teams need a way of synchronizing changes to their databases. Migrations is a simple technique where all changes to the database are expressed as a migration file that contains the change. If we want to add a column, we need to create a migration file that says 'Please add a column points to my table users with type integer'. This file will be committed to the project and all developers will be able to run it and make their databases be on the latest state. We call applying one or more migrations 'migrating the database'. Remember that after running mix phx.gen.auth
we needed to do mix ecto.migrate
? That's it.
Migrations also need to be reversible in case something goes wrong. Using the example above the reverse command would be 'Please remove the column points from the table users'. We call reversing one or more migrations doing a 'rollback of the database'. Say you messed up your mix phx.gen.auth
and created a table called userss
, you could easily undo the error with mix ecto.rollback
.
Usually, web frameworks come with migrations support such as Rails' Active Record, Phoenix uses Ecto and AdonisJS Lucid. Some frameworks don't come with anything related to databases such as ExpressJS so you'd need to install something like Sequelize which has migrations support too.
Here's what a migration file looks like in AdonisJS:
// database/migrations/1587988332388_users.js
import BaseSchema from '@ioc:Adonis/Lucid/Schema'
export default class extends BaseSchema {
protected tableName = 'users'
public async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
public async down() {
this.schema.dropTable(this.tableName)
}
}
As you can see there's an up
and down
method to teach how to migrate and how to rollback. Also notice the filename uses a timestamp: the reason for that is so migrations must be run in the order they were created to ensure consistency.
Now that you know how roughly a migration looks like and what they're used for, let's get to what matters, Ecto.
Migrations with Ecto
Ecto migrations are slightly different. They (most of times) don't need to define up
and down
methods because the syntax is very aware of how to migrate and rollback it. Let's take the users migrations for example:
# 20230617121436_create_users_auth_tables.exs
defmodule Champions.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end
There's no up
and down
, just a change
method. This means we are confident Ecto can do and undo things with whatever we do there. To make it easier to understand let's break down its blocks:
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
The execute/2
function works as up
and down
. The first SQL statement is the up
case and the second one is the down
. This specific statement says 'When you migrate this, please create the extension citext if it does not exist' and the empty string as the second argument means 'don't do anything during rollbacks'.
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string, null: false
add :confirmed_at, :naive_datetime
timestamps()
end
This block tells ecto to create this table during migration and delete it during rollbacks. It's all under the hood because of the create/2 function combined with table/2 function. Inside the do
block we can see we ask for 5 fields to be added:
- An
email
of citext (case insensitive text) type non-nullable field. - A
hashed_password
string type non-nullable field. - A
confirmed_at
naive datetime (not aware of timezones) nullable field. -
timestamps/1
createsinserted_at
andupdated_at
, both naive datetime non-nullable fields with default values to what their names point at.
create unique_index(:users, [:email])
# other things here
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
As the name implies, the first unique_index/3 will create on migration and delete on rollback a unique index for the users
table under email
only. index/3 will generate a regular index and the second unique_index/3 is a composite index under context
and token
.
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
timestamps(updated_at: false)
end
You should already be able to understand most of what's happening here but the new things are references/2 used to create a foreign key from users_tokens
to users
which will make rows from the tokens table be deleted if the foreign key in users is deleted and the fact that this table opts-out of updated_at
since tokens are never updated.
For more details on Ecto migrations, I recommend reading their Ecto SQL Ecto.Migration docs and guides but in case you want to learn the possible types for your columns head out to Ecto docs under Ecto.Schema. Yes, those are two different docs because Ecto does not necessarily means you need to use one of the default Ecto SQL databases, you could use Mongo adapter or even something like ClickHouse which is also SQL.
Creating our first migration by hand
$ mix help ecto.gen.migration
mix ecto.gen.migration
Generates a migration.
The repository must be set under :ecto_repos in the current app configuration
or given via the -r option.
## Examples
$ mix ecto.gen.migration add_posts_table
Once more generators come to the rescue. We could write it by hand but you'd be having to figure out the current timestamp, write some boilerplate code and all that is boring, let Ecto do that for you. The migration name doesn't impact it's effect but it's nice to be clear what you're going to do:
$ mix ecto.gen.migration add_users_points
* creating priv/repo/migrations/20230617145124_add_users_points.exs
The default code should be something like:
defmodule Champions.Repo.Migrations.AddUsersPoints do
use Ecto.Migration
def change do
end
end
Our only migration so far had used create
to create/delete a database table for us, what if we just want to add a column? alter/2 comes to the rescue.
defmodule Champions.Repo.Migrations.AddUsersPoints do
use Ecto.Migration
def change do
alter table(:users) do
add :points, :integer, default: 0, null: false
end
end
end
No specific up
and down
needed, Ecto knows how to do these for you. We will be creating a field called points that default to 0 and can't be null. Let's run this migration. Here's a fun fact: if you pass --log-migrations-sql
you can see the SQL queries being run:
$ mix ecto.migrate --log-migrations-sql
11:58:05.588 [info] == Running 20230617145124 Champions.Repo.Migrations.AddUsersPoints.change/0 forward
11:58:05.590 [info] alter table users
11:58:05.625 [debug] QUERY OK db=9.6ms
ALTER TABLE "users" ADD COLUMN "points" integer DEFAULT 0 NOT NULL []
11:58:05.626 [info] == Migrated 20230617145124 in 0.0s
Alright, that should do… For our Postgres database at least. We still need to teach Ecto how to map this column to Elixir. Head out to lib/champions/accounts/user.ex
and let's add it:
defmodule Champions.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :confirmed_at, :naive_datetime
field :points, :integer, default: 0
timestamps()
end
# more stuff
end
Since we 10x developers and we just added something meaningful to our code, we want to make sure we add tests for this! I didn't mention it to you but the Phoenix auth generator created a lot of test files. You can verify that with mix test
:
$ mix test
................................................................................................................................
Finished in 1.9 seconds (0.6s async, 1.2s sync)
128 tests, 0 failures
Randomized with seed 490838
Let's keep it simple and edit an existing test. Head out to test/champions/accounts_test.exs
and look for 'returns the user if the email exists'. A simple assertion should do:
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
+ assert user.points == 0
end
If running mix test
still works, we are done with Ecto. At least for now.
Your first UI change
Our users cannot see how many points they have so far. We can tweak our layout to show their points beside their email. Headout to lib/champions_web/components/layouts/root.html.heex
and look for <%= @current_user do %>
.
<%= if @current_user do %>
<li class="text-[0.8125rem] leading-6 text-zinc-900">
<%= @current_user.email %>
+ (<%= @current_user.points %> points)
</li>
...more stuff
HEEx: your new best friend
HEEx stands for HTML + Embedded Elixir. It's simply the way Phoenix uses to express HTML with Elixir code inside. There are a ton of amazing things behind the scenes to make HEEx a thing but we will defer talking about them to later. What you need to take away from it right now are a few key concepts:
- Whenever you see a tag
<%= elixir code here %>
that elixir code will be rendered into the HTML. - To run Elixir code and not render anything in the HTML just omit the
=
sign such as<% x = 10 %>
. - Any variable inside tags that starts with
@
are called assigns. They're special and we will talk about them a lot over the next chapters. For now, we used the@current_user
assign to render both the user email and points on the navbar.
For this change, we went to our root.html.heex
file which contains the outermost HTML of our web page, including the HTML, header, and body tags. Later on, we will be learning more about layouts when it comes to creating multiple of them.
Summary
- Most Ecto migration codes know how to be migrated and rollbacked without needing two different code paths.
- Ecto
timestamps
function createsinserted_at
andupdated_at
fields unless the code opts out of one. -
mix ecto.gen.migration migration_name
does the boilerplate bit of creating migration files with timestamps on names. - One must also update Ecto models after doing migrations so Ecto knows which fields are available.
- HEEx is how Phoenix templates HTML for interfaces, it uses variables know as
assigns
with names that start with@
like@current_user
. - HEEx has special tags for rendering HTML content from Elixir code
<%= code %>
and just running elixir code without rendering things<% x = 10 %>
. -
root.html.heex
contains a simple navbar that shows who's the currently logged user.
Top comments (0)