Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Ecto

reading/deprecated_ecto.livemd

Ecto

Mix.install([
  {:jason, "~> 1.4"},
  {:kino, "~> 0.9", override: true},
  {:youtube, github: "brooklinjazz/youtube"},
  {:hidden_cell, github: "brooklinjazz/hidden_cell"},
  {:kino_db, "~> 0.2.1"}
])

Navigation

Return Home Report An Issue

Setup

Ensure you type the ea keyboard shortcut to evaluate all Elixir cells before starting. Alternatively, you can evaluate the Elixir cells as you read.

Ecto

Ecto provides a standard API layer for communicating with the database of an Elixir application.

By default, Ecto uses a PostgreSQL Database. Ensure you already have PostgreSQL installed on your computer from the Relational Database Management Systems lesson.

Ecto splits into four main modules.

  • Ecto.Repo handles all communication between the application and the database. Ecto.Repo reads and writes from the underlying PostgreSQL database.
  • Ecto.Query built queries to retrieve and manipulate data with the Ecto.Repo repository.
  • Ecto.Schema maps the application struct data representation to the underlying PostgreSQL database representation.
  • Ecto.Changeset creates changesets for validating and applying constraints to structs.

To demonstrate how to use Ecto, we’re going to create a journal application where users can create private journal entries.

Configure Ecto

We can follow the Ecto Getting Started Guide to add Ecto to a project.

First, create a new journal mix application in the projects folder.

mix new journal --sup

Add postgrex and ecto_sql to your list of dependencies in mix.exs. postgrex is a PostgreSQL driver for Elixir. Ecto uses this driver to communicate with PostgreSQL. ecto_sql is a SQL adapter for Ecto and database migrations.

defp deps do
  [
    {:ecto_sql, "~> 3.0"},
    {:postgrex, ">= 0.0.0"}
  ]
end

Install dependencies.

mix deps.get

Ecto provides a command to automate configuration.

mix ecto.gen.repo -r Journal.Repo

Configure the Ecto Repository in config.exs. By default, PostgreSQL uses a postgres user with a postgres password.

config :journal, Journal.Repo,
  database: "journal_repo",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

You should also have a new repo.ex file that defines a Journal.Repo module. The Journal.Repo (repository) module is a wrapper around your database. We’ll use the Journal.Repo module anytime you want to communicate with the database.

defmodule Journal.Repo do
  use Ecto.Repo,
    otp_app: :journal,
    adapter: Ecto.Adapters.Postgres
end

The Journal.Repo process runs as a supervisor within the supervision tree of your application, so we need to add it to the supervision tree in application.ex.

def start(_type, _args) do
  children = [
    Journal.Repo,
  ]

  ...

In addition, add the following to config.exs. We need this configuration to run the mix ecto commands.

config :journal, ecto_repos: [Journal.Repo]

Create The Database

To create a local database on your computer, run the following command.

mix ecto.create

You may see an error that includes the following.

(invalid_password) password authentication failed for user "user"

If so, this means the authentication for your local PostgreSQL application is failing. Change config.exs with the correct username and password for PostgreSQL.

config :journal, Journal.Repo,
  database: "journal_repo",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

Now create the local database.

mix ecto.create
The database for Journal.Repo has been created

Similar to the createdb command used in the RDBMS lesson, this creates a journal_repo database in PostgreSQL. We can verify this from the psql prompt.

sudo -i -u postgres
postgres$ psql
postgres-# \l
...
journal_repo            | postgres | UTF8     | C.UTF-8 | C.UTF-8 |

We can drop this database from the command line (not the psql prompt). This clears any data in the database.

mix ecto.drop

Use \l to list the locale databases again, and the journal_repo database should be gone.

We do want the database to exist, though, so make sure to re-rerun the mix ecto.create command.

Tables And Migrations

We use migrations to create tables in the database.

We can create a new migration file using mix ecto.gen.migration.

mix ecto.gen.migration create_users

We can name the migration anything we like. However, it’s best to be descriptive.

This command creates a priv/repo/migrations/20220611011557_create_users.exs file with the current timestamp. This migration file defines the table we want to create. By default, this table is empty.

defmodule Journal.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do

  end
end

We’ll modify the module to create a users table. The create and table macros create a users table. The add macro defines a column on that table with the desired data type. The users table stores user’s name and email as a :string.

Elixir primitive types map to the underlying data structure in PostgreSQL. For example, :string maps to :varchar.

defmodule Journal.Repo.Migrations.CreateUsers do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :name, :string
      add :email, :string
    end
  end
end

Run the following command to run all migrations.

mix ecto.migrate

18:22:17.619 [info]  == Running 20220611011557 Journal.Repo.Migrations.CreateUsers.change/0 forward

18:22:17.621 [info]  create table users

18:22:17.632 [info]  == Migrated 20220611011557 in 0.0s

We can verify the users table exists in the journal_repo database from the PostgreSQL prompt. There is also a schema_migrations table used by Ecto under the hood.

postgres-# \c journal_repo
journal_repo=# \dt
               List of relations
 Schema |       Name        | Type  |  Owner
--------+-------------------+-------+----------
 public | schema_migrations | table | postgres
 public | users             | table | postgres

Your Turn

With the users table defined, we can use SQL to read and write data. We don’t recommend doing this manually for your application, but we want to demonstrate that Ecto is simply a wrapper around PostgreSQL.

opts = [
  hostname: "localhost",
  port: 5432,
  username: "postgres",
  password: "postgres",
  database: "journal_repo",
  socket_options: [:inet6]
]

{:ok, conn} = Kino.start_child({Postgrex, opts})

In the Elixir smart cell below, enter the following SQL query to insert a user into the users table.

INSERT INTO users (ID,NAME,EMAIL) VALUES (0, 'Peter', 'peter@spider.web');
result2 = Postgrex.query!(conn, "", [])

Ensure the user was created by evaluating the cell below to display all users in the users table.

result3 = Postgrex.query!(conn, "select * from users ", [])

Schemas

Schemas are an Elixir representation of the data stored in our database.

Create a users schema in lib/journal/user.ex with the following content. It’s conventional to use a singular name for the schema.

defmodule Journal.User do
  use Ecto.Schema

  schema "users" do
    field :name, :string
    field :email, :string
  end
end

The schema defines how we want to map information from our application to our database.

Under the hood, this schema defines a Journal.User struct with the :name and :email keys. We will use this struct when we insert data into the database and when we read data from the database.

Inserting Data

Open you project in the IEx shell.

iex -S mix

The Journal.User schema automatically defines a corresponding Journal.User struct.

Create an instance of the struct by entering the following into the shell.

iex> user = %Journal.User{name: "Peter", email: "peter@spider.web"}

We can insert a user into the users table through the Journal.Repo module.

iex> Journal.Repo.insert(user)

Assuming this succeeds with an {:ok, user} tuple, there should be a user in our PostgreSQL database, which we’ll check using a smart cell.

Re-evaluate the smart cell below to ensure a new user is in the users table.

result = Postgrex.query!(conn, "select * from users", [])

Validating Data

We can use Ecto Changesets to validate data before inserting it into the database. It’s conventional to define the changeset in the schema file.

Add the following changeset/2 function to lib/journal/users.ex. Ensure you also import Ecto.Changeset.

defmodule Journal.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field :name, :string
    field :email, :string
  end

  def changeset(user, params \\ %{}) do
    user
    |> cast(params, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
  end
end

For additional validation functions, consult the Ecto Changeset Functions documentation.

Now we can use the changeset/2 function to pre-validate data before inserting it into the database. Ensure you recompile() or restart the IEx shell to load the recent file change, then enter the following.

iex> %Journal.User{}
|> Journal.User.changeset(%{name: "miles", email: "miles@spider.web"})
|> Journal.Repo.insert()

It should return an {:ok, user} tuple.

{:ok,
 %Journal.User{
   __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
   email: "miles@spider.web",
   id: 8,
   name: "miles"
 }}

Now, invalid data will not insert into the database.

iex> %Journal.User{}
|> Journal.User.changeset(%{name: "miles", email: "invalid email"})
|> Journal.Repo.insert()

It should return an {:error, user} tuple.

{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{email: "invalid email", name: "miles"},
   errors: [email: {"has invalid format", [validation: :format]}],
   data: #Journal.User<>,
   valid?: false
 >}

Your Turn

Create an additional user from the IEx shell using the Journal.Repo.insert/2 function.

Re-evaluate the SQL query above and you should see another user in the users table.

Reading Data

We can also use the Journal.Repo module to query the database. Enter the following into the IEx shell.

iex> Journal.Repo.all(Journal.User)

The Journal.Repo.all function should return a list of Journal.User structs.

[
  %Journal.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    email: "peter@spider.web",
    id: 1,
    name: "Peter"
  },
  %Journal.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    email: "miles@spider.web",
    id: 2,
    name: "Miles"
  }
]

Note that using Journal.User is an alternative to from u in "users". However, with from u in "users" we need to use :select to specify how to return the data.

Journal.Repo.all(from u in "users", select: %Journal.User{id: u.id, name: u.name, email: u.email})

For this reason, we’ll generally prefer using the schema module directly.

Ecto.Query

With the Ecto.Query module, we can create more specific queries rather than retrieving all items in a table.

We can build queries using a keyword-list syntax.

For example, we can query the users table using the :where keyword to find users with a matching :email field.

iex> import Ecto.Query
iex> query = from Journal.User, where: u.email == "miles@spider.web", select: u.name
iex> Journal.Repo.all(query)

Instead of returning all users, Journal.Repo.one/2 returns the first user that matches the query.

iex> Journal.Repo.one(query)

Alternatively, we can use pipe syntax to accomplish the same behavior.

iex> query = from(Journal.User) |> where([u], u.email == "miles@spider.web") |> select([u], u.name)
iex>  Journal.Repo.all(query)

Your Turn

Create queries to find the following.

  • return all users whose name is "Peter".
  • return the :email field for user’s whose name is "Miles"
  • return the :name field for user’s whose email is "peter@spider.web"
  • (bonus) users whose name contains the letter P see ilike in the documentation.

Entries

We’ll power through creating entries since we’ve already created users.

Create the migration.

mix ecto.gen.migration create_entries

Modify the migration file under priv/repo/migrations/create_entries.ex. We’ll use the timestamps macro to store when we create and update entries.

We’ll also use the :text type for the content field to allow for large content. By default, the :string type allows for a maximum of 255 characters, which isn’t enough for the content of a journal entry.

references/2 defines the foreign key to the users table.

defmodule Journal.Repo.Migrations.CreateEntries do
  use Ecto.Migration

  def change do
    create table(:entries) do
      add :title, :string
      add :content, :text
      add :user_id, references(:users)

      timestamps()
    end
  end
end

We have to migrate our database when we create a new migration.

mix ecto.migrate

Create the schema file under lib/journal/entry.ex.

We use the belongs_to/2 macro to define the relationship between the Journal.Entry and the Journal.User.

timestamps creates the :updated_at and :created_at field for us.

We’ll also create a simple changeset function.

defmodule Journal.Entry do
  use Ecto.Schema
  import Ecto.Changeset

  schema "entries" do
    field :title, :string
    field :content, :string

    belongs_to :user, Journal.User

    timestamps()
  end

  def changeset(entry, params \\ %{}) do
    entry
    |> cast(params, [:title, :content, :user_id])
    |> validate_required([:title, :content])
  end
end

Now we can insert entries with Journal.Repo.insert/2.

iex> import Ecto.Query
iex> %Journal.Entry{} |> Journal.Entry.changeset(%{title: "Rhino Encounter", content: "Today I fought the Rhino", user_id: 1}) |> Journal.Repo.insert()

Notice that the entry automatically includes timestamp information.

{:ok,
 %Journal.Entry{
   __meta__: #Ecto.Schema.Metadata<:loaded, "entries">,
   content: "Today I fought the Rhino",
   id: 2,
   inserted_at: ~N[2022-06-11 06:00:46],
   title: "Rhino Encounter",
   updated_at: ~N[2022-06-11 06:00:46],
   user: #Ecto.Association.NotLoaded,
   user_id: 1
 }}

We can query entries with Journal.Repo.all/2.

iex> Journal.Repo.all(Journal.Entry)
[
  %Journal.Entry{
    __meta__: #Ecto.Schema.Metadata<:loaded, "entries">,
    content: "Today I fought the Rhino",
    id: 2,
    inserted_at: ~N[2022-06-11 06:00:46],
    title: "Rhino Encounter",
    updated_at: ~N[2022-06-11 06:00:46],
    user: #Ecto.Association.NotLoaded,
    user_id: 1
  }
]

We can use :join and :on keys to retrieve data from a related table in our query. For example, we might make a query to return the titles of journal entries with their author’s name.

Journal.Repo.all(from entry in Journal.Entry, join: user in Journal.User, on: entry.user_id == user.id, select: {user.name, entry.title})

Alternatively, we can use Repo.preload/3 to load associated data.

Journal.Repo.all(from entry in Journal.Entry, preload: :user)

preload/3 returns the same list of structs, with associated data included in the struct.

[
  %Journal.Entry{
    __meta__: #Ecto.Schema.Metadata<:loaded, "entries">,
    content: "Today I fought the Rhino",
    id: 2,
    inserted_at: ~N[2022-06-11 06:00:46],
    title: "Rhino Encounter",
    updated_at: ~N[2022-06-11 06:00:46],
    user: %Journal.User{
      __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
      email: "peter@spider.web",
      entries: #Ecto.Association.NotLoaded,
      id: 1,
      name: "Peter"
    },
    user_id: 1
  }
]

If we add a has_many association to the Journal.User schema, we can also query the entries related to a user.

defmodule Journal.User do
  use Ecto.Schema
  import Ecto.Changeset

  schema "users" do
    field(:name, :string)
    field(:email, :string)

    has_many :entries, Journal.Entry
  end

  def changeset(user, params \\ %{}) do
    user
    |> cast(params, [:name, :email])
    |> validate_required([:name, :email])
    |> validate_format(:email, ~r/@/)
    |> unique_constraint(:email)
  end
end

We’ll use :preload to retrieve all of the entries for each user.

iex> Journal.Repo.all(from u in Journal.User, preload: [:entries])

Now each Journal.User struct should include a list in the :entries field.

[
  %Journal.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    email: "peter@spider.web",
    entries: [
      %Journal.Entry{
        __meta__: #Ecto.Schema.Metadata<:loaded, "entries">,
        content: "Today I fought the Rhino",
        id: 2,
        inserted_at: ~N[2022-06-11 06:00:46],
        title: "Rhino Encounter",
        updated_at: ~N[2022-06-11 06:00:46],
        user: #Ecto.Association.NotLoaded,
        user_id: 1
      }
    ],
    id: 1,
    name: "Peter"
  },
  %Journal.User{
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    email: "miles@spider.web",
    entries: [],
    id: 2,
    name: "miles"
  },
  ...
]

Further Reading

Ecto is a massive topic that we will cover throughout the course. For now, we’re armed with the basics of Ecto.

We highly recommend the Ecto documentation. More specifically, consider reading up on the four main Ecto modules.

We’ve also found a useful Ecto Cheatsheet.

There are also several lessons by Elixir Schools you may find useful.

Mark As Completed

file_name = Path.basename(Regex.replace(~r/#.+/, __ENV__.file, ""), ".livemd")

progress_path = __DIR__ <> "/../progress.json"
existing_progress = File.read!(progress_path) |> Jason.decode!()

default = Map.get(existing_progress, file_name, false)

form =
  Kino.Control.form(
    [
      completed: input = Kino.Input.checkbox("Mark As Completed", default: default)
    ],
    report_changes: true
  )

Task.async(fn ->
  for %{data: %{completed: completed}} <- Kino.Control.stream(form) do
    File.write!(progress_path, Jason.encode!(Map.put(existing_progress, file_name, completed)))
  end
end)

form

Commit Your Progress

Run the following in your command line from the curriculum folder to track and save your progress in a Git commit. Ensure that you do not already have undesired or unrelated changes by running git status or by checking the source control tab in Visual Studio Code.

$ git checkout solutions
$ git checkout -b deprecated-ecto-reading
$ git add .
$ git commit -m "finish deprecated ecto reading"
$ git push origin deprecated-ecto-reading

Create a pull request from your deprecated-ecto-reading branch to your solutions branch. Please do not create a pull request to the DockYard Academy repository as this will spam our PR tracker.

DockYard Academy Students Only:

Notify your instructor by including @BrooklinJazz in your PR description to get feedback. You (or your instructor) may merge your PR into your solutions branch after review.

If you are interested in joining the next academy cohort, sign up here to receive more news when it is available.