From 4ad84917324d8ee1640884b51fb67f98a6a064c4 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Wed, 13 May 2026 13:31:46 -0400 Subject: [PATCH 1/2] add on_join_table_conflict to repo.insert --- lib/ecto/association.ex | 17 ++++++++++++- lib/ecto/repo.ex | 6 +++++ lib/ecto/repo/schema.ex | 2 +- test/ecto/repo/many_to_many_test.exs | 37 +++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/lib/ecto/association.ex b/lib/ecto/association.ex index 01d9ecc893..7a5b09166e 100644 --- a/lib/ecto/association.ex +++ b/lib/ecto/association.ex @@ -1554,8 +1554,9 @@ defmodule Ecto.Association.ManyToMany do owner_value = dump!(:insert, join_through, owner, owner_key, adapter) related_value = dump!(:insert, join_through, related, related_key, adapter) data = %{join_owner_key => owner_value, join_related_key => related_value} + join_table_opts = put_join_table_on_conflict!(opts) - case insert_join(join_through, refl, parent_changeset, data, opts) do + case insert_join(join_through, refl, parent_changeset, data, join_table_opts) do {:error, join_changeset} -> {:error, %{ @@ -1592,6 +1593,20 @@ defmodule Ecto.Association.ManyToMany do "an atom (representing a schema) or a string (representing a table)" end + defp put_join_table_on_conflict!(opts) do + case Keyword.fetch(opts, :on_join_table_conflict) do + {:ok, on_conflict} when on_conflict in [:raise, :nothing] -> + Keyword.put(opts, :on_conflict, on_conflict) + + :error -> + opts + + {:ok, other} -> + raise ArgumentError, + "expected `:on_join_table_conflict` to be one of `:raise` or `:nothing`, got: `#{inspect(other)}`" + end + end + defp insert_join?(%{action: :insert}, _, _field, _related_key), do: true defp insert_join?(_, %{action: :insert}, _field, _related_key), do: true diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex index c99d5fabca..8803049c04 100644 --- a/lib/ecto/repo.ex +++ b/lib/ecto/repo.ex @@ -1905,6 +1905,12 @@ defmodule Ecto.Repo do are not updated in order to enable optimizations such as HOT updates in PostgreSQL. Defaults to `true`. + * `:on_join_table_conflict` - If a many-to-many association is part of the insert, + Ecto will automatically try to create the corresponding entry in the association's + `:join_through` table. This option allows you to configure the conflict resolution + behaviour when the record already exists. The allowed values are `:raise` (the default) + or `:nothing`. + * `:stale_error_field` - The field where stale errors will be added in the returning changeset. This option can be used to avoid raising `Ecto.StaleEntryError`. diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index 0ac755b928..9e89113251 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -1163,7 +1163,7 @@ defmodule Ecto.Repo.Schema do defp assoc_opts([], _opts), do: [] defp assoc_opts(_assocs, opts) do - Keyword.take(opts, [:timeout, :log, :telemetry_event, :prefix, :allow_stale]) + Keyword.take(opts, [:timeout, :log, :telemetry_event, :prefix, :allow_stale, :on_join_table_conflict]) end defp process_parents(changeset, user_changeset, assocs, reset_assocs, adapter, opts) do diff --git a/test/ecto/repo/many_to_many_test.exs b/test/ecto/repo/many_to_many_test.exs index 8dacd67a25..dfdbc7b40b 100644 --- a/test/ecto/repo/many_to_many_test.exs +++ b/test/ecto/repo/many_to_many_test.exs @@ -107,10 +107,45 @@ defmodule Ecto.Repo.ManyToManyTest do assert assoc.inserted_at assert_received {:insert, _} - assert_received {:insert_all, %{source: "schemas_assocs"}, + assert_received {:insert_all, %{source: "schemas_assocs", on_conflict: {:raise, [], []}}, + [[my_assoc_id: 1, my_schema_id: 1]]} + end + + test "handles assocs on insert with on_join_table_conflict" do + sample = %MyAssoc{x: "xyz"} + + changeset = + %MySchema{} + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:assocs, [sample]) + + schema = TestRepo.insert!(changeset, on_join_table_conflict: :nothing) + [assoc] = schema.assocs + assert assoc.id + assert assoc.x == "xyz" + assert assoc.inserted_at + assert_received {:insert, _} + + assert_received {:insert_all, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}}, [[my_assoc_id: 1, my_schema_id: 1]]} end + test "on_join_table_conflict only accepts :raise or :nothing" do + sample = %MyAssoc{x: "xyz"} + + changeset = + %MySchema{} + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:assocs, [sample]) + + msg = + "expected `:on_join_table_conflict` to be one of `:raise` or `:nothing`, got: `:replace_all`" + + assert_raise ArgumentError, msg, fn -> + TestRepo.insert!(changeset, on_join_table_conflict: :replace_all) + end + end + test "handles assocs on insert preserving parent schema prefix" do sample = %MyAssoc{x: "xyz"} From 91a45bfa30b29646e9364e3ba1d11d7ae9ff02b1 Mon Sep 17 00:00:00 2001 From: Greg Rychlewski Date: Wed, 13 May 2026 17:57:22 -0400 Subject: [PATCH 2/2] move to reflection --- lib/ecto/association.ex | 26 +++++++++++---------- lib/ecto/repo.ex | 6 ----- lib/ecto/repo/schema.ex | 2 +- lib/ecto/schema.ex | 7 ++++++ test/ecto/repo/many_to_many_test.exs | 35 ++++++++++++++++------------ 5 files changed, 42 insertions(+), 34 deletions(-) diff --git a/lib/ecto/association.ex b/lib/ecto/association.ex index 7a5b09166e..2f961dccd8 100644 --- a/lib/ecto/association.ex +++ b/lib/ecto/association.ex @@ -1300,6 +1300,7 @@ defmodule Ecto.Association.ManyToMany do @behaviour Ecto.Association @on_delete_opts [:nothing, :delete_all] @on_replace_opts [:raise, :mark_as_invalid, :delete] + @on_join_through_conflict_opts [:raise, :nothing] defstruct [ :field, @@ -1312,6 +1313,7 @@ defmodule Ecto.Association.ManyToMany do :join_keys, :join_through, :on_cast, + :on_join_through_conflict, where: [], join_where: [], defaults: [], @@ -1355,7 +1357,9 @@ defmodule Ecto.Association.ManyToMany do join_keys = opts[:join_keys] join_through = opts[:join_through] + on_join_through_conflict = Keyword.get(opts, :on_join_through_conflict, :raise) validate_join_through(name, join_through) + validate_on_join_through_conflict(name, on_join_through_conflict) {owner_key, join_keys} = case join_keys do @@ -1431,6 +1435,7 @@ defmodule Ecto.Association.ManyToMany do queryable: queryable, on_delete: on_delete, on_replace: on_replace, + on_join_through_conflict: on_join_through_conflict, unique: Keyword.get(opts, :unique, false), defaults: defaults, where: where, @@ -1554,7 +1559,7 @@ defmodule Ecto.Association.ManyToMany do owner_value = dump!(:insert, join_through, owner, owner_key, adapter) related_value = dump!(:insert, join_through, related, related_key, adapter) data = %{join_owner_key => owner_value, join_related_key => related_value} - join_table_opts = put_join_table_on_conflict!(opts) + join_table_opts = Keyword.put(opts, :on_conflict, refl.on_join_through_conflict) case insert_join(join_through, refl, parent_changeset, data, join_table_opts) do {:error, join_changeset} -> @@ -1593,18 +1598,15 @@ defmodule Ecto.Association.ManyToMany do "an atom (representing a schema) or a string (representing a table)" end - defp put_join_table_on_conflict!(opts) do - case Keyword.fetch(opts, :on_join_table_conflict) do - {:ok, on_conflict} when on_conflict in [:raise, :nothing] -> - Keyword.put(opts, :on_conflict, on_conflict) - - :error -> - opts + defp validate_on_join_through_conflict(_name, on_join_through_conflict) + when on_join_through_conflict in @on_join_through_conflict_opts do + :ok + end - {:ok, other} -> - raise ArgumentError, - "expected `:on_join_table_conflict` to be one of `:raise` or `:nothing`, got: `#{inspect(other)}`" - end + defp validate_on_join_through_conflict(name, other) do + raise ArgumentError, + "expected `:on_join_through_conflict` to be one of `:raise` or `:nothing` in " <> + "many-to-many association #{inspect(name)}, got: `#{inspect(other)}`" end defp insert_join?(%{action: :insert}, _, _field, _related_key), do: true diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex index 8803049c04..c99d5fabca 100644 --- a/lib/ecto/repo.ex +++ b/lib/ecto/repo.ex @@ -1905,12 +1905,6 @@ defmodule Ecto.Repo do are not updated in order to enable optimizations such as HOT updates in PostgreSQL. Defaults to `true`. - * `:on_join_table_conflict` - If a many-to-many association is part of the insert, - Ecto will automatically try to create the corresponding entry in the association's - `:join_through` table. This option allows you to configure the conflict resolution - behaviour when the record already exists. The allowed values are `:raise` (the default) - or `:nothing`. - * `:stale_error_field` - The field where stale errors will be added in the returning changeset. This option can be used to avoid raising `Ecto.StaleEntryError`. diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index 9e89113251..0ac755b928 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -1163,7 +1163,7 @@ defmodule Ecto.Repo.Schema do defp assoc_opts([], _opts), do: [] defp assoc_opts(_assocs, opts) do - Keyword.take(opts, [:timeout, :log, :telemetry_event, :prefix, :allow_stale, :on_join_table_conflict]) + Keyword.take(opts, [:timeout, :log, :telemetry_event, :prefix, :allow_stale]) end defp process_parents(changeset, user_changeset, assocs, reset_assocs, adapter, opts) do diff --git a/lib/ecto/schema.ex b/lib/ecto/schema.ex index 53ab8fcf3c..4ccff13cc3 100644 --- a/lib/ecto/schema.ex +++ b/lib/ecto/schema.ex @@ -1322,6 +1322,12 @@ defmodule Ecto.Schema do associated records. See `Ecto.Changeset`'s section on related data for more info. + * `:on_join_through_conflict` - If the association is part of an insert, Ecto + will automatically try to create the appropriate entry in the `:join_through` + table. This option allows you to configure the conflict resolution behaviour + when the record already exists. The allowed values are `:raise` or `:nothing`. + Defaults to `:raise` + * `:defaults` - Default values to use when building the association. It may be a keyword list of options that override the association schema or an `atom`/`{module, function, args}` that receives the association struct @@ -2193,6 +2199,7 @@ defmodule Ecto.Schema do :on_delete, :defaults, :on_replace, + :on_join_through_conflict, :unique, :where, :join_where, diff --git a/test/ecto/repo/many_to_many_test.exs b/test/ecto/repo/many_to_many_test.exs index dfdbc7b40b..fcd24ecc55 100644 --- a/test/ecto/repo/many_to_many_test.exs +++ b/test/ecto/repo/many_to_many_test.exs @@ -52,13 +52,20 @@ defmodule Ecto.Repo.ManyToManyTest do schema "my_schema" do field :x, :string field :y, :binary - many_to_many :assocs, MyAssoc, join_through: "schemas_assocs", on_replace: :delete + + many_to_many :assocs, MyAssoc, + join_through: "schemas_assocs", + on_replace: :delete many_to_many :where_assocs, MyAssoc, join_through: "schemas_assocs", join_where: [public: true], on_replace: :delete + many_to_many :on_conflict_assocs, MyAssoc, + join_through: "schemas_assocs", + on_join_through_conflict: :nothing + many_to_many :schema_assocs, MyAssoc, join_through: MySchemaAssoc, join_defaults: [public: true] @@ -70,6 +77,10 @@ defmodule Ecto.Repo.ManyToManyTest do many_to_many :mfa_schema_assocs, MyAssoc, join_through: MySchemaAssoc, join_defaults: {__MODULE__, :send_to_self, [:extra]} + + many_to_many :on_conflict_schema_assocs, MyAssoc, + join_through: MySchemaAssoc, + on_join_through_conflict: :nothing end def send_to_self(struct, owner, extra) do @@ -111,39 +122,33 @@ defmodule Ecto.Repo.ManyToManyTest do [[my_assoc_id: 1, my_schema_id: 1]]} end - test "handles assocs on insert with on_join_table_conflict" do + test "handles assocs on insert with on_join_through_conflict and binary join_through" do sample = %MyAssoc{x: "xyz"} changeset = %MySchema{} |> Ecto.Changeset.change() - |> Ecto.Changeset.put_assoc(:assocs, [sample]) + |> Ecto.Changeset.put_assoc(:on_conflict_assocs, [sample]) - schema = TestRepo.insert!(changeset, on_join_table_conflict: :nothing) - [assoc] = schema.assocs - assert assoc.id - assert assoc.x == "xyz" - assert assoc.inserted_at + TestRepo.insert!(changeset) assert_received {:insert, _} assert_received {:insert_all, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}}, [[my_assoc_id: 1, my_schema_id: 1]]} end - test "on_join_table_conflict only accepts :raise or :nothing" do + test "handles assocs on insert with on_join_through_conflict and schema join_through" do sample = %MyAssoc{x: "xyz"} changeset = %MySchema{} |> Ecto.Changeset.change() - |> Ecto.Changeset.put_assoc(:assocs, [sample]) + |> Ecto.Changeset.put_assoc(:on_conflict_schema_assocs, [sample]) - msg = - "expected `:on_join_table_conflict` to be one of `:raise` or `:nothing`, got: `:replace_all`" + TestRepo.insert!(changeset) + assert_received {:insert, _} - assert_raise ArgumentError, msg, fn -> - TestRepo.insert!(changeset, on_join_table_conflict: :replace_all) - end + assert_received {:insert, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}}} end test "handles assocs on insert preserving parent schema prefix" do