Getting Started with Ecto Part 3: CRUD Operations
— 6 min read
Welcome to part three of Getting Started with Ecto. On the last post, we covered how to create migrations, schemas, and changesets. In this post, we will cover how to run CRUD operations against our Postgres database using Ecto.
- Installing and Configuring Ecto
- Migrations, Schemas, and Changesets
- CRUD operations (This Post)
- Advanced Queries
Ecto is not an ORM
If you come from Ruby on Rails or .Net, you might be familiar with Active Record or Entity framework. You may be used to doing something like user.Save()
or User.Find()
. These and other frameworks follow the Active Record design pattern which is an approach of accessing data in a database. One of the major drawbacks of this pattern is that your domain is usually tightly coupled to a certain persistence layer. You probably will have hard time changing the type of database you are using.
Ecto follows the Repository design pattern which is an abstraction of the data layer. It's a way of centralizing the handling of the domain objects. In Ecto, queries are done using the Ecto.Query
DSL against our repository (GettingStartedWithEcto.Repo
).
I highly suggest reading this article to learn more about the repository pattern.
Okay, let's move on to creating records.
Creating Records
The first thing we are going to create a challenge. Check out the database design in part 2 for reference.
Let's define the Challenges
module inside getting_started_with_ecto/challenges/challenges.ex
. Let's define a function called create_challenge
that takes one argument with a map as the default. In this function, we will validate the data via the changeset and insert it to the database. Here's how that looks like:
defmodule GettingStartedWithEcto.Challenges do alias GettingStartedWithEcto.Challenges.Challenge alias GettingStartedWithEcto.Repo
def create_challenge(attrs \\ %{}) do %Challenge{} |> Challenge.changeset(attrs) |> Repo.insert() endend
In this case, the Repo.insert
function takes a changeset
. It also takes other options like:
:returning
for selecting which fields to return. It will return the fields the struct by default.:on_conflict
to specify an alternative action of raising an error. We will use this one later on for upserts.
See the official docs for more options.
You can also use the Repo.insert!
function which is similar to Repo.insert
which will raise if the changeset is invalid or an error happens.
Okay, let's try inserting a Challenge using the function we just created.
%{ title: "Best Challenge Ever", description: "An example description", level: "easy"} |> GettingStartedWithEcto.Challenges.create_challenge
It should return the following tuple:
{:ok, %GettingStartedWithEcto.Challenges.Challenge{ __meta__: #Ecto.Schema.Metadata<:loaded, "challenges">, description: "An example description", id: 4, inserted_at: ~N[2018-12-07 19:46:50], level: "easy", solutions: #Ecto.Association.NotLoaded<association :solutions is not loaded>, title: "Best Challenge Ever", updated_at: ~N[2018-12-07 19:46:50] }}
Make note of that Ecto.Association.NotLoaded
message. We will go over that later.
Here are other functions Ecto provides out of the box to insert records:
Repo.insert_all
for inserting many entries into the repository.Repo.insert_or_update
for updating or inserting a record into the repository. This is also a way of doing upserts.
Let's move on to reading records from our repository.
Reading Records
Let's create a simple function inside the Challenges
module to get a challenge by its primary id. Let's call it get_challenge_by_id
and it will look something like this:
def get_challenge_by_id(id) do Challenge |> Repo.get(id)end
As you can see, this functions is fairly straightforward. The Repo.get
takes in our Challenge
schema and the id. It returns the struct if there is a match, otherwise, nil
will be returned.
Let's give it a try.
alias GettingStartedWithEcto.Challenges
{:ok, challenge} = %{ title: "Best Challenge Ever", description: "An example description", level: "easy"} |> Challenges.create_challenge
Challenges.get_challenge_by_id(challenge.id)
It should return the following:
%GettingStartedWithEcto.Challenges.Challenge{ __meta__: #Ecto.Schema.Metadata<:loaded, "challenges">, description: "An example description", id: 51, inserted_at: ~N[2018-12-11 05:08:32], level: "easy", solutions: #Ecto.Association.NotLoaded<association :solutions is not loaded>, title: "Best Challenge Ever", updated_at: ~N[2018-12-11 05:08:32]}
There are other function for querying the database and Ecto does support custom queries, we will cover does later. Here are some of the functions Ecto provides out of the box:
Repo.one
for fetching a single result.Repo.get_by
for fetching a single result by a given column instead of the primary key.Repo.all
for fetching all entries from a query.
Let's move on the updating records.
Updating Records
Let's start by creating the update_challenge
function inside the Challenges
module.
It will take in a %Challenge
struct as the first argument and the updated fields as a map. Our function will look something like this:
def update_challenge(%Challenge{} = challenge, attrs \\ %{}) do challenge |> Challenge.changeset(attrs) |> Repo.update()end
As you may already see, the update function is similar to create_challenge
. We pass in our record as the first argument and the fields to update as the second argument. The Repo.update
function takes the updated changeset and executes the query to update the record. This relies on the primary key of the record Ecto.NoPrimaryKeyFieldError
will be raised if there is none.
Let's try updating the challenge we just created.
alias GettingStartedWithEcto.Challenges
{:ok, challenge} = %{ title: "Best Challenge Ever", description: "An example description", level: "easy"} |> Challenges.create_challenge
challenge|> Challenges.update_challenge(%{title: "Updated title"})
It should return a tuple {:ok, struct}
:
{:ok,%GettingStartedWithEcto.Challenges.Challenge{ __meta__: #Ecto.Schema.Metadata<:loaded, "challenges">, description: "An example description", id: 21, inserted_at: ~N[2018-12-08 14:13:19], level: "easy", solutions: #Ecto.Association.NotLoaded<association :solutions is not loaded>, title: "Updated title", updated_at: ~N[2018-12-08 14:13:51]}}
Deleting Records
Deleting records with Ecto is simple. Let's create the delete_challenge
function inside our Challenges
module. We will take a %Challenge
struct or changeset as an argument and delete the record by calling the Repo.delete
. Here's what it looks like:
def delete_challenge(%Challenge{} = challenge) do challenge |> Repo.delete()end
It returns {:ok, struct}
if it succeeded or {:error, changeset}
if there was an error.
What if we want to delete a record and we only have the id? We can do that by creating a struct with just the id field and passing it to the Repo.delete
function. Our function will look something like this:
def delete_challenge(id) when is_integer(id) do %Challenge{id: id} |> Repo.delete()end
With the magic of pattern matching and guards, our function will take the id and delete the record.
One last thing
I used the Ecto functions that return tuples or nil
but don't raise any errors. There are equivalent functions that raise errors instead. For example, there is a Repo.get!
that raises an errors instead of returning a nil
. There is also a Repo.delete!
that raises an error instead of returning {:error, changeset}
. Most of these functions end with a !
, check out the documentation for more info.
Congratulations 🎉 🎉 🎉
You just learned how to create, read, update, and delete records with Ecto. On the next post, we will cover how to write advanced queries along with how to insert records with relationships.
Checkout the source code for reference or to see the the full module we created.