Search code examples
elixirphoenix-frameworkecto

Elixir Ecto adding computed value only on create


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

Solution

  • 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.