prepare_changes and counter cache
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)
enddef 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à !