Conditionally associate in a simple changeset() function

Hi! :wave:

I’d like to ask how you implement a super common functionality, for which I do not see any easy way in Ecto.Changeset

Lets say I have a Todo which belongs to a TodoList.

I get my auto-generated changeset() using phx.gen.schema which looks roughly like this:

  def changeset(todo, attrs) do

    todo
    |> cast(attrs, [:title, :details, :done])
    |> validate_required([:title])
  end

Now I would like to also be able to pass the association into this function, so in my context API todos.ex I can do:

def move_todo_to_list(todo, todo_list) do
   update_todo(todo, %{todo_list: todo_list})
end

obviouly in changeset() I cannot cast() the todo_list.
I need to call either put_assoc(), or change() - however, the association is not always given, I can also just have this usage from controller

def create_todo(params) do
  %Todo{} 
  |> Todo.changeset(params)
  |> Repo.insert()
end

where params would contain todo_list_id…

I do not want to do any conditional handling in my changeset, nor defp some multiclause helpers:

  def changeset(todo, attrs) do
    chset = todo
    |> cast(attrs, [:title, :details, :done])
    
    # OMG verbose!!!!
    chset = if attrs[:todo_list] do
      put_assoc(chset, attrs[:todo_list], todo_list)
    else
      chset
    end

    chset
    |> validate_required([:title])
  end

(note, some custom validations might use the association to figure out if an attribute is valid or not, so validate goes last).

I used to do kinda elegant:

  def changeset(todo, attrs) do
    assocs = Map.take(attrs, [:todo_list])

    todo
    |> cast(attrs, [:title, :details, :done])
    |> change(assocs)
    |> validate_required([:title])
  end

But this will not work if attrs are sometimes keyed by strings (data from user), and sometimes by atoms (programmer controlled params from some module), and then when I add :todo_list key, i might end up with “cannot mix strings and atom keys” error from cast().

I bet this has to be solved somehow elegantly, this is such a common use case – but cannot come up with anything based on Ecto standard functions other then writing some custom helpers…