diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex index 91b6398b45..8af8e3014e 100644 --- a/lib/ecto/repo.ex +++ b/lib/ecto/repo.ex @@ -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. @@ -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 @@ -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`. @@ -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 diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex index e2fedf5f60..f0ba97a715 100644 --- a/lib/ecto/repo/schema.ex +++ b/lib/ecto/repo/schema.ex @@ -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( @@ -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. @@ -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, @@ -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 @@ -913,7 +916,7 @@ 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") @@ -921,7 +924,7 @@ defmodule Ecto.Repo.Schema do {{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") diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs index 82775a182a..ac41607d83 100644 --- a/test/ecto/repo_test.exs +++ b/test/ecto/repo_test.exs @@ -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}, @@ -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" @@ -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"