Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion lib/ecto/association.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -1312,6 +1313,7 @@ defmodule Ecto.Association.ManyToMany do
:join_keys,
:join_through,
:on_cast,
:on_join_through_conflict,
where: [],
join_where: [],
defaults: [],
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
%{
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions lib/ecto/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2193,6 +2199,7 @@ defmodule Ecto.Schema do
:on_delete,
:defaults,
:on_replace,
:on_join_through_conflict,
:unique,
:where,
:join_where,
Expand Down
44 changes: 42 additions & 2 deletions test/ecto/repo/many_to_many_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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
Expand Down Expand Up @@ -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"}

Expand Down
Loading