Using Ecto.Multi to update parent and and dynamically child records

I have two objects, Logs and Blocks. Logs belong to Blocks - if a Log and a Block share a hashtag, then they’ll be linked together. The link is stored in the Log.

Updating Logs is fairly simple. However, if you edit the hashtags in a Block, then you have to update linked Logs and (potentially) update Logs that will be linked

To address this bulk update, I’m wrapping up database operations in a transaction via Ecto.Multi. However, I’m not sure of the best way to do this. This is my current version:

def update_block_then_logs( %Block{} = block, attrs, for: user ) do
  Ecto.Multi.new()
  |> Ecto.Multi.update( :block, Blocks.update_block(block, attrs) )
  |> Ecto.Multi.run( :logs, fn _repo, %{block: block} ->
    tags = block.tags |> String.split

    # Returns logs that are currently set to this block and logs that will be set
    log_changesets = list_logs_affected_by( block: block, tags: tags, for: user )
    |> Enum.map( fn log ->
      regenerate_blocks_for_log( log, for: user )
    end)

    errors = log_changesets
    |> Enum.filter( fn {status, _changeset} -> status == :error end )
    |> Enum.map( fn {_status, changeset} -> changeset end )

    if errors != nil && Enum.count(errors) > 0 do
      { :error, errors }
    else
      { :ok, log_changesets }
    end
  end)
  |> Repo.transaction
end

regenerate_blocks_for_log interrogates the database (and will assume that the Block has already been updated). It ends up calling Repo.update( log_changeset ) rather than returning the changeset itself. Because I’m using Ecto.Multi.run I had to create some error trapping. The code I wrote seems okay, but then I thought it might be better for error trapping if I separated out each Repo.update into its own operation in the Ecto.Multi object.

So I created this version:

def update_block_then_logs( %Block{} = block, attrs, for: user ) do
  block_changeset = Blocks.update_block( block, attrs )

  multi = Ecto.Multi.new()
  |> Ecto.Multi.update( :block, block_changeset )

  tags = block_changeset 
  |> Ecto.Changeset.get_field( :tags )
  |> String.split

  # Returns logs that are currently set to this block and logs that will be set
  multi_log = list_logs_affected_by( block: block, tags: tags, for: user )
  |> Enum.map( fn log ->
    op_name = String.to_atom("log_#{log.id}")
    log_changeset = regenerate_blocks_for_log( log, for: user )

    Ecto.Multi.new
    |> Ecto.Multi.update( op_name, log_changeset )
  end)
  |> Enum.reduce( Ecto.Multi.new(), &Ecto.Multi.append/2 )

  Ecto.Multi.append( multi, multi_log )
  |> Repo.transaction()
end

In this version, regenerate_blocks_for_log returns a changeset. However, I realised that regenerate_blocks_for_log won’t work - since it was written to read data committed within a transaction (which doesn’t happen until the last statement).

I could adjust regenerate_blocks_for_log so that it takes into account changes to the Block that have not yet been committed. But then I wonder, is there a way to dynamically alter the Multi using the first technique (as records have been committed in the transaction)?

I’ve also asked this question on SO

I’d start with your first version and then split out everything you do in that one Ecto.Multi.run using Ecto.Multi.merge and sharing data via the return values of many Ecto.Multi.run segments within that merge. Multi does not require you to actually make database connections within run, but basically anything returning {:ok | :error, value} can be used.
As you’ve already noticed you’d want to keep the execution of your code within some Ecto.Multi callback, so it’s executed in the context of the transaction and not beforehand.

1 Like

Sweet! Ecto.Multi.merge was a what I was looking for. Here’s the updated version:

  def update_block_then_logs( %Block{} = block, attrs, for: user ) do
    Ecto.Multi.new()
    |> Ecto.Multi.update( :block, Blocks.update_block(block, attrs) )
    |> Ecto.Multi.merge( fn %{block: block} ->
      tags = if block.tags == nil do [] else block.tags |> String.split end

      # Returns logs that are currently set to this block and logs that will be set
      list_logs_affected_by( block, tags, for: user )
      |> Enum.map( fn log ->
        op_name = String.to_atom("log_#{log.id}")
        log_changeset = regenerate_blocks_for_log( log, for: user )

        Ecto.Multi.new
        |> Ecto.Multi.update( op_name, log_changeset )
      end)
      |> Enum.reduce( Ecto.Multi.new(), &Ecto.Multi.append/2 )
    end)
    |> Repo.transaction
  end

Is this the kind of thing that you meant?

1 Like

Kinda. I usually just do Enum.reduce instead of Enum.map() |> Enum.reduce() though.

1 Like