Repository: elixir-ecto/ecto (elixir)

Issue:
Update specific fields from EXCLUDED table on conflict
Is it possible to do something like this on Postgres?

```
Repo.insert_all(Struct, entries, on_conflict: [set: [:foo, :bar])
```

I found solution via fragment (#1853) but this way I can't provide ```on_conflict``` value as an argument (I can't use ```fragment``` outside of ```from``` scope). Eventually I'd like to make the next code work:

```
def save(struct, entries, opts \\ []) do
  Repo.insert_all(struct, entries, opts)
end

save(MyStruct, [%MyStruct{foo: "foo", bar: "bar", bazz: "bazz"}], on_conflict: [set: [:foo, :bar])
```

I've checked *Ecto.Repo.Schema.on_conflict/6* and concluded it's just not supported at all. Are there any hacks here? :)

Many thanks!

Pull request description:
Add {:replace, columns}, closes #2370

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cb33573c..74482509 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -40,6 +40,7 @@ This means Ecto no longer requires Ecto-specific extensions on databases such as
   * [Ecto.Query] Support tuples in filter expressions, allowing queries such as `where: {p.foo, p.bar} > {^foo, ^bar}`
   * [Ecto.Repo] Allow some query parameters such as `ssl` and `pool_size` to be given in the repository URL
   * [Ecto.Repo] Support `:replace_all_except_primary_key` as `:on_conflict` strategy
+  * [Ecto.Repo] Support `{:replace, columns}` as `:on_conflict` strategy
 
 ### Bug fixes
 
diff --git a/lib/ecto/repo.ex b/lib/ecto/repo.ex
index 4917af03..ba7309b0 100644
--- a/lib/ecto/repo.ex
+++ b/lib/ecto/repo.ex
@@ -563,8 +563,8 @@ defmodule Ecto.Repo do
     * `:prefix` - The prefix to run the query on (such as the schema path
       in Postgres or the database in MySQL).
     * `:on_conflict` - It may be one of `:raise` (the default), `:nothing`,
-      `:replace_all`, `:replace_all_except_primary_key`, a keyword list of
-      update instructions or an `Ecto.Query` query for updates. See the
+      `:replace_all`, `:replace_all_except_primary_key`, `{:replace, columns}`, a keyword list of
+      update instructions, `{:replace, columns}` or an `Ecto.Query` query for updates. See the
       "Upserts" section for more information.
     * `:conflict_target` - Which columns to verify for conflicts. If
       none is specified, the conflict target is left up to the database
@@ -592,6 +592,8 @@ defmodule Ecto.Repo do
       parameters)
     * `:replace_all_except_primary_key` - same as above except primary keys are
       not replaced. This option requires a schema
+    * `{:replace, columns}` - replace only specific columns. This option requires
+      conflict_target
     * a keyword list of update instructions - such as the one given to
       `c:update_all/3`, for example: `[set: [title: "new title"]]`
     * an `Ecto.Query` that will act as an `UPDATE` statement, such as the
@@ -725,7 +727,7 @@ defmodule Ecto.Repo do
       in Postgres or the database in MySQL). This overrides the prefix set
       in the struct.
     * `:on_conflict` - It may be one of `:raise` (the default), `:nothing`,
-      `:replace_all`, `:replace_all_except_primary_key`, a keyword list of
+      `:replace_all`, `:replace_all_except_primary_key`, `{:replace, columns}`, a keyword list of
       update instructions or an `Ecto.Query` query for updates. See the
       "Upserts" section for more information.
     * `:conflict_target` - Which columns to verify for conflicts. If
@@ -757,6 +759,8 @@ defmodule Ecto.Repo do
       in the excluded row except (the record given in the function parameters)
     * `:replace_all_except_primary_key` - same as above except primary keys are
       not replaced
+    * `{:replace, columns}` - replace only specific columns. This option requires
+      conflict_target
     * a keyword list of update instructions - such as the one given to
       `c:update_all/3`, for example: `[set: [title: "new title"]]`
     * an `Ecto.Query` that will act as an `UPDATE` statement, such as the
diff --git a/lib/ecto/repo/schema.ex b/lib/ecto/repo/schema.ex
index 3e4fe1a3..934d378a 100644
--- a/lib/ecto/repo/schema.ex
+++ b/lib/ecto/repo/schema.ex
@@ -500,6 +500,10 @@ defmodule Ecto.Repo.Schema do
         {:nothing, [], conflict_target}
       :replace_all ->
         {header, [], conflict_target}
+      {:replace, keys} when is_list(keys) and conflict_target == [] ->
+        raise ArgumentError, ":conflict_target option is required when :on_conflict is replace"
+      {:replace, keys} when is_list(keys) ->
+        {keys, [], conflict_target}
       :replace_all_except_primary_key when is_nil(schema) ->
         raise ArgumentError, "cannot use :replace_all_except_primary_key on operations without a schema"
       :replace_all_except_primary_key ->


Test patch:
diff --git a/integration_test/cases/repo.exs b/integration_test/cases/repo.exs
index bf12dab5..d90c0961 100644
--- a/integration_test/cases/repo.exs
+++ b/integration_test/cases/repo.exs
@@ -1626,5 +1626,37 @@ defmodule Ecto.Integration.RepoTest do
       assert updated_second.title == "second_updated"
       assert updated_second.text == "second_updated"
     end
+
+    @tag :with_conflict_target
+    test "on conflict replace and conflict_target" do
+      post_first = %Post{title: "first", visits: 10, public: true, uuid: Ecto.UUID.generate}
+      post_second = %Post{title: "second", visits: 20, public: false, uuid: Ecto.UUID.generate}
+
+      {:ok, post_first} = TestRepo.insert(post_first, on_conflict: {:replace, [:title, :visits]}, conflict_target: :uuid)
+      {:ok, post_second} = TestRepo.insert(post_second, on_conflict: {:replace, [:title, :visits]}, conflict_target: :uuid)
+
+      assert post_first.id
+      assert post_second.id
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      # Multiple record change value: note `public` field is not changed
+      changes = [%{id: post_first.id, title: "first_updated", visits: 11, public: false, text: "first_updated", uuid: post_first.uuid},
+                 %{id: post_second.id, title: "second_updated", visits: 21, public: true, text: "second_updated", uuid: post_second.uuid}]
+
+      TestRepo.insert_all(Post, changes, on_conflict: {:replace, [:title, :visits, :text]}, conflict_target: :uuid)
+      assert TestRepo.all(from p in Post, select: count(p.id)) == [2]
+
+      updated_first = TestRepo.get(Post, post_first.id)
+      assert updated_first.title == "first_updated"
+      assert updated_first.visits == 11
+      assert updated_first.public == true
+      assert updated_first.text == "first_updated"
+
+      updated_second = TestRepo.get(Post, post_second.id)
+      assert updated_second.title == "second_updated"
+      assert updated_second.visits == 21
+      assert updated_second.public == false
+      assert updated_second.text == "second_updated"
+    end
   end
 end
diff --git a/test/ecto/repo_test.exs b/test/ecto/repo_test.exs
index 7027046e..4213cb59 100644
--- a/test/ecto/repo_test.exs
+++ b/test/ecto/repo_test.exs
@@ -444,6 +444,12 @@ defmodule Ecto.RepoTest do
       end
     end
 
+    test "raises on empty conflict_target with on_conflict replace" do
+      assert_raise ArgumentError, ":conflict_target option is required when :on_conflict is replace", fn ->
+        TestRepo.insert(%MySchema{id: 1}, on_conflict: {:replace, []})
+      end
+    end
+
     test "raises on query mismatch" do
       assert_raise ArgumentError, ~r"cannot run on_conflict: query", fn ->
         query = from p in "posts"