Having difficulty implementing a particular strategy with Ectos cast_assoc and on_replace API

Hello, I have the below schema relationship network (the names are for illustrative purposes):

                                                  | --- Revision  |               
                                                ->| active: false |               
                                             --/  | ---           |               
                                          --/     +---------------+               
+--------------------------------+     --/        +---------------+               
| --- Book                       |  --/           | --- Revision  |              
| has_one where: active == true  |-/------------->| active: false |               
| ---                            |  -\            | ---           |               
+--------------------------------+    --\         +---------------+               
                                         ---\     +---------------+               
                                             --\  | --- Revision  |               
                                                ->| active: true  |               
                                                  | ---           |               
                                                  +---------------+ 

It’s a one-to-many relationship where one of the Revisions has a property called active that is set to true and the rest are set to false. The Revision where active is set to true represents the “current version” of the Book. I am having trouble getting a specific action working in Ecto due to how cast_assoc appears to work. Instead of modifying the active revision I want it to create with a new Revision that is active with the same properties (plus the modifications):

  1. Set current Revision’s active property to false
  2. Copy the current Revision attributes to a new Revision
  3. Set the new Revision’s active property to true
  4. Apply any changes being made to the old Revision instead to the new Revision

The reason for this approach is that we want an immutable history of Revision records while maintaining a simplified interface for the user (they only see the most recent Revision).

In my initial approach of this process I attempted to change the current active revision relationship via cast_assoc however using that complained because on_replace defaults to raise, but didn’t find an on_replace option that satisfies my needs. Instead I have to bifurcate the changeset logic in a way that one casts the assoc for validation purposes and the other simply ignores the casting (so that I can change the relationship in another way).

Currently I made an Ecto.Multi chain that will: Set all previous revisions active to false; create a new revision with active true; signal the update changeset on the parent to avoid casting the child (to allow updating the other parent attributes).

I feel like this is cumbersome and not elegant at all. The most problematic bit being identifying whether any changes to the current active child happened or not and forking the logic.

I wonder if it would be possible to have a customization of the on_replace behavior where I can describe a process where I want to duplicate and insert a new record, associate it, and keep the old record around.

Maybe there is a better way to approach this that I missed?

Thank you

Honestly, I always prefer this option over shenanigans with cast_assoc/put_assoc.

This or the newer option is to do operations in a Repo.transaction/2 block.

Sure, but is there any other option for my case?

That’s what I’m using, however my code for saving looks something like this:

Book.update_changeset(book, book_params)
|> case do
  %Ecto.Changeset{changes: %{revision: %Ecto.Changeset{changes: revision_changes}}} ->
    Ecto.Multi.new()
    |> Ecto.Multi.update(
      :old_revision,
      Revision.archive_changeset(book.revision)
    )
    |> Ecto.Multi.insert(
      :revision,
      Revision.create_changeset(
        %Revision{},
        revision_params |> Map.put(:book, book) |> Map.delete("id")
      )
    )
    |> Ecto.Multi.update(
      :book,
      fn %{revision: new_revision} ->
        Book.update_without_revision_changeset(
          book,
          book_params
        )
      end
    )
    |> Dashboard.Repo.transaction()

  changeset ->
    Dashboard.Repo.update(changeset)
end

I can’t help but imagine I could do better than this?

Hmm, indeed. Have you tried using put_assoc/4?

It was designed to work exactly for this use-case (maybe someone else can tell you more, as I don’t remember when I used it last time), where you work with the entire collection.