Skip to content

Commit fd2ec52

Browse files
Add Ecto.Changeset.reorder_assoc (#4722)
--------- Co-authored-by: José Valim <jose.valim@gmail.com>
1 parent 9fee945 commit fd2ec52

2 files changed

Lines changed: 127 additions & 0 deletions

File tree

lib/ecto/changeset.ex

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,109 @@ defmodule Ecto.Changeset do
12141214
cast_relation(:assoc, changeset, name, opts)
12151215
end
12161216

1217+
@doc """
1218+
Reorders the changes for a given association.
1219+
1220+
This function should be used when wanting to re-order the list of changes
1221+
for an association with cardinality `:many` before writing to the database.
1222+
The 2-arity version sorts the changes in a way that is safe for use with
1223+
unique constraints.
1224+
1225+
For example, if you have a unique constraint on the field `:name` and your list
1226+
of changes might introduce conflicts, you can use this to sort changes by deletes
1227+
first, then updates and then inserts. The `:on_replace` behaviour will be
1228+
handled automatically.
1229+
1230+
Using this function is preferable to relying on deferred constraints because the
1231+
resulting error cannot be mapped back into the correct changeset and your transaction
1232+
will simply raise.
1233+
1234+
Care must be taken when using this in conjunction with the `:sort_param` option
1235+
in `cast_assoc/3`. They both change the internal ordering of the association so you
1236+
must isolate the effects of this function to only the database operation.
1237+
1238+
See `reorder_assoc/3` if you would like to use a custom sorting function.
1239+
1240+
## Example
1241+
iex> # assume `:comments` association has `on_replace: delete`
1242+
iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}, %Comment{id: 2, body: "bye"}]}
1243+
...> |> change()
1244+
...> |> put_assoc(:comments, [%Comment{id: 3, body: ""}, %Comment{id: 2, body: "hello"}])
1245+
...> |> reorder_assoc(:comments, sort_fn)
1246+
iex> cs.changes.comments
1247+
[%Ecto.Changeset{data: %Comment{id: 1}}, %Ecto.Changeset{data: %Comment{id: 2}}, %Ecto.Changeset{data: %Comment{id: 3}}]
1248+
"""
1249+
@spec reorder_assoc(t, atom()) :: t
1250+
def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do
1251+
%{types: types, changes: changes} = changeset
1252+
refl = relation!(:reorder, :assoc, name, Map.get(types, name))
1253+
reorder_assoc(changeset, name, changes, &unique_safe_sort(refl, &1, &2))
1254+
end
1255+
1256+
@doc """
1257+
Reorders the changes for a given association using a custom sorting function.
1258+
1259+
Behaviour is similar to `reorder_assoc/2` except it allows the user to define
1260+
their own sorting function. It must be of arity 2 where the two arguments are
1261+
the changesets to be compared. You must return `true` if the first changeset
1262+
precedes or is in the same place as the second changeset and `false` otherwise.
1263+
1264+
## Example
1265+
1266+
iex> sort_fn = cs1, _cs2 ->
1267+
...> # ensure inserts come first
1268+
...> case cs1.action do
1269+
...> :insert -> true
1270+
...> _ -> false
1271+
...> end
1272+
...> end
1273+
iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}]}
1274+
...> |> change()
1275+
...> |> put_assoc(:comments, [%Comment{id: 2, body: "hello"}, %Comment{id: 1, body: ""}])
1276+
...> |> reorder_assoc(:comments, sort_fn)
1277+
iex> cs.changes.comments
1278+
[%Ecto.Changeset{data: %Comment{id: 1}}, %Ecto.Changeset{data: %Comment{id: 2}}]
1279+
"""
1280+
@spec reorder_assoc(t, atom(), (t, t -> boolean())) :: t
1281+
def reorder_assoc(%Changeset{} = changeset, name, sort_fn)
1282+
when is_atom(name) and is_function(sort_fn, 2) do
1283+
%{types: types, changes: changes} = changeset
1284+
_ = relation!(:reorder, :assoc, name, Map.get(types, name))
1285+
reorder_assoc(changeset, name, changes, sort_fn)
1286+
end
1287+
1288+
defp reorder_assoc(changeset, name, changes, sort_fn) do
1289+
assoc_changes =
1290+
case changes do
1291+
%{^name => changes} when is_list(changes) ->
1292+
changes
1293+
1294+
_ ->
1295+
raise ArgumentError,
1296+
"`reorder_assoc/3` requires an association with `:many` cardinality and a list of associated changes"
1297+
end
1298+
1299+
sorted_assoc_changes = Enum.sort(assoc_changes, &sort_fn.(&1, &2))
1300+
updated_changes = Map.put(changeset.changes, name, sorted_assoc_changes)
1301+
%{changeset | changes: updated_changes}
1302+
end
1303+
1304+
defp unique_safe_sort(refl, changeset1, changeset2) do
1305+
action_sort_rank(changeset1.action, refl) <= action_sort_rank(changeset2.action, refl)
1306+
end
1307+
1308+
defp action_sort_rank(action, refl) do
1309+
case action do
1310+
:delete -> 0
1311+
:replace when refl.on_replace == :delete -> 0
1312+
:update -> 1
1313+
:replace when refl.on_replace == :nilify -> 1
1314+
:insert -> 2
1315+
# For things like `:ignore` we lump them at the end
1316+
_ -> 3
1317+
end
1318+
end
1319+
12171320
@doc """
12181321
Casts the given embed with the changeset parameters.
12191322

test/ecto/changeset_test.exs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,30 @@ defmodule Ecto.ChangesetTest do
948948
assert get_assoc(belongs_to_changeset, :post, :struct) == nil
949949
end
950950

951+
test "reorder_assoc/2 sorts actions (delete then update then insert)" do
952+
cs =
953+
%Post{comments: [%Comment{id: 1, post_id: 1}, %Comment{id: 2, post_id: 1}]}
954+
|> change()
955+
|> put_assoc(:comments, [%Comment{id: 3, post_id: 2}, %Comment{id: 2, post_id: 2}])
956+
957+
ordered_cs = reorder_assoc(cs, :comments)
958+
assert Enum.map(cs.changes.comments, & &1.action) == [:replace, :insert, :update]
959+
assert Enum.map(ordered_cs.changes.comments, & &1.action) == [:replace, :update, :insert]
960+
end
961+
962+
test "reorder_assoc/3 accepts custom sort" do
963+
cs =
964+
%Post{comments: [%Comment{id: 2, post_id: 1}]}
965+
|> change()
966+
|> put_assoc(:comments, [%Comment{id: 2, post_id: 2}, %Comment{id: 3, post_id: 2}])
967+
968+
sort_fn = fn cs1, _cs2 -> cs1.action == :insert end
969+
ordered_cs = reorder_assoc(cs, :comments, sort_fn)
970+
971+
assert Enum.map(cs.changes.comments, & &1.action) == [:update, :insert]
972+
assert Enum.map(ordered_cs.changes.comments, & &1.action) == [:insert, :update]
973+
end
974+
951975
test "fetch_change/2" do
952976
changeset = changeset(%{"title" => "foo", "body" => nil, "upvotes" => nil})
953977

0 commit comments

Comments
 (0)