feat: add update_change function and builtin change (#976)

This commit is contained in:
Riccardo Binetti 2024-04-07 11:02:38 +02:00 committed by GitHub
parent 8249b6cabd
commit 7d75e64d86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 227 additions and 0 deletions

View file

@ -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.
"""

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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