Nested Changes in Ecto and LiveView

I’m interested in people’s opinions on how I approached a particular problem. I’m using LiveView, and I have a complex form with 4 total associations, one of those nested 2 levels in the base struct. I needed the entire changeset because I use the actions to broadcast the results (for example, if I update the parent, and create 2 children, and update 1 child, I need to broadcast an update on the parent, and 2 children, and broadcast a create on the other child). I also needed to know the current_change AKA only the thing or thing that changed last, so that I could perform some actions only once when a field changed. Specifically to make this work with LiveView, I needed to update the params when the x_id changed`

My solution, at a high level is use the same changeset to validate changes, and to update the record (I know that’s normal, but bear with me). I had to actually change the struct and re-validate, so I use apply_action during the form validation and Repo.update when I update the record (you’ll see why in a moment). Generally, the parent record has belongs_to relationships to the child structs, so when the x_id field changes on the parent, I want to present the fields of the new record to the user. I do this by changing the params when the x_id changes. Similarly, if I change the x_id, and make changes to x I need to change the associated record before validating the changes.

Basically handle_page_id_change manages updating the data in the changeset to the new record if the parent x_id changes. maybe_replace_page_params handles updating the params when the x_id changes so that the form changes when the user selects a new x. Note that I have to recast the x_id's after changing the associated records because I used apply_action / Repo.update.

In the case of cast_assoc(:annotation ... and handle_content_id_change I’m doing the same thing for the 2nd level nested change. I could potentially clean that up and refactor it inside the annotation changeset, but since there was only one of them, and I don’t do this anywhere else (yet), I left it here.

Once the associated data and params are updated, It’s business as usual. I cast the remaining params/foreign keys/assocs, and automatically name some records.

Any questions or comments? I’m curious if anyone else has had the same problem, or has any other ways of doing this that work better. I don’t fully understand the performance cost of doing all this work every time the user hits a key, but it’s been pretty performant in testing. From a “database correctness” standpoint, this seems like the right thing to do (change the foreign key on the parent first, make any changes to the associated record second).

  def nested_changeset(step, last_step, attrs, state, action) do
    last_change = changeset(last_step, attrs)
    step
  # |> change page
    |> cast(attrs, [ :page_id ])
    |> foreign_key_constraint(:page)
    |> Changeset.handle_page_id_change(state)
    |> Changeset.maybe_replace_page_params(last_change, state)
  # |> same_thing_for_annotation_and_element
  # |> update step fk's
    |> Changeset.cast_changeset_params([ :page_id, :annotation_id, :element_id ])
    |> Changeset.update_foreign_keys(action)
  # |> change content
    |> cast_assoc(:annotation, with: &Annotation.content_id_changeset/2)
    |> Changeset.handle_content_id_change(state)
    |> Changeset.maybe_replace_content_params(last_change, state)
  # |> update annotation fk's
    |> cast_assoc(:annotation, with: &Annotation.content_id_changeset/2)
    |> Changeset.update_foreign_keys(action)
  # |> final changes
    |> Changeset.cast_changeset_params([ :process_id, :step_type_id ])
    |> Changeset.cast_changeset_params([ :name, :order, :url, :text, :width, :height, :page_reference ])
    |> foreign_key_constraint(:process)
    |> foreign_key_constraint(:step_type)
    |> assoc_changeset()
    |> names_changeset()
    |> validate_required([:order])
  end
  
  
  def handle_page_id_change(%{ changes: %{ page_id: page_id }} = changeset, state) do
    Logger.debug("Page id changed to #{page_id}")
    page = Web.get_page!(page_id, state, state.assigns.state_opts)
    Map.put(changeset.data, :page, page)
    |> cast(changeset.params, [ ])
  end
  def handle_page_id_change(changeset, _state), do: changeset

  def maybe_replace_page_params(changeset, %{ changes: %{ page_id: page_id }}, state) do
    Logger.debug("Replacing Page #{page_id} Params")
    page = Web.get_page!(page_id, state, state.assigns.state_opts)
    page_params = replace_params_with_fields(changeset.params["page"], page, Page)
    params =
      changeset.params
      |> Map.put("page", page_params)
      |> Map.put("page_id", Integer.to_string(page_id))

    Map.put(changeset, :params, params)
  end
  def maybe_replace_page_params(changeset, _last_changeset, _state), do: changeset
  
   def update_foreign_keys(changeset, action) do
    case action do
      :validate ->
        { :ok, step } = apply_action(changeset, :update)
        step
        |> cast(changeset.params, [ ])
      _ ->
        { :ok, step } = UserDocs.Repo.update(changeset)
        step
        |> cast(changeset.params, [ ])
    end
  end