Infer a field value from another field

I want to set a struct’s field value depending on the value of another field.

This is the method I’ve arrived at, where I wait until changeset is called and slip it in between the validators. Is this the correct/expected method of doing something like this?

One issue with doing it this way is if someone writes fruit = %Fruit(%{name: :lemon}), they can’t access fruit.tastes until they save and load the record (or at least call some wrapper around changeset and apply_changeset). This isn’t a huge deal though, just a would-be-nice. The reason I’m not just using a Fruit.tastes(fruit) function is because tastes has to be indexable in the DB.

defmodule Food.Fruit do
  use Ecto.Schema
  import Ecto.Changeset

  @required_attributes [:name]
  @optional_attributes []
  @valid_names [:lemon, :banana]
  @valid_tastes [:sour, :sweet]
  
  schema "fruits" do
    field :name, :string #user specified

    field :tastes, :string #inferred from name

    timestamps()
  end

  def changeset(fruit, attrs) do
    fruit
    |> cast(attrs, @required_attribtues ++ @optional_attributes)
    |> validate_required(@required_attributes)
    |> validate_inclusion(:name, @valid_names)
    |> infer_colour
    # these are debately needed, we should set things correctly in infer_colour
    # but perhaps doesn't hurt to check to ward against future stupid.
    |> validate_required(:tastes)
    |> validate_inclusion(:tastes, @valid_tastes)
  end

  # name is invalid, so we can't infer any value
  def infer_colour(%Ecto.Changeset{errors: [{:name, _} | _]} = changeset), do: changeset
  # name isn't error'd so we can infer a value
  def infer_colour(%Ecto.Changeset{} = changeset) do
    case fetch_field(changeset, :name) do
      # match on {:data, value} or {:changes, value} so if the user passes a
      # custom :tastes to Fruit.changeset we default to overwriting it
      # with our correct value (though cast(...) should be stripping :tastes if present)
      {_, :lemon} -> change(changeset, %{tastes: :sour})
      {_, :apple} -> change(changeset, %{tastes: :sweet})
      {_. name} -> add_error(changeset, :tastes, "unknown :tastes for #{name}")
      :error -> add_error(changeset, :tastes, "could not infer :tastes, no :name found")
    end
  end
end
1 Like

Not an expert yet, but in a very good book about “Programming Phoenix ≥ 1.4” some people with unknown names like Valim/McCord/Tate :smiley: used a similar pattern to put a hashed password (created from the clear text password) into the changeset. So if nobody else will anser: I think it’s no bad idea :wink:

Chapter 5: Authenticating Users / Managing Registration Changesets (page 75)

Btw.: Would love to buy a “Programming Phoenix ≥ 1.4 - dive deeper” book with more details, best practices, …

1 Like

Someone on stack overflow pointed out that you could in fact just modify the attrs sent to changeset before attempting any validations.

It does rely on :name being in the attributes so you can infer :tastes unless you want to pass/search fruit and attrs at which point you might as well go the inline method since picking out the correct :name is easier with a Changeset.

1 Like