Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions lib/ecto/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
greg-rychlewski marked this conversation as resolved.

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.

Expand Down
24 changes: 24 additions & 0 deletions test/ecto/changeset_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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})

Expand Down
Loading