mirror of
https://github.com/ash-project/ash.git
synced 2024-09-20 05:23:03 +12:00
feat: add update_change
function and builtin change (#976)
This commit is contained in:
parent
8249b6cabd
commit
7d75e64d86
5 changed files with 227 additions and 0 deletions
|
@ -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.
|
||||
"""
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
12
lib/ash/resource/change/update_change.ex
Normal file
12
lib/ash/resource/change/update_change.ex
Normal 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
|
|
@ -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
|
||||
|
|
93
test/resource/changes/update_change_test.exs
Normal file
93
test/resource/changes/update_change_test.exs
Normal 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
|
Loading…
Reference in a new issue