Skip to content

Commit 10b3372

Browse files
Add on_join_through_conflict option for ManyToMany Associations (#4726)
1 parent 270aceb commit 10b3372

3 files changed

Lines changed: 67 additions & 3 deletions

File tree

lib/ecto/association.ex

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,7 @@ defmodule Ecto.Association.ManyToMany do
13001300
@behaviour Ecto.Association
13011301
@on_delete_opts [:nothing, :delete_all]
13021302
@on_replace_opts [:raise, :mark_as_invalid, :delete]
1303+
@on_join_through_conflict_opts [:raise, :nothing]
13031304

13041305
defstruct [
13051306
:field,
@@ -1312,6 +1313,7 @@ defmodule Ecto.Association.ManyToMany do
13121313
:join_keys,
13131314
:join_through,
13141315
:on_cast,
1316+
:on_join_through_conflict,
13151317
where: [],
13161318
join_where: [],
13171319
defaults: [],
@@ -1355,7 +1357,9 @@ defmodule Ecto.Association.ManyToMany do
13551357

13561358
join_keys = opts[:join_keys]
13571359
join_through = opts[:join_through]
1360+
on_join_through_conflict = Keyword.get(opts, :on_join_through_conflict, :raise)
13581361
validate_join_through(name, join_through)
1362+
validate_on_join_through_conflict(name, on_join_through_conflict)
13591363

13601364
{owner_key, join_keys} =
13611365
case join_keys do
@@ -1431,6 +1435,7 @@ defmodule Ecto.Association.ManyToMany do
14311435
queryable: queryable,
14321436
on_delete: on_delete,
14331437
on_replace: on_replace,
1438+
on_join_through_conflict: on_join_through_conflict,
14341439
unique: Keyword.get(opts, :unique, false),
14351440
defaults: defaults,
14361441
where: where,
@@ -1554,8 +1559,9 @@ defmodule Ecto.Association.ManyToMany do
15541559
owner_value = dump!(:insert, join_through, owner, owner_key, adapter)
15551560
related_value = dump!(:insert, join_through, related, related_key, adapter)
15561561
data = %{join_owner_key => owner_value, join_related_key => related_value}
1562+
join_table_opts = Keyword.put(opts, :on_conflict, refl.on_join_through_conflict)
15571563

1558-
case insert_join(join_through, refl, parent_changeset, data, opts) do
1564+
case insert_join(join_through, refl, parent_changeset, data, join_table_opts) do
15591565
{:error, join_changeset} ->
15601566
{:error,
15611567
%{
@@ -1592,6 +1598,17 @@ defmodule Ecto.Association.ManyToMany do
15921598
"an atom (representing a schema) or a string (representing a table)"
15931599
end
15941600

1601+
defp validate_on_join_through_conflict(_name, on_join_through_conflict)
1602+
when on_join_through_conflict in @on_join_through_conflict_opts do
1603+
:ok
1604+
end
1605+
1606+
defp validate_on_join_through_conflict(name, other) do
1607+
raise ArgumentError,
1608+
"expected `:on_join_through_conflict` to be one of `:raise` or `:nothing` in " <>
1609+
"many-to-many association #{inspect(name)}, got: `#{inspect(other)}`"
1610+
end
1611+
15951612
defp insert_join?(%{action: :insert}, _, _field, _related_key), do: true
15961613
defp insert_join?(_, %{action: :insert}, _field, _related_key), do: true
15971614

lib/ecto/schema.ex

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1322,6 +1322,12 @@ defmodule Ecto.Schema do
13221322
associated records. See `Ecto.Changeset`'s section on related data
13231323
for more info.
13241324
1325+
* `:on_join_through_conflict` - If the association is part of an insert, Ecto
1326+
will automatically try to create the appropriate entry in the `:join_through`
1327+
table. This option allows you to configure the conflict resolution behaviour
1328+
when the record already exists. The allowed values are `:raise` or `:nothing`.
1329+
Defaults to `:raise`
1330+
13251331
* `:defaults` - Default values to use when building the association.
13261332
It may be a keyword list of options that override the association schema
13271333
or an `atom`/`{module, function, args}` that receives the association struct
@@ -2193,6 +2199,7 @@ defmodule Ecto.Schema do
21932199
:on_delete,
21942200
:defaults,
21952201
:on_replace,
2202+
:on_join_through_conflict,
21962203
:unique,
21972204
:where,
21982205
:join_where,

test/ecto/repo/many_to_many_test.exs

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,20 @@ defmodule Ecto.Repo.ManyToManyTest do
5252
schema "my_schema" do
5353
field :x, :string
5454
field :y, :binary
55-
many_to_many :assocs, MyAssoc, join_through: "schemas_assocs", on_replace: :delete
55+
56+
many_to_many :assocs, MyAssoc,
57+
join_through: "schemas_assocs",
58+
on_replace: :delete
5659

5760
many_to_many :where_assocs, MyAssoc,
5861
join_through: "schemas_assocs",
5962
join_where: [public: true],
6063
on_replace: :delete
6164

65+
many_to_many :on_conflict_assocs, MyAssoc,
66+
join_through: "schemas_assocs",
67+
on_join_through_conflict: :nothing
68+
6269
many_to_many :schema_assocs, MyAssoc,
6370
join_through: MySchemaAssoc,
6471
join_defaults: [public: true]
@@ -70,6 +77,10 @@ defmodule Ecto.Repo.ManyToManyTest do
7077
many_to_many :mfa_schema_assocs, MyAssoc,
7178
join_through: MySchemaAssoc,
7279
join_defaults: {__MODULE__, :send_to_self, [:extra]}
80+
81+
many_to_many :on_conflict_schema_assocs, MyAssoc,
82+
join_through: MySchemaAssoc,
83+
on_join_through_conflict: :nothing
7384
end
7485

7586
def send_to_self(struct, owner, extra) do
@@ -107,10 +118,39 @@ defmodule Ecto.Repo.ManyToManyTest do
107118
assert assoc.inserted_at
108119
assert_received {:insert, _}
109120

110-
assert_received {:insert_all, %{source: "schemas_assocs"},
121+
assert_received {:insert_all, %{source: "schemas_assocs", on_conflict: {:raise, [], []}},
111122
[[my_assoc_id: 1, my_schema_id: 1]]}
112123
end
113124

125+
test "handles assocs on insert with on_join_through_conflict and binary join_through" do
126+
sample = %MyAssoc{x: "xyz"}
127+
128+
changeset =
129+
%MySchema{}
130+
|> Ecto.Changeset.change()
131+
|> Ecto.Changeset.put_assoc(:on_conflict_assocs, [sample])
132+
133+
TestRepo.insert!(changeset)
134+
assert_received {:insert, _}
135+
136+
assert_received {:insert_all, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}},
137+
[[my_assoc_id: 1, my_schema_id: 1]]}
138+
end
139+
140+
test "handles assocs on insert with on_join_through_conflict and schema join_through" do
141+
sample = %MyAssoc{x: "xyz"}
142+
143+
changeset =
144+
%MySchema{}
145+
|> Ecto.Changeset.change()
146+
|> Ecto.Changeset.put_assoc(:on_conflict_schema_assocs, [sample])
147+
148+
TestRepo.insert!(changeset)
149+
assert_received {:insert, _}
150+
151+
assert_received {:insert, %{source: "schemas_assocs", on_conflict: {:nothing, [], []}}}
152+
end
153+
114154
test "handles assocs on insert preserving parent schema prefix" do
115155
sample = %MyAssoc{x: "xyz"}
116156

0 commit comments

Comments
 (0)