Repository: elixir-ecto/ecto
Pull request #2376 (2018-01-13 16:32:36)

Add force update option for casting relations
Adds an option on `cast_assoc/3` and `cast_embeds/3` to add `[force: true]` in the `repo_opts` on a changeset.

Addresses #2317.

Note: I wasn't quite sure how to validate that record's `updated_at` is updated in the test suite.

ebert[bot]: Ebert has finished reviewing this Pull Request and has found:

* 1 possible new issue (including those that may have been commented here).

You can see more details about this review at https://ebertapp.io/github/elixir-ecto/ecto/pulls/2376.

<!-- Ebert auto generated comment -->

alexgaribay: It should be good for a second look now.
josevalim: One last comment and we should be good to go!
josevalim: :heart: :green_heart: :blue_heart: :yellow_heart: :purple_heart:

Patch:
diff --git a/lib/ecto/changeset.ex b/lib/ecto/changeset.ex
index c2370bd2..6c7725c2 100644
--- a/lib/ecto/changeset.ex
+++ b/lib/ecto/changeset.ex
@@ -669,6 +669,8 @@ defmodule Ecto.Changeset do
     * `:required` - if the association is a required field
     * `:required_message` - the message on failure, defaults to "can't be blank"
     * `:invalid_message` - the message on failure, defaults to "is invalid"
+    * `:force_update_on_change` - force the parent record to be updated in the repository if
+      there is a change, defaults to true
   """
   def cast_assoc(changeset, name, opts \\ []) when is_atom(name) do
     cast_relation(:assoc, changeset, name, opts)
@@ -695,6 +697,8 @@ defmodule Ecto.Changeset do
     * `:required` - if the embed is a required field
     * `:required_message` - the message on failure, defaults to "can't be blank"
     * `:invalid_message` - the message on failure, defaults to "is invalid"
+    * `:force_update_on_change` - force the parent record to be updated in the repository if
+      there is a change, defaults to true
   """
   def cast_embed(changeset, name, opts \\ []) when is_atom(name) do
     cast_relation(:embed, changeset, name, opts)
@@ -728,6 +732,7 @@ defmodule Ecto.Changeset do
           current  = Relation.load!(data, original)
           case Relation.cast(relation, value, current, on_cast) do
             {:ok, change, relation_valid?} when change != original ->
+              changeset = force_update(changeset, opts)
               missing_relation(%{changeset | changes: Map.put(changes, key, change),
                                  valid?: changeset.valid? and relation_valid?}, key, current, required?, relation, opts)
             :error ->
@@ -792,6 +797,14 @@ defmodule Ecto.Changeset do
   defp relation!(op, type, name, schema_type),
     do: raise(ArgumentError, "expected `#{name}` to be an #{type} in `#{op}_#{type}`, got: `#{inspect schema_type}`")
 
+  defp force_update(changeset, opts) do
+    if Keyword.get(opts, :force_update_on_change, true) do
+      put_in(changeset.repo_opts[:force], true)
+    else
+      changeset
+    end
+  end
+
   ## Working with changesets
 
   @doc """


Test patch:
diff --git a/test/ecto/changeset/embedded_test.exs b/test/ecto/changeset/embedded_test.exs
index 38baf66d..44fe5ac4 100644
--- a/test/ecto/changeset/embedded_test.exs
+++ b/test/ecto/changeset/embedded_test.exs
@@ -210,6 +210,19 @@ defmodule Ecto.Changeset.EmbeddedTest do
     assert changeset.valid?
   end
 
+  test "cast embeds_one with `:force_update_on_change` option" do
+    changeset = cast(%Author{profile: %Profile{id: "id"}}, %{profile: nil}, :profile,
+                     force_update_on_change: true)
+    assert changeset.repo_opts[:force]
+
+    changeset = cast(%Author{profile: %Profile{id: "id"}}, %{profile: nil}, :profile,
+                     force_update_on_change: false)
+    assert changeset.repo_opts == []
+
+    changeset = cast(%Author{profile: nil}, %{profile: nil}, :profile, force_update_on_change: true)
+    assert changeset.repo_opts == []
+  end
+
   test "cast embeds_one with custom changeset" do
     changeset = cast(%Author{}, %{"profile" => %{"name" => "michal"}}, :profile,
                      with: &Profile.optional_changeset/2)
@@ -442,6 +455,19 @@ defmodule Ecto.Changeset.EmbeddedTest do
     assert changeset.errors == [posts: {"is invalid", [validation: :embed, type: {:array, :map}]}]
   end
 
+  test "cast embeds_many with `:force_update_on_change` option" do
+    params = [%{title: "hello"}]
+    changeset = cast(%Author{}, %{posts: params}, :posts, force_update_on_change: true)
+    assert changeset.repo_opts[:force]
+
+    changeset = cast(%Author{}, %{posts: params}, :posts, force_update_on_change: false)
+    assert changeset.repo_opts == []
+
+    changeset = cast(%Author{posts: [%Post{title: "hello"}]}, %{posts: params}, :posts,
+                     force_update_on_change: true)
+    assert changeset.repo_opts == []
+  end
+
   test "cast embeds_many with empty parameters" do
     changeset = cast(%Author{posts: []}, %{}, :posts)
     assert changeset.changes == %{}
diff --git a/test/ecto/changeset/has_assoc_test.exs b/test/ecto/changeset/has_assoc_test.exs
index 837de65e..6d63ebc2 100644
--- a/test/ecto/changeset/has_assoc_test.exs
+++ b/test/ecto/changeset/has_assoc_test.exs
@@ -214,6 +214,20 @@ defmodule Ecto.Changeset.HasAssocTest do
     assert changeset.errors == [profile: {"can't be blank", [validation: :required]}]
   end
 
+  test "cast has_one with `:force_update_on_change` option" do
+    changeset = cast(%Author{}, %{profile: %{name: "michal"}}, :profile,
+                     force_update_on_change: true)
+    assert changeset.repo_opts[:force]
+
+    changeset = cast(%Author{}, %{profile: %{name: "michal"}}, :profile,
+                     force_update_on_change: false)
+    assert changeset.repo_opts == []
+
+    changeset = cast(%Author{profile: %{name: "michal"}}, %{name: "michal"}, :profile,
+                     force_update_on_change: true)
+    assert changeset.repo_opts == []
+  end
+
   test "cast has_one with optional" do
     changeset = cast(%Author{profile: %Profile{id: "id"}}, %{"profile" => nil}, :profile)
     assert changeset.changes.profile == nil
@@ -487,6 +501,18 @@ defmodule Ecto.Changeset.HasAssocTest do
     assert changeset.errors == [posts: {"is invalid", [validation: :assoc, type: {:array, :map}]}]
   end
 
+  test "cast has_many with `:force_update_on_change` option" do
+    changeset = cast(%Author{}, %{posts: [%{title: "hello"}]}, :posts, force_update_on_change: true)
+    assert changeset.repo_opts[:force]
+
+    changeset = cast(%Author{}, %{posts: [%{title: "hello"}]}, :posts, force_update_on_change: false)
+    assert changeset.repo_opts == []
+
+    changeset = cast(%Author{posts: [%Post{title: "hello"}]}, %{posts: [%{title: "hello"}]}, :posts,
+                     force_update_on_change: true)
+    assert changeset.repo_opts == []
+  end
+
   test "cast has_many with empty parameters" do
     changeset = cast(%Author{posts: []}, %{}, :posts)
     assert changeset.changes == %{}