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
18 changes: 16 additions & 2 deletions lib/ecto/repo.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1675,6 +1675,11 @@ defmodule Ecto.Repo do
`{:unsafe_fragment, "(coalesce(firstname, ''), coalesce(lastname, '')) WHERE middlename IS NULL"}` for
`ON CONFLICT (coalesce(firstname, ''), coalesce(lastname, '')) WHERE middlename IS NULL` SQL query.

* `:replace_changed` - Whether to include `:conflict_target` fields when `:on_conflict`
is `:replace_all` or `{:replace_all_except, fields}`. If `true`, the conflict target
fields are not updated in order to enable optimizations such as HOT updates in PostgreSQL.
Defaults to `true`.

* `:placeholders` - A map with placeholders. This feature is not supported
by all databases. See the ["Placeholders" section](#c:insert_all/3-placeholders) for more information.

Expand Down Expand Up @@ -1718,7 +1723,9 @@ defmodule Ecto.Repo do
such as IDs and autogenerated timestamps (`inserted_at` and `updated_at`).
Do not use this option if you have auto-incrementing primary keys, as they
will also be replaced. You most likely want to use `{:replace_all_except, [:id]}`
or `{:replace, fields}` explicitly instead. This option requires a schema
or `{:replace, fields}` explicitly instead. This option requires a schema. Fields
specified by `:conflict_target` will be ignored unless `:replace_changed` is
configured to be `false`

* `{:replace_all_except, fields}` - same as above except the given fields
(and the ones given as conflict target) are not replaced. This option
Expand Down Expand Up @@ -1842,6 +1849,11 @@ defmodule Ecto.Repo do
`{:unsafe_fragment, "(coalesce(firstname, ""), coalesce(lastname, "")) WHERE middlename IS NULL"}` for
`ON CONFLICT (coalesce(firstname, ""), coalesce(lastname, "")) WHERE middlename IS NULL` SQL query.

* `:replace_changed` - Whether to include `:conflict_target` fields when `:on_conflict`
is `:replace_all` or `{:replace_all_except, fields}`. If `true`, the conflict fields
are not updated in order to enable optimizations such as HOT updates in PostgreSQL.
Defaults to `true`.

* `:stale_error_field` - The field where stale errors will be added in
the returning changeset. This option can be used to avoid raising
`Ecto.StaleEntryError`.
Expand Down Expand Up @@ -1880,7 +1892,9 @@ defmodule Ecto.Repo do
such as IDs and autogenerated timestamps (`inserted_at` and `updated_at`).
Do not use this option if you have auto-incrementing primary keys, as they
will also be replaced. You most likely want to use `{:replace_all_except, [:id]}`
or `{:replace, fields}` explicitly instead. This option requires a schema
or `{:replace, fields}` explicitly instead. This option requires a schema. Fields
specified by `:conflict_target` will be ignored unless `:replace_changed` is
configured to be `false`

* `{:replace_all_except, fields}` - same as above except the given fields are
not replaced. This option requires a schema
Expand Down
11 changes: 7 additions & 4 deletions lib/ecto/repo/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,10 @@ defmodule Ecto.Repo.Schema do
on_conflict = Keyword.get(opts, :on_conflict, :raise)
conflict_target = Keyword.get(opts, :conflict_target, [])
conflict_target = conflict_target(conflict_target, dumper)
replace_changed? = Keyword.get(opts, :replace_changed, true)

{on_conflict, conflict_cast_params} =
on_conflict(on_conflict, conflict_target, schema_meta, counter, dumper, adapter)
on_conflict(on_conflict, conflict_target, replace_changed?, schema_meta, counter, dumper, adapter)

opts =
Keyword.put(
Expand Down Expand Up @@ -445,6 +446,7 @@ defmodule Ecto.Repo.Schema do
on_conflict = Keyword.get(opts, :on_conflict, :raise)
conflict_target = Keyword.get(opts, :conflict_target, [])
conflict_target = conflict_target(conflict_target, dumper)
replace_changed? = Keyword.get(opts, :replace_changed, true)

# On insert, we always merge the whole struct into the
# changeset as changes, except the primary key if it is nil.
Expand Down Expand Up @@ -479,6 +481,7 @@ defmodule Ecto.Repo.Schema do
on_conflict(
on_conflict,
conflict_target,
replace_changed?,
schema_meta,
fn -> length(dump_changes) end,
dumper,
Expand Down Expand Up @@ -889,7 +892,7 @@ defmodule Ecto.Repo.Schema do
end
end

defp on_conflict(on_conflict, conflict_target, schema_meta, counter_fun, dumper, adapter) do
defp on_conflict(on_conflict, conflict_target, replace_changed?, schema_meta, counter_fun, dumper, adapter) do
%{source: source, schema: schema, prefix: prefix} = schema_meta

case on_conflict do
Expand All @@ -913,15 +916,15 @@ defmodule Ecto.Repo.Schema do
# Remove the conflict targets from the replacing fields
# since the values don't change and this allows postgres to
# possibly perform a HOT optimization: https://www.postgresql.org/docs/current/storage-hot.html
to_remove = List.wrap(conflict_target)
to_remove = if replace_changed?, do: List.wrap(conflict_target), else: []
replace = replace_all_fields!(:replace_all, schema, to_remove)

if replace == [], do: raise(ArgumentError, "empty list of fields to update, use the `:replace` option instead")

{{replace, [], conflict_target}, []}

{:replace_all_except, fields} ->
to_remove = List.wrap(conflict_target) ++ fields
to_remove = if replace_changed?, do: List.wrap(conflict_target) ++ fields, else: fields
replace = replace_all_fields!(:replace_all_except, schema, to_remove)

if replace == [], do: raise(ArgumentError, "empty list of fields to update, use the `:replace` option instead")
Expand Down
20 changes: 19 additions & 1 deletion test/ecto/repo_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1928,7 +1928,7 @@ defmodule Ecto.RepoTest do
assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], []}}}
end

test "includes conflict target in the field list given to :replace_all_except" do
test "does not pass conflict target to :replace_all_except" do
fields = [:map, :z, :yyy, :x]

TestRepo.insert(%MySchema{id: 1},
Expand All @@ -1939,6 +1939,18 @@ defmodule Ecto.RepoTest do
assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], [:id]}}}
end

test "passes conflict target to :replace_all_except when replace_changed is false" do
fields = [:map, :z, :yyy, :x, :id]

TestRepo.insert(%MySchema{id: 1},
on_conflict: {:replace_all_except, [:array]},
conflict_target: [:id],
replace_changed: false
)

assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], [:id]}}}
end

test "raises on empty-list of fields to update when :replace_all_except is given" do
msg = "empty list of fields to update, use the `:replace` option instead"

Expand All @@ -1956,6 +1968,12 @@ defmodule Ecto.RepoTest do
assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], [:id]}}}
end

test "includes conflict target in :replace_all when replace_changed is false" do
fields = [:map, :array, :z, :yyy, :x, :id]
TestRepo.insert(%MySchema{id: 1}, on_conflict: :replace_all, conflict_target: [:id], replace_changed: false)
assert_received {:insert, %{source: "my_schema", on_conflict: {^fields, [], [:id]}}}
end

test "raises on empty-list of fields to update when :replace_all is given" do
msg = "empty list of fields to update, use the `:replace` option instead"

Expand Down
Loading