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…

How about the following?

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

That does not cover a useful situation when the assoc is not yet in the db (let’s say I created a new todo with its own list, and will let Repo.insert both)

Tbh at a certain point it’s just not worth it trying to treat every possible scenario as “one and the same”. Ecto has useful APIs for all the usecases you mentioned. It’s however not going to be one API covering all of them at the same time.

1 Like

I agree with @LostKobrakai here but if you really insist, you can just make a function with multiple heads, one of which covers a nil primary / foreign key.