Skip to content

Commit 8c7955d

Browse files
committed
Add opt-in INSERT IGNORE support for MySQL via conflict_target
The default on_conflict: :nothing behavior now uses the ON DUPLICATE KEY UPDATE x = x workaround (restoring original behavior), which always reports 1 affected row regardless of whether the row was inserted or ignored. For users who need accurate row counts (0 when ignored, 1 when inserted), INSERT IGNORE can be explicitly enabled via: Repo.insert_all(Post, posts, on_conflict: :nothing, conflict_target: {:unsafe_fragment, "insert_ignore"}) This approach was chosen to avoid modifying Ecto core with a new on_conflict type like :ignore for MySQL-specific intricacies. By leveraging the existing {:unsafe_fragment, _} mechanism, MySQL users can opt into INSERT IGNORE semantics when needed while maintaining backward compatibility. Note that INSERT IGNORE has broader semantics in MySQL - it ignores certain type conversion errors in addition to duplicate key conflicts - which is why it's not the default behavior.
1 parent 7478895 commit 8c7955d

File tree

4 files changed

+96
-29
lines changed

4 files changed

+96
-29
lines changed

integration_test/myxql/upsert_all_test.exs

Lines changed: 44 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,60 +13,84 @@ defmodule Ecto.Integration.UpsertAllTest do
1313

1414
test "on conflict ignore" do
1515
post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
16-
# First insert succeeds - 1 row inserted
16+
# Default :nothing behavior uses ON DUPLICATE KEY UPDATE x = x workaround
17+
# which always reports rows as affected
18+
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {1, nil}
1719
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {1, nil}
20+
end
21+
22+
test "explicit insert ignore" do
23+
post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
24+
# First insert succeeds - 1 row inserted
25+
assert TestRepo.insert_all(Post, [post],
26+
on_conflict: :nothing,
27+
conflict_target: {:unsafe_fragment, "insert_ignore"}
28+
) == {1, nil}
29+
1830
# Second insert is ignored due to duplicate - 0 rows inserted (INSERT IGNORE behavior)
19-
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {0, nil}
31+
assert TestRepo.insert_all(Post, [post],
32+
on_conflict: :nothing,
33+
conflict_target: {:unsafe_fragment, "insert_ignore"}
34+
) == {0, nil}
2035
end
2136

22-
test "on conflict ignore with mixed records (some conflicts, some new)" do
37+
test "explicit insert ignore with mixed records (some conflicts, some new)" do
2338
# Insert an existing post
2439
existing_uuid = "6fa459ea-ee8a-3ca4-894e-db77e160355e"
2540
existing_post = [title: "existing", uuid: existing_uuid]
26-
assert TestRepo.insert_all(Post, [existing_post], on_conflict: :nothing) == {1, nil}
41+
42+
assert TestRepo.insert_all(Post, [existing_post],
43+
on_conflict: :nothing,
44+
conflict_target: {:unsafe_fragment, "insert_ignore"}
45+
) == {1, nil}
2746

2847
# Now insert a batch with one duplicate and two new records
2948
new_uuid1 = "7fa459ea-ee8a-3ca4-894e-db77e160355f"
3049
new_uuid2 = "8fa459ea-ee8a-3ca4-894e-db77e160355a"
3150

3251
posts = [
33-
[title: "new post 1", uuid: new_uuid1], # new - should be inserted
34-
[title: "duplicate", uuid: existing_uuid], # duplicate - should be ignored
35-
[title: "new post 2", uuid: new_uuid2] # new - should be inserted
52+
[title: "new post 1", uuid: new_uuid1],
53+
[title: "duplicate", uuid: existing_uuid],
54+
[title: "new post 2", uuid: new_uuid2]
3655
]
3756

3857
# With INSERT IGNORE, only 2 rows should be inserted (the non-duplicates)
39-
assert TestRepo.insert_all(Post, posts, on_conflict: :nothing) == {2, nil}
58+
assert TestRepo.insert_all(Post, posts,
59+
on_conflict: :nothing,
60+
conflict_target: {:unsafe_fragment, "insert_ignore"}
61+
) == {2, nil}
4062

4163
# Verify the data - should have 3 posts total (1 existing + 2 new)
4264
assert length(TestRepo.all(Post)) == 3
4365

4466
# Verify the existing post was not modified
4567
[original] = TestRepo.all(from p in Post, where: p.uuid == ^existing_uuid)
46-
assert original.title == "existing" # title unchanged
68+
assert original.title == "existing"
4769

4870
# Verify new posts were inserted
4971
assert TestRepo.exists?(from p in Post, where: p.uuid == ^new_uuid1)
5072
assert TestRepo.exists?(from p in Post, where: p.uuid == ^new_uuid2)
5173
end
5274

53-
test "on conflict ignore with all duplicates" do
75+
test "explicit insert ignore with all duplicates" do
5476
# Insert initial posts
5577
uuid1 = "1fa459ea-ee8a-3ca4-894e-db77e160355e"
5678
uuid2 = "2fa459ea-ee8a-3ca4-894e-db77e160355e"
57-
initial_posts = [
58-
[title: "first", uuid: uuid1],
59-
[title: "second", uuid: uuid2]
60-
]
61-
assert TestRepo.insert_all(Post, initial_posts, on_conflict: :nothing) == {2, nil}
79+
initial_posts = [[title: "first", uuid: uuid1], [title: "second", uuid: uuid2]]
80+
81+
assert TestRepo.insert_all(Post, initial_posts,
82+
on_conflict: :nothing,
83+
conflict_target: {:unsafe_fragment, "insert_ignore"}
84+
) == {2, nil}
6285

6386
# Try to insert all duplicates
64-
duplicate_posts = [
65-
[title: "dup1", uuid: uuid1],
66-
[title: "dup2", uuid: uuid2]
67-
]
87+
duplicate_posts = [[title: "dup1", uuid: uuid1], [title: "dup2", uuid: uuid2]]
88+
6889
# All are duplicates, so 0 rows inserted
69-
assert TestRepo.insert_all(Post, duplicate_posts, on_conflict: :nothing) == {0, nil}
90+
assert TestRepo.insert_all(Post, duplicate_posts,
91+
on_conflict: :nothing,
92+
conflict_target: {:unsafe_fragment, "insert_ignore"}
93+
) == {0, nil}
7094

7195
# Verify count unchanged
7296
assert length(TestRepo.all(Post)) == 2

lib/ecto/adapters/myxql.ex

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,23 @@ defmodule Ecto.Adapters.MyXQL do
103103
automatically commits after some commands like CREATE TABLE.
104104
Therefore MySQL migrations does not run inside transactions.
105105
106+
### Upserts
107+
108+
When using `on_conflict: :nothing`, the adapter uses the
109+
`ON DUPLICATE KEY UPDATE x = x` workaround to simulate "do nothing"
110+
behavior. This always reports 1 affected row regardless of whether
111+
the row was actually inserted or ignored.
112+
113+
If you need accurate row counts (0 when ignored, 1 when inserted),
114+
you can opt into MySQL's `INSERT IGNORE` by specifying:
115+
116+
Repo.insert_all(Post, posts,
117+
on_conflict: :nothing,
118+
conflict_target: {:unsafe_fragment, "insert_ignore"})
119+
120+
Note that `INSERT IGNORE` has broader semantics in MySQL - it also
121+
ignores certain type conversion errors, not just duplicate key conflicts.
122+
106123
## Old MySQL versions
107124
108125
### JSON support
@@ -330,10 +347,10 @@ defmodule Ecto.Adapters.MyXQL do
330347

331348
case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do
332349
{:ok, %{num_rows: 0}} ->
333-
# With INSERT IGNORE (on_conflict: :nothing), 0 rows means the row was
334-
# ignored due to a conflict, which is expected behavior
350+
# With INSERT IGNORE (explicit via conflict_target), 0 rows means the row
351+
# was ignored due to a conflict, which is expected behavior
335352
case on_conflict do
336-
{:nothing, _, _} ->
353+
{:nothing, _, {:unsafe_fragment, "insert_ignore"}} ->
337354
{:ok, []}
338355

339356
_ ->

lib/ecto/adapters/myxql/connection.ex

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,10 @@ if Code.ensure_loaded?(MyXQL) do
193193
]
194194
end
195195

196-
defp insert_keyword({:nothing, _, []}), do: "INSERT IGNORE INTO "
196+
# INSERT IGNORE only when explicitly requested via conflict_target
197+
defp insert_keyword({:nothing, _, {:unsafe_fragment, "insert_ignore"}}),
198+
do: "INSERT IGNORE INTO "
199+
197200
defp insert_keyword(_), do: "INSERT INTO "
198201

199202
def insert(_prefix, _table, _header, _rows, _on_conflict, _returning, []) do
@@ -212,11 +215,17 @@ if Code.ensure_loaded?(MyXQL) do
212215
[]
213216
end
214217

215-
defp on_conflict({:nothing, _, []}, _header) do
216-
# Handled by INSERT IGNORE
218+
# Explicit INSERT IGNORE - no ON DUPLICATE KEY clause needed
219+
defp on_conflict({:nothing, _, {:unsafe_fragment, "insert_ignore"}}, _header) do
217220
[]
218221
end
219222

223+
# Default :nothing - uses workaround to simulate "do nothing" behavior
224+
defp on_conflict({:nothing, _, []}, [field | _]) do
225+
quoted = quote_name(field)
226+
[" ON DUPLICATE KEY UPDATE ", quoted, " = " | quoted]
227+
end
228+
220229
defp on_conflict({fields, _, []}, _header) when is_list(fields) do
221230
[
222231
" ON DUPLICATE KEY UPDATE "

test/ecto/adapters/myxql_test.exs

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1465,10 +1465,12 @@ defmodule Ecto.Adapters.MyXQLTest do
14651465
end
14661466
end
14671467

1468-
test "insert with on conflict" do
1469-
# Using INSERT IGNORE for :nothing on_conflict
1468+
test "insert with on duplicate key" do
1469+
# Default :nothing uses ON DUPLICATE KEY UPDATE workaround
14701470
query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:nothing, [], []}, [])
1471-
assert query == ~s{INSERT IGNORE INTO `schema` (`x`,`y`) VALUES (?,?)}
1471+
1472+
assert query ==
1473+
~s{INSERT INTO `schema` (`x`,`y`) VALUES (?,?) ON DUPLICATE KEY UPDATE `x` = `x`}
14721474

14731475
update = from("schema", update: [set: [z: "foo"]]) |> plan(:update_all)
14741476
query = insert(nil, "schema", [:x, :y], [[:x, :y]], {update, [], []}, [])
@@ -1498,6 +1500,21 @@ defmodule Ecto.Adapters.MyXQLTest do
14981500
end
14991501
end
15001502

1503+
test "insert with explicit insert ignore" do
1504+
# Explicit INSERT IGNORE via conflict_target: {:unsafe_fragment, "insert_ignore"}
1505+
query =
1506+
insert(
1507+
nil,
1508+
"schema",
1509+
[:x, :y],
1510+
[[:x, :y]],
1511+
{:nothing, [], {:unsafe_fragment, "insert_ignore"}},
1512+
[]
1513+
)
1514+
1515+
assert query == ~s{INSERT IGNORE INTO `schema` (`x`,`y`) VALUES (?,?)}
1516+
end
1517+
15011518
test "insert with query" do
15021519
select_query = from("schema", select: [:id]) |> plan(:all)
15031520

0 commit comments

Comments
 (0)