What is the best practice approach for adding a computed value on create/insert? Should I create a unique changeset for both create and update?
Say, for example, I have a blog post model, and I want to create a title slug value and store it. This is a bit contrived, but say for some reason I only want to set it on create and not update. Should I do something like the following?
defmodule MyBlog.Post do
use MyBlog.Web, :model
schema "posts" do
field :title, :string
field :title_slug, :string
field :content, :text
timestamps
end
@required_fields ~w(
title
content
)
@optional_fields ~w()
def create_changeset(model, params \\ :empty) do
changeset(model, params)
|> generate_title_slug
end
defp changeset(model, params \\ :empty) do
model
|> cast(params, @required_fields, @optional_fields)
end
defp generate_title_slug(changeset) do
put_change(changeset, :title_slug, __some_slug_generation_code__)
end
def update_changeset(model, params \\ :empty) do
changeset(model, params)
end
end
I strongly discourage callbacks - they are hard to test, introduce global state, are obscure, and are difficult to reason about. This also goes against one of the core Elixir's principles: "explicit is better than implicit".
The Ecto's core team is even considering getting rid of callbacks, or changing the name and making them less exposed. Using a callback should be a last resort option, when nothing else is possible.
To showcase what is one of the issues with callbacks, let's imagine a scenario where you indeed used a callback to solve this problem. And now you're designing an admin interface where you don't want to have this behaviour. How do you solve this? You start going down the rabbit hole of disabling callbacks, introducing exceptions upon exceptions, and have a hard to follow, multi-branch conditional logic. But this is solving a wrong problem all together!
The different changeset approach is perfectly fine and very natural regarding Ecto's architecture. This way you can have different validations for different actions and nothing is global. Let's think how you would solve the issue in the scenario I showcased earlier. It's extremely simple - you create another changeset function!
A solution I've seen couple of times is to change the changeset
function to take three arguments and pattern match on the type in the first one, e.g.:
def changeset(action, model, params \\ :empty)
def changeset(:create, model, params)
# return create changeset
end
def changeset(:update, model, params)
# return update changeset
end
I'm not sure which is better - multiple functions or pattern matching in one function. This is mostly question of preference.