Skip to content

Commit d502da9

Browse files
reorder_assoc
1 parent 9fee945 commit d502da9

2 files changed

Lines changed: 103 additions & 0 deletions

File tree

lib/ecto/changeset.ex

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1214,6 +1214,85 @@ 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 of this function sorts the changes in a way that is safe
1223+
for use with 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 function to sort changes
1227+
by deletes, then updates and then inserts. The `:on_replace` behavour 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+
See `reorder_assoc/3` if you would like to use your own custom sorting function.
1235+
"""
1236+
@spec cast_assoc(t, atom()) :: t
1237+
def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do
1238+
reorder_assoc(changeset, name, &unique_safe_sort/3)
1239+
end
1240+
1241+
@doc """
1242+
Reorders the changes for a given association using a custom sorting function.
1243+
1244+
This function behaves similarly to `reorder_assoc/2` except it allows the user
1245+
to define their own sorting function. The function must be of arity 3 where the
1246+
first argument is the reflection struct of the association, such as `Ecto.Association.Has`.
1247+
The next two arguments are the changesets to be compared for sorting. You must return
1248+
a `true` if the first changeset precedes or is in the same place as the second changeset
1249+
and `false` otherwise.
1250+
"""
1251+
@spec cast_assoc(t, atom(), (term(), t, t -> boolean())) :: t
1252+
def reorder_assoc(%Changeset{} = changeset, name, sort_fn)
1253+
when is_atom(name) and is_function(sort_fn, 3) do
1254+
refl =
1255+
case changeset do
1256+
%{data: %{__struct__: schema}} ->
1257+
schema.__schema__(:association, name) ||
1258+
raise ArgumentError,
1259+
"schema #{inspect(schema)} does not have association `#{name}`"
1260+
1261+
_ ->
1262+
raise ArgumentError, "cannot reorder association without data"
1263+
end
1264+
1265+
assoc_changes =
1266+
case changeset.changes do
1267+
%{^name => changes} when is_list(changes) ->
1268+
changes
1269+
1270+
_ ->
1271+
raise ArgumentError,
1272+
"`reorder_assoc/3` requires an association with `:many` cardinality and a list of associated changes"
1273+
end
1274+
1275+
sorted_assoc_changes = Enum.sort(assoc_changes, &sort_fn.(refl, &1, &2))
1276+
updated_changes = Map.put(changeset.changes, name, sorted_assoc_changes)
1277+
%{changeset | changes: updated_changes}
1278+
end
1279+
1280+
defp unique_safe_sort(refl, changeset1, changeset2) do
1281+
action_sort_rank(changeset1.action, refl) <= action_sort_rank(changeset2.action, refl)
1282+
end
1283+
1284+
defp action_sort_rank(action, refl) do
1285+
case action do
1286+
:delete -> 0
1287+
:replace when refl.on_replace == :delete -> 0
1288+
:update -> 1
1289+
:replace when refl.on_replace == :nilify -> 1
1290+
:insert -> 2
1291+
# For things like `:ignore` we lump them at the end
1292+
_ -> 3
1293+
end
1294+
end
1295+
12171296
@doc """
12181297
Casts the given embed with the changeset parameters.
12191298

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 _refl, 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)