@@ -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
0 commit comments