Ecto.Multi.to_list() result form

Using Ecto Multi, and the to_list() method, I am trying to assert the form of the Multi before committing the transaction. The example from the docs (https://hexdocs.pm/ecto/Ecto.Multi.html#module-example) made me expect a form as follows:

          multi_result = Ecto.Multi.new() |> Domain.Helpers.DeviceHistoryRecord.build_multi(normalized_data)
          assert [
            {:registry, {:run, result_registry, []}},
            {:insert_registry, {:insert_or_update, _registry_changeset, []}},
            {:update_registry_name, {:update, registry_changeset, []}},
            {:item, {:run, result_item, []}},
            {:insert_item, {:insert_or_update, item_changeset, []}},
            {:manufacturer, {:run, result_manufacturer, []}},
            {:insert_manufacturer, {:insert_or_update, manufacturer_changeset, []}},
            {:dhr, {:insert_or_update, dhr_changeset, []}}
          ] = Ecto.Multi.to_list(multi_result)

Instead the result is a set of :run operations, with a function reference, rather than the relevant changesets to check.

pry(6)> multi_result.operations                      
[
  dhr: {:run, #Function<6.131623037/2 in Ecto.Multi.operation_fun/3>},
  insert_manufacturer: {:run,
   #Function<6.131623037/2 in Ecto.Multi.operation_fun/3>},
  manufacturer: {:run,
   #Function<5.99861518/2 in Domain.Helpers.DeviceHistoryRecord.build_multi/2>},
  insert_item: {:run, #Function<6.131623037/2 in Ecto.Multi.operation_fun/3>},
  item: {:run,
   #Function<3.99861518/2 in Domain.Helpers.DeviceHistoryRecord.build_multi/2>},
  update_registry_name: {:run,
   #Function<6.131623037/2 in Ecto.Multi.operation_fun/3>},
  insert_registry: {:run,
   #Function<6.131623037/2 in Ecto.Multi.operation_fun/3>},
  registry: {:run,
   #Function<0.99861518/2 in Domain.Helpers.DeviceHistoryRecord.build_multi/2>}
]

Here is the function I use to compose the Multi transaction:

  def build_multi(ecto_multi, normalized_data) do
    ecto_multi
    |> Ecto.Multi.run(:registry, fn _repo, _args ->
      case Repo.get_by(Registry, sheet_id: normalized_data[:registry]) do
        nil -> {:ok, nil}
        result -> {:ok, result}
      end
    end)
    |> Ecto.Multi.insert_or_update(:insert_registry, fn %{registry: registry} ->
      case registry do
        nil -> Registry.changeset(%Registry{sheet_id: normalized_data[:registry]})
        _ -> Ecto.Changeset.change(registry)
      end
    end)
    |> Ecto.Multi.update(:update_registry_name, fn %{insert_registry: registry} ->
      Domain.Helpers.Registry.update_registry_name_changeset(registry)
    end)
    |> Ecto.Multi.run(:item, fn _repo, _args ->
      case Repo.get_by(Item, item_id: normalized_data[:item]) do
        nil -> {:ok, nil}
        result -> {:ok, result}
      end
    end)
    |> Ecto.Multi.insert_or_update(:insert_item, fn %{item: item} ->
      case item do
        nil -> Item.changeset(%Item{item_id: normalized_data[:item]})
        _ -> Ecto.Changeset.change(item)
      end
    end)
    |> Ecto.Multi.run(:manufacturer, fn _repo, _args ->
      case Repo.get_by(Manufacturer, name: normalized_data[:manufacturer]) do
        nil -> {:ok, nil}
        result -> {:ok, result}
      end
    end)
    |> Ecto.Multi.insert_or_update(:insert_manufacturer, fn %{manufacturer: manufacturer} ->
      case manufacturer do
        nil -> Manufacturer.changeset(%Manufacturer{name: normalized_data[:manufacturer]})
        _ -> Ecto.Changeset.change(manufacturer)
      end
    end)
    |> Ecto.Multi.insert_or_update(:dhr, fn %{
                                              insert_registry: registry,
                                              insert_item: item,
                                              insert_manufacturer: manufacturer
                                            } ->

      lot_number = normalized_data[:lot_number]
      schema = (Repo.get_by(DeviceHistoryRecord, %{registry_id: registry.id, lot_number: lot_number}) || %DeviceHistoryRecord{item_id: item.id, lot_number: lot_number})
      |> Repo.preload(:components)
      |> Repo.preload(:equipment)
      |> Ecto.Changeset.change()

      normalized_data = %{
        normalized_data
        | registry_id: registry.id,
          item_id: item.id,
          manufacturer_id: manufacturer.id
      }

      DeviceHistoryRecord.embed_confirmations(schema, normalized_data)
      |> DeviceHistoryRecord.embed_failures(normalized_data)
      |> DeviceHistoryRecord.embed_revision_metadata(normalized_data)
      # |> DeviceHistoryRecord.embed_metadata(normalized_data)
      |> DeviceHistoryRecord.set_conditions(normalized_data)
      |> DeviceHistoryRecord.changeset(normalized_data)
    end)
  end

I’d love some feedback on ways I could simplify or make the above multi transaction more robust, or something I am missing re. the to_list() return value. I’d like to give feedback to the user of the system about any issues before committing the valid transactions.

1 Like

The docs example built the Multi with changesets, so the to_list output contained changesets. Your example passed function references, so the output contained function references.

The functions in this multi depend on the results of previous operations, so there’s not much validation you can do with them without running them.

One benefit that Multi brings is terminating the chain when one of the steps returns {:error, value}, but this code doesn’t do that in many of the intermediate steps. Consider rewriting it as an explicit sequence of operations in a transaction instead (apologies for any bugs, I can’t run this obviously):

def do_the_thing(normalized_data) do
  Repo.transaction(fn ->
    case really_do_the_thing(normalized_data) do
      {:ok, result} -> {:ok, result}
      {:error, value} -> Repo.rollback(value)
    end
  end)
end

defp really_do_the_thing(normalized_data) do
  with {:ok, registry} <- build_registry(normalized_data[:registry]),
    {:ok, _} <- update_registry_name(registry),
    {:ok, item} <- insert_item(normalized_data[:item]),
    {:ok, manufacturer} <- insert_manufacturer(normalized_data[:manufacturer]),
  do
    normalized_data = %{
      normalized_data
      | registry_id: registry.id,
        item_id: item.id,
        manufacturer_id: manufacturer.id
    }

    normalized_data()
    |> build_device_history_record_changeset()
    |> DeviceHistoryRecord.embed_confirmations(normalized_data)
    |> DeviceHistoryRecord.embed_failures(normalized_data)
    |> DeviceHistoryRecord.embed_revision_metadata(normalized_data)
    # |> DeviceHistoryRecord.embed_metadata(normalized_data)
    |> DeviceHistoryRecord.set_conditions(normalized_data)
    |> DeviceHistoryRecord.changeset(normalized_data)
    |> Repo.insert_or_update()
  end
end

defp build_registry(sheet_id) do
  registry_changeset =
    case Repo.get_by(Registry, sheet_id: sheet_id) do
      nil -> Registry.changeset(%Registry{sheet_id: sheet_id})
      registry -> Ecto.Changeset.change(registry)
    end

  Repo.insert_or_update(registry_changeset)
end

defp update_registry_name(registry) do
  registry
  |> Domain.Helpers.Registry.update_registry_name_changeset()
  |> Repo.update()
end

defp insert_item(item_id) do
  item_changeset =
    case Repo.get_by(Item, item_id: item_id) do
      nil -> Item.changeset(%Item{item_id: item_id})
      item -> Ecto.Changeset.change(item)
    end

  Repo.insert_or_update(item_changeset)
end

defp insert_manufacturer(name) do
  manufacturer_changeset =
    case Repo.get_by(Manufacturer, name: name) do
      nil -> Manufacturer.changeset(%Manufacturer{name: name})
      manufacturer -> Ecto.Changeset.change(manufacturer)
    end

    Repo.insert_or_update(manufacturer_changeset)
end

defp build_device_history_record_changeset(normalized_data) do
  normalized_data
  |> build_device_history_record()
  |> Repo.preload([:components, :equipment])
  |> Ecto.Changeset.change()
end

defp build_device_history_record(%{registry_id: registry_id, lot_number: lot_number, item_id: item_id}) do
  case Repo.get_by(DeviceHistoryRecord, registry_id: registry_id, lot_number: lot_number) do
    nil -> %DeviceHistoryRecord{item_id: item_id, lot_number: lot_number}
    record -> record
  end
end

NOTE: this does have a different behavior for errors - Ecto.Multi provides a more-detailed error tuple.

I don’t fully understand some of what the code above is doing - retrieving a record from the repo, wrapping it into a changeset, and then passing it to Repo.insert_or_update will no-op since there are no changes; was the code sample shortened from the original?

2 Likes

Yes, you’re correct there. I think the more important point is that they depend on previous results. All the functions return changesets, so I thought those might be available.

I’m assuming that’s exactly what Multi does when trying to perform the transaction. Is there maybe a way I can run the transaction, to get the potential validation errors, and then not commit the transaction/roll it back? I understand that the chain depends on previous results, but if a chain of results fails on the last operation, the validation errors are returned, and the transaction is rolled back.

I was under the impression that only the run operations required an {:ok, value} / {:error, reason} result - which they do, and the update operations just require a changeset to be returned.

In the case of the insert_or_update operation the docs specify the function must return a changeset, but I don’t remember seeing a way to simply continue without applying a changeset, so I just returned a changeset with no actual changes in some cases.

Thanks for all the options and feedback! :slight_smile: I think your last example will work best, assuming there isn’t a way to try run a Multi transaction and then roll it back, to get those validation errors. Basically, I’m trying to provide a way for a user to see whether the data they’re entering will result in a valid transaction, before actually committing it.

Multi is doing what you are looking for, yes: if even one action fails, the entire thing is rolled back. You have nothing to worry about on that front.