Skip to content
Edgar Pino
TwitterHomepage

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.

  1. Installing and Configuring Ecto
  2. Migrations, Schemas, and Changesets
  3. CRUD operations (This Post)
  4. 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()
end
end

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.