diff --git a/lib/ecto/association.ex b/lib/ecto/association.ex index 01d9ecc893..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,8 +1559,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 = Keyword.put(opts, :on_conflict, refl.on_join_through_conflict) - 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 +1598,17 @@ defmodule Ecto.Association.ManyToMany do "an atom (representing a schema) or a string (representing a table)" end + 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 + + 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 defp insert_join?(_, %{action: :insert}, _field, _related_key), do: true 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 8dacd67a25..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 @@ -107,10 +118,39 @@ 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_through_conflict and binary join_through" do + sample = %MyAssoc{x: "xyz"} + + changeset = + %MySchema{} + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:on_conflict_assocs, [sample]) + + 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 "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(:on_conflict_schema_assocs, [sample]) + + TestRepo.insert!(changeset) + assert_received {:insert, _} + + assert_received {:insert, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}}} + end + test "handles assocs on insert preserving parent schema prefix" do sample = %MyAssoc{x: "xyz"}