From d502da9d8e14f89628a0390208aa5e92ac1b486e Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Wed, 29 Apr 2026 23:41:27 -0400 Subject: [PATCH 1/8] reorder_assoc --- lib/ecto/changeset.ex | 79 ++++++++++++++++++++++++++++++++++++ test/ecto/changeset_test.exs | 24 +++++++++++ 2 files changed, 103 insertions(+) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 77c491786a..6e98014e89 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1214,6 +1214,85 @@ 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 of this function 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 function to sort changes + by deletes, then updates and then inserts. The `:on_replace` behavour 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. + + See `reorder_assoc/3` if you would like to use your own custom sorting function. + """ + @spec cast_assoc(t, atom()) :: t + def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do + reorder_assoc(changeset, name, &unique_safe_sort/3) + end + + @doc """ + Reorders the changes for a given association using a custom sorting function. + + This function behaves similarly to `reorder_assoc/2` except it allows the user + to define their own sorting function. The function must be of arity 3 where the + first argument is the reflection struct of the association, such as `Ecto.Association.Has`. + The next two arguments are the changesets to be compared for sorting. You must return + a `true` if the first changeset precedes or is in the same place as the second changeset + and `false` otherwise. + """ + @spec cast_assoc(t, atom(), (term(), t, t -> boolean())) :: t + def reorder_assoc(%Changeset{} = changeset, name, sort_fn) + when is_atom(name) and is_function(sort_fn, 3) do + refl = + case changeset do + %{data: %{__struct__: schema}} -> + schema.__schema__(:association, name) || + raise ArgumentError, + "schema #{inspect(schema)} does not have association `#{name}`" + + _ -> + raise ArgumentError, "cannot reorder association without data" + end + + assoc_changes = + case changeset.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.(refl, &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..c85eb76e9e 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 _refl, 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}) From ae8237f4a629db283d977c2b090a8c3538f183e2 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 00:00:37 -0400 Subject: [PATCH 2/8] add example to docs --- lib/ecto/changeset.ex | 19 +++++++++++++++++++ test/ecto/changeset_test.exs | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 6e98014e89..aa20e1493b 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1247,6 +1247,25 @@ defmodule Ecto.Changeset do The next two arguments are the changesets to be compared for sorting. You must return a `true` if the first changeset precedes or is in the same place as the second changeset and `false` otherwise. + + ## Example + + iex> sort_fn = refl, cs1, _cs2 -> + ...> # ensure inserts and updates come first + ...> case cs1.action do + ...> :insert -> true + ...> :update -> true + ...> :replace when refl.on_replace == :nilify -> true + ...> _ -> false + ...> end + ...> end + 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: 2, body: "hello"}, %Comment{id: 3, body: ""}]) + ...> |> reorder_assoc(:comments, sort_fn) + iex> cs.changes.comments + [%Ecto.Changeset{data: %Comment{id: 2}}, %Ecto.Changeset{data: %Comment{id: 3}}, %Ecto.Changeset{data: %Comment{id: 1}}] """ @spec cast_assoc(t, atom(), (term(), t, t -> boolean())) :: t def reorder_assoc(%Changeset{} = changeset, name, sort_fn) diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index c85eb76e9e..0bee2f3e8d 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -963,7 +963,7 @@ defmodule Ecto.ChangesetTest 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}, ]) + |> put_assoc(:comments, [%Comment{id: 2, post_id: 2}, %Comment{id: 3, post_id: 2}]) sort_fn = fn _refl, cs1, _cs2 -> cs1.action == :insert end ordered_cs = reorder_assoc(cs, :comments, sort_fn) From 5396ca69e0391fd5f410292214d5fef7e0afe17a Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 00:13:46 -0400 Subject: [PATCH 3/8] clean up docs --- lib/ecto/changeset.ex | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index aa20e1493b..f07b536a14 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1219,19 +1219,32 @@ defmodule Ecto.Changeset do 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 of this function sorts the changes in a way that is safe - for use with unique constraints. + 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 function to sort changes - by deletes, then updates and then inserts. The `:on_replace` behavour will be + of changes might introduce conflicts, you can use this to sort changes by deletes + first, then updates and then inserts. The `:on_replace` behavour 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. - See `reorder_assoc/3` if you would like to use your own custom sorting function. + 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 cast_assoc(t, atom()) :: t def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do @@ -1241,11 +1254,11 @@ defmodule Ecto.Changeset do @doc """ Reorders the changes for a given association using a custom sorting function. - This function behaves similarly to `reorder_assoc/2` except it allows the user - to define their own sorting function. The function must be of arity 3 where the - first argument is the reflection struct of the association, such as `Ecto.Association.Has`. - The next two arguments are the changesets to be compared for sorting. You must return - a `true` if the first changeset precedes or is in the same place as the second changeset + Behaviour is similar to `reorder_assoc/2` except it allows the user to define + their own sorting function. It must be of arity 3 where the first argument is + the reflection struct of the association, such as `Ecto.Association.Has`. The + next 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 From 82ec598a3440c911c1b3ec6a635dfb3dd042835e Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 07:12:17 -0400 Subject: [PATCH 4/8] Update lib/ecto/changeset.ex MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/ecto/changeset.ex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index f07b536a14..26597eb579 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1224,7 +1224,7 @@ defmodule Ecto.Changeset do 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` behavour will be + 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 From 8d0454a4d0da46897b005362ec1f72d7cf885581 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 07:30:17 -0400 Subject: [PATCH 5/8] don't expose reflection --- lib/ecto/changeset.ex | 44 ++++++++++++++++-------------------- test/ecto/changeset_test.exs | 2 +- 2 files changed, 21 insertions(+), 25 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 26597eb579..ddb090b18e 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1248,7 +1248,18 @@ defmodule Ecto.Changeset do """ @spec cast_assoc(t, atom()) :: t def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do - reorder_assoc(changeset, name, &unique_safe_sort/3) + refl = + case changeset do + %{data: %{__struct__: schema}} -> + schema.__schema__(:association, name) || + raise ArgumentError, + "schema #{inspect(schema)} does not have association `#{name}`" + + _ -> + raise ArgumentError, "cannot reorder association without data" + end + + reorder_assoc(changeset, name, &unique_safe_sort(refl, &1, &2)) end @doc """ @@ -1263,37 +1274,22 @@ defmodule Ecto.Changeset do ## Example - iex> sort_fn = refl, cs1, _cs2 -> - ...> # ensure inserts and updates come first + iex> sort_fn = cs1, _cs2 -> + ...> # ensure inserts come first ...> case cs1.action do ...> :insert -> true - ...> :update -> true - ...> :replace when refl.on_replace == :nilify -> true - ...> _ -> false ...> end ...> end - iex> # assume `:comments` association has `on_replace: delete` - iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}, %Comment{id: 2, body: "bye"}]} + iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}]} ...> |> change() - ...> |> put_assoc(:comments, [%Comment{id: 2, body: "hello"}, %Comment{id: 3, body: ""}]) + ...> |> 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: 2}}, %Ecto.Changeset{data: %Comment{id: 3}}, %Ecto.Changeset{data: %Comment{id: 1}}] + [%Ecto.Changeset{data: %Comment{id: 1}}, %Ecto.Changeset{data: %Comment{id: 2}}] """ - @spec cast_assoc(t, atom(), (term(), t, t -> boolean())) :: t + @spec cast_assoc(t, atom(), (t, t -> boolean())) :: t def reorder_assoc(%Changeset{} = changeset, name, sort_fn) - when is_atom(name) and is_function(sort_fn, 3) do - refl = - case changeset do - %{data: %{__struct__: schema}} -> - schema.__schema__(:association, name) || - raise ArgumentError, - "schema #{inspect(schema)} does not have association `#{name}`" - - _ -> - raise ArgumentError, "cannot reorder association without data" - end - + when is_atom(name) and is_function(sort_fn, 2) do assoc_changes = case changeset.changes do %{^name => changes} when is_list(changes) -> @@ -1304,7 +1300,7 @@ defmodule Ecto.Changeset do "`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.(refl, &1, &2)) + 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 diff --git a/test/ecto/changeset_test.exs b/test/ecto/changeset_test.exs index 0bee2f3e8d..3f1b7910b6 100644 --- a/test/ecto/changeset_test.exs +++ b/test/ecto/changeset_test.exs @@ -965,7 +965,7 @@ defmodule Ecto.ChangesetTest do |> change() |> put_assoc(:comments, [%Comment{id: 2, post_id: 2}, %Comment{id: 3, post_id: 2}]) - sort_fn = fn _refl, cs1, _cs2 -> cs1.action == :insert end + 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] From 3b98e007e2f1ccb8b72a60508a36077246f4205c Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 07:36:07 -0400 Subject: [PATCH 6/8] update docs --- lib/ecto/changeset.ex | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index ddb090b18e..69fa927398 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1266,11 +1266,9 @@ defmodule Ecto.Changeset do 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 3 where the first argument is - the reflection struct of the association, such as `Ecto.Association.Has`. The - next 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. + 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 From e78216c16328082a103fb91e89e45dbf66726237 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 07:36:23 -0400 Subject: [PATCH 7/8] update docs again --- lib/ecto/changeset.ex | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 69fa927398..526e001167 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1276,6 +1276,7 @@ defmodule Ecto.Changeset do ...> # ensure inserts come first ...> case cs1.action do ...> :insert -> true + ...> _ -> false ...> end ...> end iex> cs = %Post{comments: [%Comment{id: 1, body: "hello"}]} From c5a507eef1675f3fd2b84bdabbb31267577af4ac Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Thu, 30 Apr 2026 07:55:02 -0400 Subject: [PATCH 8/8] cleanup --- lib/ecto/changeset.ex | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex index 526e001167..3bf2f9a4c6 100644 --- a/lib/ecto/changeset.ex +++ b/lib/ecto/changeset.ex @@ -1248,18 +1248,9 @@ defmodule Ecto.Changeset do """ @spec cast_assoc(t, atom()) :: t def reorder_assoc(%Changeset{} = changeset, name) when is_atom(name) do - refl = - case changeset do - %{data: %{__struct__: schema}} -> - schema.__schema__(:association, name) || - raise ArgumentError, - "schema #{inspect(schema)} does not have association `#{name}`" - - _ -> - raise ArgumentError, "cannot reorder association without data" - end - - reorder_assoc(changeset, name, &unique_safe_sort(refl, &1, &2)) + %{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 """ @@ -1289,8 +1280,14 @@ defmodule Ecto.Changeset do @spec cast_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 changeset.changes do + case changes do %{^name => changes} when is_list(changes) -> changes