prepare_changes and counter cache

Sylvain Kieffer
2 min readFeb 4, 2016

--

In my blog app, I have posts which have many comments. I’d like to add a comments_count field in Post and have it automatically updated whenever a Comment is created or deleted.

In Ecto 1.0 I could have used callbacks, but they’re deprecated in Ecto 1.1. So how do I achieve this ?

prepare_changes

Ecto.Changeset.prepare_changes/2 let you add a function which will be executed in a transaction right before your change is applied to the Repo.

Here’s how it works :

changeset
|> prepare_changes(fn prepared_changeset ->
# the repo is available in the changeset itself
repo = prepared_changeset.repo
do_something_with_this_repo(repo) # you have to return the changeset at the end of the function
prepared_changeset
end)
|> Repo.insert

When Repo.insert is called, it puts itself in prepared_changeset.repo and executes the function.

The function is executed in the same transaction as the insertion. Should the insertion fail, the changes made by the function would be rollbacked as well.

Adding a counter

This one is pretty straightforward. First you generate a migration :

mix ecto.gen.migration add_comments_count_to_posts

Then you edit the migration :

defmodule Posts.Repo.Migrations.AddCommentsCountToPosts do
use Ecto.Migration
def change do
alter table(:posts) do
add :comments_count, :integer, null: false, default: 0
end
end
end

You migrate :

mix ecto.migrate

Finally, you update the Post schema :

schema "posts" do
...
field :comments_count, :integer, default: 0
...
end

Updating the parent’s counter

Let’s create a function in Comment, which will, given a changeset and a value, increment the comment’s parent comments_count by an arbitrary value :

defp update_parent_counter(changeset, value) do
changeset
|> prepare_changes(fn prepared_changeset ->
repo = prepared_changeset.repo
post_id = prepared_changeset.post_id
from(p in Posts.Post,
where: p.id == ^post_id)
|> repo.update_all(inc: [comments_count: value])
prepared_changeset
end)
end

Then we create two different changesets (one for creation, one for deletion) :

def create_changeset(model, params \\ :empty) do
changeset(model, params)
|> update_parent_counter(1)
end
def delete_changeset(model) do
# we don't care about params, so we provide an empty Map
changeset(model, %{})
|> update_parent_counter(-1)
end

Now, whenever you want to create a Comment you use :

changeset = Comment.create_changeset(%Comment{}, comment_params)Repo.insert(changeset)

For the deletion :

comment = Repo.get!(Comment, id)comment
|> Comment.delete_changeset
|> Repo.delete!

And voilà !

--

--