diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 77c491786a..f79066955f 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1214,6 +1214,109 @@ defmodule Ecto.Changeset do cast_relation(:assoc, changeset, name, opts) end + @doc """ + Reorders the changes for a given association. + + This function should be used when wanting to re-order the list of changes + for an association with cardinality `:many` before writing to the database. + The 2-arity version sorts the changes in a way that is safe for use with + unique constraints. + + For example, if you have a unique constraint on the field `:name` and your list + of changes might introduce conflicts, you can use this to sort changes by deletes + first, then updates and then inserts. The `:on_replace` behaviour will be + handled automatically. + + Using this function is preferable to relying on deferred constraints because the + resulting error cannot be mapped back into the correct changeset and your transaction + will simply raise. + + Care must be taken when using this in conjunction with the `:sort_param` option + in `cast_assoc/3`. They both change the internal ordering of the association so you + must isolate the effects of this function to only the database operation. + + See `reorder_assoc/3` if you would like to use a custom sorting function. + + ## Example + iex> # assume `:comments` association has `on_replace: delete` + iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}, %Comment{id: 2, body: "bye"}]} + ...> |> change() + ...> |> put_assoc(:comments, [%Comment{id: 3, body: ""}, %Comment{id: 2, body: "hello"}]) + ...> |> reorder_assoc(:comments, sort_fn) + iex> cs.changes.comments + [%Ecto.Changeset{data: %Comment{id: 1}}, %Ecto.Changeset{data: %Comment{id: 2}}, %Ecto.Changeset{data: %Comment{id: 3}}] + """ + @spec reorder_assoc(t, atom()) :: t + def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do + %{types: types, changes: changes} = changeset + refl = relation!(:reorder, :assoc, name, Map.get(types, name)) + reorder_assoc(changeset, name, changes, &unique_safe_sort(refl, &1, &2)) + end + + @doc """ + Reorders the changes for a given association using a custom sorting function. + + Behaviour is similar to `reorder_assoc/2` except it allows the user to define + their own sorting function. It must be of arity 2 where the two arguments are + the changesets to be compared. You must return `true` if the first changeset + precedes or is in the same place as the second changeset and `false` otherwise. + + ## Example + + iex> sort_fn = cs1, _cs2 -> + ...> # ensure inserts come first + ...> case cs1.action do + ...> :insert -> true + ...> _ -> false + ...> end + ...> end + iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}]} + ...> |> change() + ...> |> put_assoc(:comments, [%Comment{id: 2, body: "hello"}, %Comment{id: 1, body: ""}]) + ...> |> reorder_assoc(:comments, sort_fn) + iex> cs.changes.comments + [%Ecto.Changeset{data: %Comment{id: 1}}, %Ecto.Changeset{data: %Comment{id: 2}}] + """ + @spec reorder_assoc(t, atom(), (t, t -> boolean())) :: t + def reorder_assoc(%Changeset{} = changeset, name, sort_fn) + when is_atom(name) and is_function(sort_fn, 2) do + %{types: types, changes: changes} = changeset + _ = relation!(:reorder, :assoc, name, Map.get(types, name)) + reorder_assoc(changeset, name, changes, sort_fn) + end + + defp reorder_assoc(changeset, name, changes, sort_fn) do + assoc_changes = + case changes do + %{^name => changes} when is_list(changes) -> + changes + + _ -> + raise ArgumentError, + "`reorder_assoc/3` requires an association with `:many` cardinality and a list of associated changes" + end + + sorted_assoc_changes = Enum.sort(assoc_changes, &sort_fn.(&1, &2)) + updated_changes = Map.put(changeset.changes, name, sorted_assoc_changes) + %{changeset | changes: updated_changes} + end + + defp unique_safe_sort(refl, changeset1, changeset2) do + action_sort_rank(changeset1.action, refl) <= action_sort_rank(changeset2.action, refl) + end + + defp action_sort_rank(action, refl) do + case action do + :delete -> 0 + :replace when refl.on_replace == :delete -> 0 + :update -> 1 + :replace when refl.on_replace == :nilify -> 1 + :insert -> 2 + # For things like `:ignore` we lump them at the end + _ -> 3 + end + end + @doc """ Casts the given embed with the changeset parameters. diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index af73a5ea85..3f1b7910b6 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -948,6 +948,30 @@ defmodule Ecto.ChangesetTest do assert get_assoc(belongs_to_changeset, :post, :struct) == nil end + test "reorder_assoc/2 sorts actions (delete then update then insert)" do + cs = + %Post{comments: [%Comment{id: 1, post_id: 1}, %Comment{id: 2, post_id: 1}]} + |> change() + |> put_assoc(:comments, [%Comment{id: 3, post_id: 2}, %Comment{id: 2, post_id: 2}]) + + ordered_cs = reorder_assoc(cs, :comments) + assert Enum.map(cs.changes.comments, & &1.action) == [:replace, :insert, :update] + assert Enum.map(ordered_cs.changes.comments, & &1.action) == [:replace, :update, :insert] + end + + test "reorder_assoc/3 accepts custom sort" do + cs = + %Post{comments: [%Comment{id: 2, post_id: 1}]} + |> change() + |> put_assoc(:comments, [%Comment{id: 2, post_id: 2}, %Comment{id: 3, post_id: 2}]) + + sort_fn = fn cs1, _cs2 -> cs1.action == :insert end + ordered_cs = reorder_assoc(cs, :comments, sort_fn) + + assert Enum.map(cs.changes.comments, & &1.action) == [:update, :insert] + assert Enum.map(ordered_cs.changes.comments, & &1.action) == [:insert, :update] + end + test "fetch_change/2" do changeset = changeset(%{"title" => "foo", "body" => nil, "upvotes" => nil})