Adding a nested association in a changeset when I can't be sure of existing data/changes?

I’m creating a Phoenix LiveView application where a user can create an interview and add questions to that interview. I have a one-to-many relationship so that one interview can have many questions. There will always be at least one question, but otherwise it is up to the user how many questions they add.

I’m using inputs_for so that when creating/editing an interview you can change the questions for it. You can what the form looks like here

Because it’s up to the user how many questions an interview has there’s an ‘Add Question’ button. This adds another input, like so:

Currently to get the repeating inputs to show, I’ve been adding a new question to changeset.changes.questions.

The problem that I’m running in to is, if we modify the text for the first question, and then press ‘Add Question’, I can’t simply update changeset.changes.questions to equal changeset.data.questions ++ [%Interviewing.Interviews.Question{}] because it will lose any of the existing changes.

The opposite is also true, I can’t do changeset.changes.questions ++ [%Interviewing.Interviews.Question{}] if we’re editing an existing interview and we go straight to adding a question, as there are no existing changes and changeset.changes.questions does not exist.

So far I’ve created a utility get_changeset_existing_questions which will either get changeset.changes.questions, or if it does not existing create a list of ignored changes based on the existing questions in the changeset data.

  defp get_changeset_existing_questions(changeset) do
    Map.get(
      changeset.changes,
      :questions,
      Enum.map(changeset.data.questions, &%{change(&1) | action: :ignore})
    )
  end

  def add_question_to_changeset(changeset) do
    new_question = %Interviewing.Interviews.Question{}
    new_question_changeset = change(new_question)
    questions_changeset = get_changeset_existing_questions(changeset) ++ [new_question_changeset]
    Map.put(changeset, :changes, Map.merge(changeset.changes, %{questions: questions_changeset}))
  end

I’m not convinced by the correctness of what I’m doing, is there a better way? Should I simply apply any of the existing changes before adding a new change, so that I can always do changeset.data.questions ++ [%Interviewing.Interviews.Question{}]? Can I update the data directly when adding a new question and ignore changeset.changes completely? Am I missing an obvious function from Ecto.Changeset? Is my entire approach wrong?

Any help would be appreciated, thanks!

1 Like

You are not, this is simply very hard to do with changesets. As @mcrumm and I can testify, we run into this a lot in our company applications where we have a lot of forms that let people specify N devices or other things when configuring a shipment (and god help you if you need to support barcode scanners).

I have burned hours and hours trying to get this working with changesets in a way that doesn’t lead to weird behavior with one interaction or another. Changesets were simply not really built to be evolved they really just work for a one short “here is the previous state, here is some proposed changes, give me the result”. At times we’ve reimplemented form helpers from scratch just to avoid needing to use changesets.

This all sounds sort of dire, and I don’t intend to dissuade you from the path you’ve found. Basically though it is not surprising to me that you’ve had to do some strange stuff to get this to work. Mike and I hope to have some new approaches to LiveView forms in the future that makes this easier.

5 Likes

My experience is that it’s hell to work with nested changesets and LiveView. I created the following package https://github.com/mathieuprog/changeset_helpers especially for working with nested changesets and LiveView more easily. Overall my conclusion was to avoid it at all.

3 Likes

I think you’re on the right track. IMO, you can simplify your life considerably once you realize that you can completely disregard the questions in the original data if you use them to initialize the changes (which is what your get_changeset_existing_questions kind of already does).

So when you first initialize the form, use the following changeset:

def mount(...) do 
changeset = Ecto.Changeset.change(interview, %{questions: interview.questions})

{:ok, assign(socket, :changeset, changeset}
end

Now all the existing questions (if any) are in the changes. And now, the form should allow the user to, add, delete, or modify the questions, always working on the changes in changeset.

Then you can simply cast_assoc user changes (params) into your data:

def handle_event("save", %{"interview" => params}, socket) do  

socket.assigns.changeset
|> Ecto.changeset.cast(params, [ _top_level_interview_fields_here_ ])
|> Ecto.Changeset.cast_assoc(:questions)
|> Ecto.Changeset.apply_action(:save)

...
end

That’s the basic idea. For this to work the has_many associations for the questions in the interview schema has to use the on_replace: :delete option, so that you can delete existing questions.

I tested it locally with a toy example and I feel that something like this should work. Let me know if this makes sense and is helpful.

1 Like

Hey, thanks for the reply!

I had a couple of issues with this, I’m a bit of a beginner to Phoenix so it’s probably all my fault :sweat_smile:

The two issues I had was:

  1. In the mount/1 callback I wasn’t sure on how best to get access to the interview. It wasn’t in the socket by default, but I’m presuming there’s nothing wrong with manually fetching it from the database.

  2. changeset = Ecto.Changeset.change(interview, %{questions: interview.questions}) didn’t seem to update changeset.changes. I think this is because of the following:

    Changed attributes will only be added if the change does not have the same value as the field in the data.

    Ecto.Changeset — Ecto v3.7.1

Regardless, it’s very re-assuring to hear that I’m on the right track after spending hours going around in circles! :smile:

2 Likes

H!

  1. Well you’re using LiveView and you already have a form, so you must be loading your interview into the form somehow, right? :slight_smile: Yes, either fetch the interview from the DB (if updating an existing one) or create a fresh interview struct if you’re dealing with a new interview

  2. Good point! My example was working locally for me because I was lazy and using an embedded association without primary keys. To make it work when you have primary keys, setting the primary keys to nil should work:

questions = for question <- interview.questions, do: %{question | id: nil}
changeset = Ecto.Changeset.change(interview, %{questions: questions})

So now every new set of questions will replace the existing ones. Even if nothing changes, and you hit save, the existing questions will be deleted and replaced with identical copies. This is however not a big deal IMO.