Fork me on GitHub

Ecto

Ecto is an official Elixir project providing a database wrapper and integrated query language. With Ecto we’re able to create migrations, define models, insert and update records, and query them.

Mục lục

Setup

To get started we need to include Ecto and a database adapter in our project’s mix.exs. You can find a list of supported database adapters in the Usage section of the Ecto README. For our example we’ll use PostgreSQL:

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

Now we can add Ecto and our adapter to the application list:

def application do
  [applications: [:ecto, :postgrex]]
end

Repository

Finally we need to create our project’s repository, the database wrapper. This can be done via the mix ecto.gen.repo task, we’ll cover Ecto mix tasks next. The Repo can be found in lib/<project name>/repo.ex:

defmodule ExampleApp.Repo do
  use Ecto.Repo,
    otp_app: :example_app
end

Supervisor

Once we’ve created our Repo we need to setup our supervisor tree, which is usually found in lib/<project name>.ex.

It is important to note that we setup the Repo as a supervisor with supervisor/3 and not worker/3. If you generated your app with the --sup flag much of this exists already:

defmodule ExampleApp.App do
  use Application

  def start(_type, _args) do
    import Supervisor.Spec

    children = [
      supervisor(ExampleApp.Repo, [])
    ]

    opts = [strategy: :one_for_one, name: ExampleApp.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

For more info on supervisors check out the OTP Supervisors lesson.

Configuration

To configure ecto we need to add a section to our config/config.exs. Here we’ll specify the repository, adapter, database, account information:

config :example_app, ExampleApp.Repo,
  adapter: Ecto.Adapters.Postgres,
  database: "example_app",
  username: "postgres",
  password: "postgres",
  hostname: "localhost"

Mix Tasks

Ecto includes a number of helpful mix tasks for working with our database:

mix ecto.create         # Create the storage for the repo
mix ecto.drop           # Drop the storage for the repo
mix ecto.gen.migration  # Generate a new migration for the repo
mix ecto.gen.repo       # Generate a new repository
mix ecto.migrate        # Run migrations up on a repo
mix ecto.rollback       # Rollback migrations from a repo

Migrations

The best way to create migrations is the mix ecto.gen.migration <name> task. If you’re acquainted with ActiveRecord these will look familiar.

Let’s start by taking a look at a migration for a users table:

defmodule ExampleApp.Repo.Migrations.CreateUser do
  use Ecto.Migration

  def change do
    create table(:users) do
      add :username, :string, unique: true
      add :encrypted_password, :string, null: false
      add :email, :string
      add :confirmed, :boolean, default: false

      timestamps
    end

    create unique_index(:users, [:username], name: :unique_usernames)
  end
end

By default Ecto creates an id auto incrementing primary key. Here we’re using the default change/0 callback but Ecto also supports up/0 and down/0 in the event you need more granular control.

As you might have guessed adding timestamps to your migration will create and manage inserted_at and updated_at for you.

To apply our new migration run mix ecto.migrate.

For more on migrations take a look at the Ecto.Migration section of the docs.

Models

Now that we have our migration we can move on to the model. Models define our schema, helper methods, and our changesets, we’ll cover changesets more in the next sections.

For now let’s look at what the model for our migration might look like:

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

  schema "users" do
    field :username, :string
    field :encrypted_password, :string
    field :email, :string
    field :confirmed, :boolean, default: false
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true

    timestamps
  end

  @required_fields ~w(username encrypted_password email)
  @optional_fields ~w()

  def changeset(user, params \\ :empty) do
    user
    |> cast(params, @required_fields, @optional_fields)
    |> unique_constraint(:username)
  end
end

The schema we define in our model closely represents what we specified in our migration. In addition to our database fields we’re also including two virtual fields. Virtual fields are not saved to the database but can be useful for things like validation. We’ll see the virtual fields in action in the Changeset section.

Querying

Before we can query our repository we need to import the Query API, for now we only need to import from/2:

import Ecto.Query, only: [from: 2]

The official documentation can be found at Ecto.Query.

Basics

Ecto provides an excellent Query DSL that allows us to express query clearly. To find the usernames of all confirmed accounts we could use something like this:

alias ExampleApp.{Repo,User}

query = from u in User,
    where: u.confirmed == true,
    select: u.username

Repo.all(query)

In addition to all/2 Repo provides a number of callbacks including one/2, get/3, insert/2, and delete/2. A complete list of callbacks can be found at Ecto.Repo#callbacks.

Count

query = from u in User,
    where: u.confirmed == true,
    select: count(u.id)

Group By

To group usernames by their confirmation status we can include the group_by option:

query = from u in User,
    group_by: u.confirmed,
    select: [u.confirmed, count(u.id)]

Repo.all(query)

Order By

Ordering users by their creation date:

query = from u in User,
    order_by: u.inserted_at,
    select: [u.username, u.inserted_at]

Repo.all(query)

To order by DESC:

query = from u in User,
    order_by: [desc: u.inserted_at],
    select: [u.username, u.inserted_at]

Joins

Assuming we had a profile associated with our user, let’s find all confirmed account profiles:

query = from p in Profile,
    join: u in assoc(profile, :user),
    where: u.confirmed == true

Fragments

Sometimes the Query API isn’t enough, like when we need specific database functions. The fragment/1 function exists for this purpose:

query = from u in User,
    where: fragment("downcase(?)", u.username) == ^username
    select: u

Additional query examples can be found in the Ecto.Query.API module description.

Changesets

In the previous section we learned how to retrieve data but how about inserting and updating it? For that we need Changesets.

Changesets take care of filtering, validating, maintaining constraints when changing a model.

For this example we’ll focus on the changeset for user account creation. To start we need to update our model:

defmodule ExampleApp.User do
  use Ecto.Schema
  import Ecto.Changeset
  import Comeonin.Bcrypt, only: [hashpwsalt: 1]

  schema "users" do
    field :username, :string
    field :encrypted_password, :string
    field :email, :string
    field :confirmed, :boolean, default: false
    field :password, :string, virtual: true
    field :password_confirmation, :string, virtual: true

    timestamps
  end

  @required_fields ~w(username email password password_confirmation)
  @optional_fields ~w()

  def changeset(user, params \\ :empty) do
    user
    |> cast(params, @required_fields, @optional_fields)
    |> validate_length(:password, min: 8)
    |> validate_password_confirmation()
    |> unique_constraint(:username, name: :email)
    |> put_change(:encrypted_password, hashpwsalt(params[:password]))
  end

  defp validate_password_confirmation(changeset) do
    case get_change(changeset, :password_confirmation) do
      nil ->
        password_incorrect_error(changeset)
      confirmation ->
        password = get_field(changeset, :password)
        if confirmation == password, do: changeset, else: password_mismatch_error(changeset)
    end
  end

  defp password_mismatch_error(changeset) do
    add_error(changeset, :password_confirmation, "Passwords does not match")
  end

  defp password_incorrect_error(changeset) do
    add_error(changeset, :password, "is not valid")
  end
end

We’ve improved our changeset/2 function and added three new helper functions: validate_password_confirmation/1, password_mismatch_error/1 and password_incorrect_error/1.

As its name suggests changeset/2 creates a new changeset for us. In it we use cast/4 to convert our parameters to a changeset from a set of required and optional fields. Next we validate the changeset’s password length, password confirmation match using our own function, and username uniqueness. Finally we update our actual password database field. For this we use put_change/3 to update a value in the changeset.

Using User.changeset/2 is relatively straightforward:

alias ExampleApp.{User,Repo}

pw = "passwords should be hard"
changeset = User.changeset(%User{}, %{username: "doomspork",
                    email: "[email protected]",
                    password: pw,
                    password_confirmation: pw})

case Repo.insert(changeset) do
  {:ok, model}        -> # Inserted with success
  {:error, changeset} -> # Something went wrong
end

That’s it! Now you’re ready to save some data.


Share This Page