Ecto
Most web applications today need some form of data validation and persistence. In the Elixir ecosystem, we have Ecto to enable this. Before we jump into building database-backed web features, we’re going to focus on the finer details of Ecto to give a solid base to build our web features on top of. Let’s get started!
Ecto currently has adapters for the following databases:
- PostgreSQL
- MySQL
- MSSQL
- SQLite3
- MongoDB
Newly generated Phoenix projects include Ecto with the PostgreSQL adapter by default (you can pass the --no-ecto
flag to exclude this).
For a thorough, general guide for Ecto, check out the Ecto getting started guide. For an overview of all Ecto specific mix tasks for Phoenix, see the mix tasks guide.
This guide assumes that we have generated our new application with Ecto integration and that we will be using PostgreSQL. For instructions on switching to MySQL, please see the Using MySQL Guide.
The default Postgres configuration has a superuser account with username ‘postgres’ and the password ‘postgres’. If you take a look at the file config/dev.exs
, you’ll see that Phoenix works off this assumption. If you don’t have this account already setup on your machine, you can connect to your postgres instance by typing psql
and then entering the following commands:
CREATE USER postgres;
ALTER USER postgres PASSWORD 'postgres';
ALTER USER postgres WITH SUPERUSER;
Now that we have Ecto and Postgres installed and configured, the easiest way to use Ecto is to generate an Ecto schema through the phx.gen.schema
task. Ecto schemas are a way for us to specify how Elixir data types map to and from external sources, such as database tables. Let’s generate a User
schema with name
, email
, bio
, and number_of_pets
fields.
$ mix phx.gen.schema User users name:string email:string \
bio:string number_of_pets:integer
* creating ./lib/hello/user.ex
* creating priv/repo/migrations/20170523151118_create_user.exs
Remember to update your repository by running migrations:
$ mix ecto.migrate
A couple of files were generated with this task. First, we have a user.ex
file, containing our Ecto schema with our schema definition of the fields we passed to the task. Next, a migration file was generated inside priv/repo/migrations
which will create our database table that our schema maps to.
With our files in place, let’s follow the instructions and run our migration. If the repo hasn’t been created yet, run the mix ecto.create
task. Next we can run:
$ mix ecto.migrate
Compiling 1 file (.ex)
Generated hello app
[info] == Running Hello.Repo.Migrations.CreateHello.User.change/0 forward
[info] create table users
[info] == Migrated in 0.0s
Mix assumes that we are in the development environment unless we tell it otherwise with MIX_ENV=another_environment mix some_task
. Our Ecto task will get its environment from Mix, and that’s how we get the correct suffix to our database name.
If we log in to our database server, and connect to our hello_dev
database, we should see our users
table. Ecto assumes that we want an integer column called id
as our primary key, so we should see a sequence generated for that as well.
$ psql -U postgres
Type "help" for help.
postgres=# \connect hello_dev
You are now connected to database "hello_dev" as user "postgres".
hello_dev=# \d
List of relations
Schema | Name | Type | Owner
--------+-------------------+----------+----------
public | schema_migrations | table | postgres
public | users | table | postgres
public | users_id_seq | sequence | postgres
(3 rows)
hello_dev=# \q
If we take a look at the migration generated by phx.gen.schema
in priv/repo/migrations
, we’ll see that it will add the columns we specified. It will also add timestamp columns for inserted_at
and updated_at
which come from the timestamps/0
function.
defmodule Hello.Repo.Migrations.CreateHello.User do
use Ecto.Migration
def change do
create table(:users) do
add :name, :string
add :email, :string
add :bio, :string
add :number_of_pets, :integer
timestamps()
end
end
end
And here’s what that translates to in the actual users
table.
hello_dev=# \d users
Table "public.users"
Column | Type | Modifiers
---------------+-----------------------------+----------------------------------------------------
id | integer | not null default nextval('users_id_seq'::regclass)
name | character varying(255) |
email | character varying(255) |
bio | character varying(255) |
number_of_pets | integer |
inserted_at | timestamp without time zone | not null
updated_at | timestamp without time zone | not null
Indexes:
"users_pkey" PRIMARY KEY, btree (id)
Notice that we do get an id
column as our primary key by default, even though it isn’t listed as a field in our migration.
The Repo
Our Hello.Repo
module is the foundation we need to work with databases in a Phoenix application. Phoenix generated it for us in lib/hello/repo.ex
, and this is what it looks like.
defmodule Hello.Repo do
use Ecto.Repo, otp_app: :hello
@doc """
Dynamically loads the repository url from the
DATABASE_URL environment variable.
"""
def init(_, opts) do
{:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
end
end
Our repo has three main tasks - to bring in all the common query functions from Ecto.Repo
, to set the otp_app
name equal to our application name, and to initialize the options passed to the database adapter in init/2
. We’ll talk more about how to use the Repo in a bit.
When phx.new
generated our application, it included some basic repo configuration as well. Let’s look at config/dev.exs
.
...
# Configure your database
config :hello, Hello.Repo,
adapter: Ecto.Adapters.Postgres,
username: "postgres",
password: "postgres",
database: "hello_dev",
hostname: "localhost",
pool_size: 10
...
It begins by configuring our otp_app
name and repo module. Then it sets the adapter – Postgres, in our case. It also sets our login credentials. Of course, you can change these to match your actual credentials if they are different.
We also have similar configuration in config/test.exs
and config/prod.secret.exs
which can also be changed to match your actual credentials.
The Schema
Ecto schemas are responsible for mapping Elixir values to external data sources, as well as mapping external data back into Elixir data-structures. We can also define relationships to other schemas in our applications. For example, our User
schema might have many Post
’s, and each Post
would belong to a User
. Ecto also handles data validation and type casting with changesets, which we’ll discuss in a moment.
Here’s the User
schema that Phoenix generated for us.
defmodule Hello.User do
use Ecto.Schema
import Ecto.Changeset
alias Hello.User
schema "users" do
field :bio, :string
field :email, :string
field :name, :string
field :number_of_pets, :integer
timestamps()
end
@doc false
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
end
end
Ecto schemas at their core are simply Elixir structs. Our schema
block is what tells Ecto how to cast our %User{}
struct fields to and from the external users
table. Often, the ability to simply cast data to and from the database isn’t enough and extra data validation is required. This is where Ecto Changesets come in. Let’s dive in!
Changesets and Validations
Changesets define a pipeline of transformations our data needs to undergo before it will be ready for our application to use. These transformations might include type-casting, user input validation, and filtering out any extraneous parameters. Often we’ll use changesets to validate user input before writing it to the database. Ecto Repos are also changeset-aware, which allows them not only to refuse invalid data, but also perform the minimal database updates possible by inspecting the changeset to know which fields have changed.
Let’s take a closer look at our default changeset function.
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
end
Right now, we have two transformations in our pipeline. In the first call, we invoke Ecto.Changeset
’s cast/3
, passing in our external parameters and marking which fields are required for validation.
cast/3
first takes a struct, then the parameters (the proposed updates), and then the final field is the list of columns to be updated. cast/3
also will only take fields that exist in the schema.
Next, validate_required/3
checks that this list of fields is present in the changeset that cast/3
returns. By default with the generator, all fields are required.
We can verify this functionality in iex. Let’s fire up our application inside iex
by running iex -S mix
. In order to minimize typing and make this easier to read, let’s alias our Hello.User
struct.
$ iex -S mix
iex> alias Hello.User
Hello.User
Next, let’s build a changeset from our schema with an empty User
struct, and an empty map of parameters.
iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<action: nil, changes: %{},
errors: [name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]},
number_of_pets: {"can't be blank", [validation: :required]}],
data: #Hello.User<>, valid?: false>
Once we have a changeset, we can check it if it is valid.
iex> changeset.valid?
false
Since this one is not valid, we can ask it what the errors are.
iex> changeset.errors
[name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]},
number_of_pets: {"can't be blank", [validation: :required]}]
Now, let’s make number_of_pets
optional. In order to do this, we simply remove it from the list.
|> validate_required([:name, :email, :bio])
Now casting the changeset should tell us that only name
, email
, and bio
can’t be blank. We can test that by running recompile()
inside iex
and then rebuilding our changeset.
iex> recompile()
Compiling 1 file (.ex)
:ok
iex> changeset = User.changeset(%User{}, %{})
#Ecto.Changeset<action: nil, changes: %{},
errors: [name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]}],
data: #Hello.User<>, valid?: false>
iex> changeset.errors
[name: {"can't be blank", [validation: :required]},
email: {"can't be blank", [validation: :required]},
bio: {"can't be blank", [validation: :required]}]
What happens if we pass a key/value pair that is in neither defined in the schema nor required?
Inside our existing IEx shell, let’s create a params
map with valid values plus an extra random_key: "random value"
.
iex> params = %{name: "Joe Example", email: "joe@example.com", bio: "An example to all", number_of_pets: 5, random_key: "random value"}
%{email: "joe@example.com", name: "Joe Example", bio: "An example to all",
number_of_pets: 5, random_key: "random value"}
Next, let’s use our new params
map to create another changeset.
iex> changeset = User.changeset(%User{}, params)
#Ecto.Changeset<action: nil,
changes: %{bio: "An example to all", email: "joe@example.com",
name: "Joe Example", number_of_pets: 5}, errors: [],
data: #Hello.User<>, valid?: true>
Our new changeset is valid.
iex> changeset.valid?
true
We can also check the changeset’s changes - the map we get after all of the transformations are complete.
iex(9)> changeset.changes
%{bio: "An example to all", email: "joe@example.com", name: "Joe Example",
number_of_pets: 5}
Notice that our random_key
and random_value
have been removed from the final changeset. Changesets allow us to cast external data, such as user input on a web form or data from a CSV file into valid data into our system. Invalid parameters will be stripped and bad data that is unable to be cast according to our schema will be highlighted in the changeset errors.
We can validate more than just whether a field is required or not. Let’s take a look at some finer-grained validations.
What if we had a requirement that all biographies in our system must be at least two characters long? We can do this easily by adding another transformation to the pipeline in our changeset which validates the length of the bio
field.
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
end
Now, if we try to cast data containing a value of “A” for our user’s bio, we should see the failed validation in the changeset’s errors.
iex> changeset = User.changeset(%User{}, %{bio: "A"})
iex> changeset.errors[:bio]
{"should be at least %{count} character(s)",
[count: 2, validation: :length, min: 2]}
If we also have a requirement for the maximum length that a bio can have, we can simply add another validation.
def changeset(%User{} = user, attrs) do
user
|> cast(attrs, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
end
Let’s say we want to perform at least some rudimentary format validation on the email
field. All we want to check for is the presence of the “@”. The validate_format/3
function is just what we need.
def changeset(struct, params \\ %{}) do
struct
|> cast(params, [:name, :email, :bio, :number_of_pets])
|> validate_required([:name, :email, :bio, :number_of_pets])
|> validate_length(:bio, min: 2)
|> validate_length(:bio, max: 140)
|> validate_format(:email, ~r/@/)
end
If we try to cast a user with an email of “example.com”, we should see an error message like the following.
iex> changeset = User.changeset(%User{}, %{email: "example.com"})
iex> changeset.errors[:email]
{"has invalid format", [validation: :format]}
There are many more validations and transformations we can perform in a changeset. Please see the Ecto Changeset documentation for more information.
Data Persistence
We’ve talked a lot about migrations and data-storage, but we haven’t yet persisted any of our schemas or changesets. We briefly looked at our repo module in lib/hello/repo.ex
earlier, now it’s time to put it to use. Ecto Repo’s are the interface into a storage system, be it a Database like PostgreSQL, or an external service like a RESTful API. The Repo module’s purpose is to take care of the finer details of persistence and data querying for us. As the caller, we only care about fetching and persisting data. The Repo takes care of the underlying Database adapter communication, connection pooling, and error translation for database constraint violations.
Let’s head back over to IEx with iex -S mix
, and insert a couple of users to the database.
iex> alias Hello.{Repo, User}
[Hello.Repo, Hello.User]
iex> Repo.insert(%User{email: "user1@example.com"})
[debug] QUERY OK db=4.6ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user1@example.com", {{2017, 5, 23}, {19, 6, 4, 822044}}, {{2017, 5, 23}, {19, 6, 4, 822055}}]
{:ok,
%Hello.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil, email: "user1@example.com", id: 3,
inserted_at: ~N[2017-05-23 19:06:04.822044], name: nil, number_of_pets: nil,
updated_at: ~N[2017-05-23 19:06:04.822055]}}
iex> Repo.insert(%User{email: "user2@example.com"})
[debug] QUERY OK db=5.1ms
INSERT INTO "users" ("email","inserted_at","updated_at") VALUES ($1,$2,$3) RETURNING "id" ["user2@example.com", {{2017, 5, 23}, {19, 6, 8, 452545}}, {{2017, 5, 23}, {19, 6, 8, 452556}}]
{:ok,
%Hello.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil, email: "user2@example.com", id: 4,
inserted_at: ~N[2017-05-23 19:06:08.452545], name: nil, number_of_pets: nil,
updated_at: ~N[2017-05-23 19:06:08.452556]}}
We started by aliasing our User
and Repo
modules for easy access. Next, we called Repo.insert/1
and passed a user struct. Since we’re in the dev
environment, we can see the debug logs for the query our Repo performed when inserting the underlying %User{}
data. We received a 2-tuple back with {:ok, %User{}}
, which lets us know the insertion was successful. With a couple of users inserted, let’s fetch them back out of the repo.
iex> Repo.all(User)
[debug] QUERY OK source="users" db=2.7ms
SELECT u0."id", u0."bio", u0."email", u0."name", u0."number_of_pets", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
[%Hello.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil, email: "user1@example.com", id: 3,
inserted_at: ~N[2017-05-23 19:06:04.822044], name: nil, number_of_pets: nil,
updated_at: ~N[2017-05-23 19:06:04.822055]},
%Hello.User{__meta__: #Ecto.Schema.Metadata<:loaded, "users">,
bio: nil, email: "user2@example.com", id: 4,
inserted_at: ~N[2017-05-23 19:06:08.452545], name: nil, number_of_pets: nil,
updated_at: ~N[2017-05-23 19:06:08.452556]}]
That was easy! Repo.all/1
takes a data source, our User
schema in this case, and translates that to an underlying SQL query against our database. After it fetches the data, the Repo then uses our Ecto schema to map the database values back into Elixir data-structures according to our User
schema. We’re not just limited to basic querying – Ecto includes a full-fledged query DSL for advanced SQL generation. In addition to a natural Elixir DSL, Ecto’s query engine gives us multiple great features, such as SQL injection protection and compile-time optimization of queries. Let’s try it out.
iex> import Ecto.Query
Ecto.Query
iex> Repo.all(from u in User, select: u.email)
[debug] QUERY OK source="users" db=2.4ms
SELECT u0."email" FROM "users" AS u0 []
["user1@example.com", "user2@example.com"]
First, we imported Ecto.Query
, which imports the from
macro of Ecto’s Query DSL. Next, we built a query which selects all the the email addresses in our user’s table. Let’s try another example.
iex)> Repo.one(from u in User, where: ilike(u.email, "%1%"),
select: count(u.id))
[debug] QUERY OK source="users" db=1.6ms SELECT count(u0."id") FROM "users" AS u0 WHERE (u0."email" ILIKE '%1%') []
1
Now we’re starting to get a taste of Ecto’s rich querying capabilities. We used Repo.one/1
to fetch the count of all users with an email address containing “1”, and received the expected count in return. This just scratches the surface of Ecto’s query interface, and much more is supported such as sub-querying, interval queries, and advanced select statements. For example, let’s build a query to fetch a map of all user id’s to their email addresses.
iex> Repo.all(from u in User, select: %{u.id => u.email})
[debug] QUERY OK source="users" db=0.9ms
SELECT u0."id", u0."email" FROM "users" AS u0 []
[%{3 => "user1@example.com"}, %{4 => "user2@example.com"}]
That little query packed a big punch. It both fetched all user emails from the database and efficiently built a map of the results in one go. You should browse the Ecto.Query documentation to see the breadth of supported query features.
In addition to inserts, we can also perform updates and deletes with Repo.update/1
and Repo.delete/1
to update or delete a single schema. Ecto also supports bulk persistence with the Repo.insert_all
, Repo.update_all
, and Repo.delete_all
functions.
There is quite a bit more that Ecto can do and we’ve only barely scratched the surface. With a solid Ecto foundation in place, we’re now ready to continue building our app and integrate the web facing application with our backend persistence. Along the way, we’ll expand our Ecto knowledge and learn how to properly isolate our web interface from the underlying details of our system. Please take a look at the Ecto documentation for the rest of the story.
In our context guide, we’ll find out how to wrap up our Ecto access and business logic behind modules that group related functionality. We’ll see how Phoenix helps us design maintainable applications, and we’ll find out about other neat Ecto features along the way.