The Lazy Programmer's Intro to LiveView: Chapter 3

lubien

Lubien

Posted on June 18, 2023

The Lazy Programmer's Intro to LiveView: Chapter 3

Go to Chapter 1

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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", ""
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 creates inserted_at and updated_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])
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The default code should be something like:

defmodule Champions.Repo.Migrations.AddUsersPoints do
  use Ecto.Migration

  def change do

  end
end
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 creates inserted_at and updated_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.

Read chapter 4: Adding points to users

💖 💪 🙅 🚩
lubien
Lubien

Posted on June 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related