Bulk updates on a column with a unique constraint

This is a follow-up to: How to reorder items in a list in Ash framework - #2 by zachdaniel. I want to add a unique constraint to the position column but this would cause the position update to fail with:

{
:error,
%Ash.Error.Invalid{changeset: "#Changeset<>",  errors: [%Ash.Error.Changes.InvalidAttribute{field: :position, message: "has already been taken", private_vars: [constraint: :unique, constraint_name: "activities_unique_position_index"], value: nil, splode: Ash.Error, bread_crumbs: [], vars: [], path: [], stacktrace: #Splode.Stacktrace<>, class: :invalid}]}

Is there an equivalent for :on_conflict option for Ash.bulk_update?
Here is the current code for updating positions:

@impl true
  def change(changeset, _opts, context) do
    actor = context.actor
    organisation = context.tenant

    Ash.Changeset.before_action(changeset, fn changeset ->
      requested_position = Ash.Changeset.get_attribute(changeset, :position)
      old_position = changeset.data.position

      max_position =
        Activity
        |> Ash.Query.filter(group_id == ^Ash.Changeset.get_attribute(changeset, :group_id))
        |> Ash.count!(actor: actor, tenant: organisation)

      new_position = min(requested_position, max_position)

      with %Ash.BulkResult{status: :success} <-
             shift_down(old_position, new_position, organisation, actor),
           %Ash.BulkResult{status: :success} <-
             shift_up(old_position, new_position, organisation, actor) do
        changeset
      else
        %Ash.BulkResult{errors: errors} ->
          Ash.Changeset.add_error(changeset, errors)
      end

      changeset
    end)
  end

  defp shift_down(old_position, new_position, organisation, actor) do
    Activity
    |> Ash.Query.filter(position > ^old_position and position <= ^new_position)
    |> Ash.bulk_update!(:decrement_position, %{},
      strategy: :stream,
      actor: actor,
      tenant: organisation
    )
  end

  defp shift_up(old_position, new_position, organisation, actor) do
    Activity
    |> Ash.Query.filter(position < ^old_position and position >= ^new_position)
    |> Ash.bulk_update!(:increment_position, %{},
      strategy: :stream,
      actor: actor,
      tenant: organisation
    )
  end

## Inside resource:
update :update_position do
      accept [:position]

      change Omedis.Accounts.Changes.UpdateActivityPositions

      require_atomic? false
    end

    update :decrement_position do
      accept []

      change atomic_update(:position, expr(position - 1))
    end

    update :increment_position do
      accept []

      change atomic_update(:position, expr(position + 1))
    end
1 Like

on_conflict is an SQL feature, and there is no equivalent for updates. I think, that if you want to add the unique constraint, you need to change your logic.

In a before_action hook, you need to move the resource that is currently occupying the requested position to the max position, then save the current resource to the requested position (that’s just returning the changeset and letting Ash do its thing), and then in an after_action hook move the resource from the before_action into the old position of the moved resource.

You can use the changeset context to save data from the before_action hook and make it available in the after_action hook.