Learning Elixir, Phoenix and Ash Part 6: Validations
I have hit my first WAT? moment with Ash and it is related to resource validations.
I did a review of where my code is at, looking for opportunities to clean up the code and I noticed that I have implemented resource āvalidationā in several different ways. This has come about because Iām feeling my way with Ash, reading different docs and looking at different sample code.
It doesnāt make sense to implement what feels like the same functionality in three different ways so I decided to make the code consistent. But when I started to refactor the code I saw some behaviour related to attributes that I didnāt understand. When something isnāt making sense I write tests.
Letās start with a Part resource and a simple test. The Part
uses the defaults
actions.
defmodule MyApp.Factories.Part do
...
actions do
defaults [:create, :read, :update, :destroy]
end
end
defmodule MyApp.Factories.PartTest do
...
describe "create a part" do
test "with no attributes" do
Part
|> Ash.Changeset.for_create(:create, %{})
|> Ash.create!()
end
end
end
This passes. But not setting any fields is no use so letās try the creating a Part
with attributes
defmodule MyApp.Factories.PartTest do
...
describe "create a part" do
test "with attributes" do
Part
|> Ash.Changeset.for_create(:create, %{number: "1001", name: "Test Part"})
|> Ash.create!()
end
end
end
This fails with a useful error
Invalid Error
* No such input `name` for action MyApp.Factories.Part.create
The attribute exists on MyApp.Factories.Part, but is not accepted by MyApp.Factories.Part.create
The attribute is currently `public?: false`, which means that it is not accepted when using `:*`, i.e in `accept :*`.
Perhaps you meant to make the attribute public, or add it to the accept list for MyApp.Factories.Part.create?
This is because we need to tell Ash that we are allowed to pass in arguments to the create
function. Notice that it knows we have a name
attribute on the Part
resource. And Ash helpfully tells us a couple of ways to fix this. Nice.
Perhaps you meant to make the attribute public, or add it to the accept list for MyApp.Factories.Part.create?
Private attributes are a good thing so lets add the attribute to the accept
list. To do this we have to define a create action and add an accept
list with the arguments we want to pass in. We have to remove create
from the defaults
list otherwise weāll get a compiler error saying we have defined the same function twice.
defmodule MyApp.Factories.Part do
...
actions do
defaults [:read, :update, :destroy]
create :create do
accept [:number, :name]
end
end
end
This passes. We can create a Part
with a name
and number
. But now we want the first test to fail. We donāt want to be able to create a part without a number
or a name
. We need prevent nil
values being set for these fields.
Presence
There seems to be several ways to do this. First letās try validating the arguments to the create
action. We do this by declaring number
as an argument
instead of accept
and disallowing nil
values.
defmodule MyApp.Factories.Part do
...
actions do
defaults [:read, :update, :destroy]
create :create do
accept [:name]
argument :number, :string do
allow_nil? false
end
end
end
end
Invalid Error
* argument number is required
Thatās good. Our first test now fails because we didnāt pass in the number. But what about if we add a not nil
constraint on the number attribute instead?
defmodule MyApp.Factories.Part do
...
attributes do
uuid_primary_key :id
attribute :number, :string do
allow_nil? false
end
end
end
Invalid Error
* attribute number is required
OK, same but different. Letās try validations
defmodule MyApp.Factories.Part do
...
validations do
validate present(:number)
end
Invalid Error
* Invalid value provided for number: must be present.
That works too. And there is a fourth which we havenāt tested which is a not null
constraint in the database1. But why are there three ways to achieve the same thing. These arenāt extactly the same obviously, but they achieve the same end goal of ensuring that the resource attribute is set. Which should I use?
The current test just tests that we donāt get an error when we try to create a Part. The next thing to do is test that we are actually creating a valid object by checking the values. Letās extend the second test to make sure weāre setting the attributes
defmodule MyApp.Factories.PartTest do
...
describe "create a part" do
test "with attributes" do
part =
Part
|> Ash.Changeset.for_create(:create, %{number: "1001", name: "Test Part"})
|> Ash.create!()
assert part.number == "1001"
assert part.name == "Test Part"
end
end
end
When we repeat the process above using the allow_nil: false
on the attribute
our test now fails with
1) test create a part with attributes (MyApp.Factories.PartTest)
test/my_app/factories/part_test.exs:30
Assertion with == failed
code: assert part.number == "1001"
left: nil # <---
right: "1001"
stacktrace:
test/my_app/factories/part_test.exs:36: (test)
Even though we are passing in the value as an argument to the create action itās not being set. The first test with attributes wasnāt working after all. This behaviour had me stumped for a long time. Why arenāt the arguments to the action being persisted to the resource?
I have one file in my app that was generated by the framework (Ash Authentication) and thatās the User
resource. The relevant bits are below.
defmodule MyApp.Accounts.User do
...
actions do
create :register_with_password do
...
argument :email, :ci_string do
allow_nil? false
end
change set_attribute(:email, arg(:email)) # <---
end
end
...
end
This seems to indicate that using the argument
is the way to do it, maybe, because this file doesnāt have a validation
on the email
field. But you can see this code explicitly sets the attribute using the email
argument. Thatās the bit I was missing. I spoke with my buddy James and he said that action attributes are used when you pass in an argument that isnāt a field on the resource, eg you need arguments to derive a field value. But then you have to explicitly tell Ash to set the field. An example in the User
resource is the password
and password_confirmation
. These arenāt fields of the resource, they are used to construct the hashed password. This makes sense, but then why is the email passed in as an argument; the email is a field?
I have another project that I have been using as reference and it follows a similar pattern of using arguments
instead of validations
. Despite this I have settled on validations
for my code and made all the resources consistent. This is my test.
describe "create a part" do
test "with no attributes", %{factory: factory} do
{:error, changeset} =
Part
|> Ash.Changeset.for_create(:create, %{})
|> Ash.Changeset.set_tenant(factory.id)
|> Ash.create()
assert Enum.any?(changeset.errors, fn error ->
error.field == :name && error.message =~ "must be present"
end)
assert Enum.any?(changeset.errors, fn error ->
error.field == :number && error.message =~ "must be present"
end)
end
test "with attributes", %{factory: factory} do
{:ok, part} =
Part
|> Ash.Changeset.for_create(:create, %{number: "1001", name: "Test Part"})
|> Ash.Changeset.set_tenant(factory.id)
|> Ash.create()
assert part.number == "1001"
assert part.name == "Test Part"
end
end
So far I have only implemented CRUD actions. Soon Iāll need to add more complicated interactions between my resources, maybe then it will become clear why there needs to be so many functions that seem overlap so much. At the moment it feels like unnecessary complexity.
Uniqueness
If we look at Ash's built in validations we see there is one glaring omission (as a Ruby on Rails developer); thereās no uniqueness
validation. Unlike Ruby on Rails where uniqueness is just another validation, in Ash unique constraints are handled with Identities.
identities do
identity :unique_number_per_factory, [:number, :factory_id],
message: "Part number must be unique within a factory"
identity :unique_name_per_factory, [:name, :factory_id],
message: "Part name must be unique within a factory"
end
One thing about identities
is that (it appears) their default behaviour is to do nothing. You can add an identity
but unless you generate and apply a migration the constraint wonāt be applied. I discovered this when my tests failed. I get the reasoning behind this. Unique validations in Ruby on Rails are problematic because they suffer from race conditions. They should be handled at the database level. There is a way to ask Ash to check for uniqueness before committing to the database; eager_check?
and pre_check?
. I discovered these while I was writing this post2 but at the moment Iāve chosen not to use them and just lean on the database. If you arenāt using a relational database as your back end then itās worth reading about these options
As I said earlier I feel like this is all a bit too complicated. I think you could probably get away with any combination of two of the āvalidationā methods and be fine. For example, do away with validations
and use attribute
allow_nil? false
to generate the validation?
attributes do
uuid_primary_key :id
attribute :number, :string do
allow_nil? false
end
end
I know one of Elixir and Phoenixās āthingsā is prefer explicit over implicit behaviour, but Ash is all about implicit. Thereās no Elixir anywhere. PragProg have an Ash book in beta (at time of writing). Iām going to buy it and see if it explains the reasoning for this.
Anyway, validations are in place. Now I can build some forms.