Skip to content
Open
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
85 changes: 81 additions & 4 deletions integration_test/myxql/upsert_all_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,87 @@ defmodule Ecto.Integration.UpsertAllTest do

test "on conflict ignore" do
post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) ==
{1, nil}
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) ==
{1, nil}
# Default :nothing behavior uses ON DUPLICATE KEY UPDATE x = x workaround
# which always reports rows as affected
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {1, nil}
assert TestRepo.insert_all(Post, [post], on_conflict: :nothing) == {1, nil}
end

test "explicit insert ignore" do
post = [title: "first", uuid: "6fa459ea-ee8a-3ca4-894e-db77e160355e"]
# First insert succeeds - 1 row inserted
assert TestRepo.insert_all(Post, [post],
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"}
) == {1, nil}

# Second insert is ignored due to duplicate - 0 rows inserted (INSERT IGNORE behavior)
assert TestRepo.insert_all(Post, [post],
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"}
) == {0, nil}
end

test "explicit insert ignore with mixed records (some conflicts, some new)" do
# Insert an existing post
existing_uuid = "6fa459ea-ee8a-3ca4-894e-db77e160355e"
existing_post = [title: "existing", uuid: existing_uuid]

assert TestRepo.insert_all(Post, [existing_post],
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"}
) == {1, nil}

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

posts = [
[title: "new post 1", uuid: new_uuid1],
[title: "duplicate", uuid: existing_uuid],
[title: "new post 2", uuid: new_uuid2]
]

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

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

# Verify the existing post was not modified
[original] = TestRepo.all(from p in Post, where: p.uuid == ^existing_uuid)
assert original.title == "existing"

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

test "explicit insert ignore with all duplicates" do
# Insert initial posts
uuid1 = "1fa459ea-ee8a-3ca4-894e-db77e160355e"
uuid2 = "2fa459ea-ee8a-3ca4-894e-db77e160355e"
initial_posts = [[title: "first", uuid: uuid1], [title: "second", uuid: uuid2]]

assert TestRepo.insert_all(Post, initial_posts,
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"}
) == {2, nil}

# Try to insert all duplicates
duplicate_posts = [[title: "dup1", uuid: uuid1], [title: "dup2", uuid: uuid2]]

# All are duplicates, so 0 rows inserted
assert TestRepo.insert_all(Post, duplicate_posts,
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"}
) == {0, nil}

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

test "on conflict keyword list" do
Expand Down
31 changes: 28 additions & 3 deletions lib/ecto/adapters/myxql.ex
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,23 @@ defmodule Ecto.Adapters.MyXQL do
automatically commits after some commands like CREATE TABLE.
Therefore MySQL migrations does not run inside transactions.

### Upserts

When using `on_conflict: :nothing`, the adapter uses the
`ON DUPLICATE KEY UPDATE x = x` workaround to simulate "do nothing"
behavior. This always reports 1 affected row regardless of whether
the row was actually inserted or ignored.

If you need accurate row counts (0 when ignored, 1 when inserted),
you can opt into MySQL's `INSERT IGNORE` by specifying:

Repo.insert_all(Post, posts,
on_conflict: :nothing,
conflict_target: {:unsafe_fragment, "insert_ignore"})

Note that `INSERT IGNORE` has broader semantics in MySQL - it also
ignores certain type conversion errors, not just duplicate key conflicts.

## Old MySQL versions

### JSON support
Expand Down Expand Up @@ -330,9 +347,17 @@ defmodule Ecto.Adapters.MyXQL do

case Ecto.Adapters.SQL.query(adapter_meta, sql, values ++ query_params, opts) do
{:ok, %{num_rows: 0}} ->
raise "insert operation failed to insert any row in the database. " <>
"This may happen if you have trigger or other database conditions rejecting operations. " <>
"The emitted SQL was: #{sql}"
# With INSERT IGNORE (explicit via conflict_target), 0 rows means the row
# was ignored due to a conflict, which is expected behavior
case on_conflict do
{:nothing, _, {:unsafe_fragment, "insert_ignore"}} ->
{:ok, []}

_ ->
raise "insert operation failed to insert any row in the database. " <>
"This may happen if you have trigger or other database conditions rejecting operations. " <>
"The emitted SQL was: #{sql}"
end

# We were used to check if num_rows was 1 or 2 (in case of upserts)
# but MariaDB supports tables with System Versioning, and in those
Expand Down
15 changes: 14 additions & 1 deletion lib/ecto/adapters/myxql/connection.ex
Original file line number Diff line number Diff line change
Expand Up @@ -181,9 +181,10 @@ if Code.ensure_loaded?(MyXQL) do
@impl true
def insert(prefix, table, header, rows, on_conflict, [], []) do
fields = quote_names(header)
insert_keyword = insert_keyword(on_conflict)

[
"INSERT INTO ",
insert_keyword,
quote_table(prefix, table),
" (",
fields,
Expand All @@ -192,6 +193,12 @@ if Code.ensure_loaded?(MyXQL) do
]
end

# INSERT IGNORE only when explicitly requested via conflict_target
defp insert_keyword({:nothing, _, {:unsafe_fragment, "insert_ignore"}}),
do: "INSERT IGNORE INTO "

defp insert_keyword(_), do: "INSERT INTO "

def insert(_prefix, _table, _header, _rows, _on_conflict, _returning, []) do
error!(nil, ":returning is not supported in insert/insert_all by MySQL")
end
Expand All @@ -208,6 +215,12 @@ if Code.ensure_loaded?(MyXQL) do
[]
end

# Explicit INSERT IGNORE - no ON DUPLICATE KEY clause needed
defp on_conflict({:nothing, _, {:unsafe_fragment, "insert_ignore"}}, _header) do
[]
end

# Default :nothing - uses workaround to simulate "do nothing" behavior
defp on_conflict({:nothing, _, []}, [field | _]) do
quoted = quote_name(field)
[" ON DUPLICATE KEY UPDATE ", quoted, " = " | quoted]
Expand Down
16 changes: 16 additions & 0 deletions test/ecto/adapters/myxql_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1466,6 +1466,7 @@ defmodule Ecto.Adapters.MyXQLTest do
end

test "insert with on duplicate key" do
# Default :nothing uses ON DUPLICATE KEY UPDATE workaround
query = insert(nil, "schema", [:x, :y], [[:x, :y]], {:nothing, [], []}, [])

assert query ==
Expand Down Expand Up @@ -1499,6 +1500,21 @@ defmodule Ecto.Adapters.MyXQLTest do
end
end

test "insert with explicit insert ignore" do
# Explicit INSERT IGNORE via conflict_target: {:unsafe_fragment, "insert_ignore"}
query =
insert(
nil,
"schema",
[:x, :y],
[[:x, :y]],
{:nothing, [], {:unsafe_fragment, "insert_ignore"}},
[]
)

assert query == ~s{INSERT IGNORE INTO `schema` (`x`,`y`) VALUES (?,?)}
end

test "insert with query" do
select_query = from("schema", select: [:id]) |> plan(:all)

Expand Down