A title for your blog

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.


  1. I don’t count the database constraint because that’s happening outside the framework. If you hit the database constraint you get an Unknown Error and the bare Ecto error.

  2. I really need to RTFM more ĀÆ\_(惄)_/ĀÆ

#ash #elixir #phoenix