diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index 8e5b6420..73f24169 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -4146,6 +4146,41 @@ defmodule Ash.Changeset do end end + @doc """ + Updates an existing attribute change by applying a function to it. + + This is useful for applying some kind of normalization to the attribute. + + ```elixir + Ash.Changeset.update_change(changeset, :serial, &String.downcase/1) + ``` + + The update function gets called with the value already cast to the correct type. + + ```elixir + changeset + |> Ash.Changeset.change_attribute(:integer_attribute, "3") + |> Ash.Changeset.update_change(:integer_attribute, fn x -> x + 1 end) + ``` + + ## Invalid value handling + + If `update_change` is called with a changeset that has not been validated yet, the update + function must handle potentially invalid and `nil` values. + + To only deal with valid values, you can call `update_change` in a `before_action` hook. + """ + @spec update_change(t(), atom, (any -> any)) :: t() + def update_change(changeset, attribute, fun) do + case Ash.Changeset.fetch_change(changeset, attribute) do + {:ok, change} -> + Ash.Changeset.force_change_attribute(changeset, attribute, fun.(change)) + + :error -> + changeset + end + end + @doc """ Add an argument to the changeset, which will be provided to the action. """ diff --git a/lib/ash/resource/change/builtins.ex b/lib/ash/resource/change/builtins.ex index 91714f47..4d7f63c2 100644 --- a/lib/ash/resource/change/builtins.ex +++ b/lib/ash/resource/change/builtins.ex @@ -131,6 +131,23 @@ defmodule Ash.Resource.Change.Builtins do @set_attribute_opts end + @doc """ + Updates an existing attribute change by applying a function to it. + + The update function gets called with the value already cast to the correct type, and only gets called + on valid changesets, so the value is guaranteed to have passed validations and constraints. + """ + defmacro update_change(attribute, function) do + {value, function} = + Spark.CodeHelpers.lift_functions(function, :change_update_change, __CALLER__) + + quote generated: true do + unquote(function) + + {Ash.Resource.Change.UpdateChange, attribute: unquote(attribute), function: unquote(value)} + end + end + @doc """ Increments an attribute's value by the amount specified, which defaults to 1. diff --git a/lib/ash/resource/change/update_change.ex b/lib/ash/resource/change/update_change.ex new file mode 100644 index 00000000..4ef33263 --- /dev/null +++ b/lib/ash/resource/change/update_change.ex @@ -0,0 +1,12 @@ +defmodule Ash.Resource.Change.UpdateChange do + @moduledoc false + use Ash.Resource.Change + + @impl true + def change(changeset, opts, _) do + Ash.Changeset.before_action( + changeset, + &Ash.Changeset.update_change(&1, opts[:attribute], opts[:function]) + ) + end +end diff --git a/test/changeset/changeset_test.exs b/test/changeset/changeset_test.exs index 40f2d352..fec55ddf 100644 --- a/test/changeset/changeset_test.exs +++ b/test/changeset/changeset_test.exs @@ -301,6 +301,37 @@ defmodule Ash.Test.Changeset.ChangesetTest do defstruct [:name] end + defmodule Tag do + @moduledoc false + use Ash.Resource, domain: Domain, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end + + actions do + default_accept :* + defaults [:read, :destroy, create: :*, update: :*] + end + + attributes do + uuid_primary_key :id + + attribute :name, :string do + public?(true) + allow_nil?(false) + end + + attribute :category, :string do + public?(true) + end + + attribute :priority, :integer do + public?(true) + end + end + end + describe "new" do test "it wraps a new resource in a `create` changeset" do assert %Ash.Changeset{ @@ -1094,4 +1125,43 @@ defmodule Ash.Test.Changeset.ChangesetTest do Ash.Changeset.for_create(Category, :with_name_validation, %{"name" => ""}).errors end end + + describe "update_change/3" do + test "updates the attribute if it exists" do + changeset = + Tag + |> Ash.Changeset.new() + |> Ash.Changeset.change_attribute(:name, "FOO") + |> Ash.Changeset.update_change(:name, &String.downcase/1) + |> Ash.Changeset.for_create(:create) + + assert %Ash.Changeset{attributes: %{name: "foo"}} = changeset + end + + test "does not call the update function if the attribute is not set" do + Tag + |> Ash.Changeset.new() + |> Ash.Changeset.update_change(:category, fn _ -> raise "I should not be called" end) + |> Ash.Changeset.for_create(:create) + end + + test "does not call the update function on attributes that failed initial validation" do + Tag + |> Ash.Changeset.new() + |> Ash.Changeset.change_attribute(:priority, "foo") + |> Ash.Changeset.update_change(:priority, fn _ -> raise "I should not be called" end) + |> Ash.Changeset.for_create(:create) + end + + test "gets called with casted attributes" do + changeset = + Tag + |> Ash.Changeset.new() + |> Ash.Changeset.change_attribute(:priority, "3") + |> Ash.Changeset.update_change(:priority, &(&1 + 1)) + |> Ash.Changeset.for_create(:create) + + assert %Ash.Changeset{attributes: %{priority: 4}} = changeset + end + end end diff --git a/test/resource/changes/update_change_test.exs b/test/resource/changes/update_change_test.exs new file mode 100644 index 00000000..8315b351 --- /dev/null +++ b/test/resource/changes/update_change_test.exs @@ -0,0 +1,93 @@ +defmodule Ash.Test.Resource.Changes.UpdateChangeTest do + @moduledoc false + use ExUnit.Case, async: true + + defmodule TimeMachine do + @moduledoc false + def double_age(42) do + raise "boom" + end + + def double_age(age) when is_integer(age) and age >= 0 and age < 200 do + age * 2 + end + end + + defmodule Author do + @moduledoc false + use Ash.Resource, data_layer: Ash.DataLayer.Ets, domain: Ash.Test.Domain + + ets do + private?(true) + end + + actions do + create :double_age do + accept([:name, :age]) + change update_change(:age, &TimeMachine.double_age/1) + validate numericality(:age, less_than: 200) + end + + create :triple_age do + accept([:name, :age]) + change update_change(:age, fn age -> age * 3 end) + end + end + + attributes do + uuid_primary_key :id + attribute(:name, :string, public?: true, allow_nil?: false) + attribute(:age, :integer, public?: true, allow_nil?: false, constraints: [min: 0]) + end + end + + describe "update_change builtin" do + test "works with valid change" do + author = + Author + |> Ash.Changeset.for_create(:double_age, %{name: "foo", age: 20}) + |> Ash.create!() + + assert author.age == 40 + end + + test "works with inline function" do + author = + Author + |> Ash.Changeset.for_create(:triple_age, %{name: "foo", age: 20}) + |> Ash.create!() + + assert author.age == 60 + end + + test "calls update function with casted attribute" do + author = + Author + |> Ash.Changeset.for_create(:double_age, %{name: "foo", age: "20"}) + |> Ash.create!() + + assert author.age == 40 + end + + test "doesn't call update function with failed constraint" do + {:error, _} = + Author + |> Ash.Changeset.for_create(:double_age, %{name: "foo", age: -5}) + |> Ash.create() + end + + test "doesn't call update function with missing required attribute" do + {:error, _} = + Author + |> Ash.Changeset.for_create(:double_age, %{name: "foo"}) + |> Ash.create() + end + + test "doesn't call update function with other changeset errors" do + {:error, _} = + Author + |> Ash.Changeset.for_create(:double_age, %{age: 42}) + |> Ash.create() + end + end +end