Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Ash Simple DataLayer Demo

simple_data_layer.livemd

Ash Simple DataLayer Demo

Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)

Mix.install([{:ash, "~> 3.0"}, {:yaml_elixir, "~> 2.9"}],
  consolidate_protocols: false
)

File.cd!(__DIR__)

Introduction

This short demo illustrates how to back an Ash resource with a simple data layer. For example, our app might need information about planets in the Solar System, and it’s convenient to store them in yaml/*.yaml files on disk, one file per planet:

File.read!("yaml/mars.yaml") |> IO.puts

We could of course store the planet information in simple maps, but there are benefits to having them as Ash resources:

  • We can easily define validation rules
  • We can define calculations and aggregations using the familiar expression syntax
  • We can define policies, use them in relationships, etc.

Version 1: Creating planets from maps stored in YAML files

Let’s define an Ash resource representing a planet:

defmodule Planet do
  use Ash.Resource, domain: Demo

  attributes do
    attribute :num, :integer, primary_key?: true, allow_nil?: false, public?: true
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :year_discovered, :integer, public?: true
    attribute :mass, :float, allow_nil?: false, public?: true
    attribute :radius, :float, allow_nil?: false, public?: true
  end

  actions do
    defaults [create: :*]
  end
end

defmodule Demo do
  use Ash.Domain
  
  resources do
    resource Planet
  end
end

We can now create resources from maps and get validation for free. For example, the naïve import works for Uranus

uranus = YamlElixir.read_from_file!("yaml/uranus.yaml")
Planet |> Ash.Changeset.for_create(:create, uranus) |> Ash.create!()

but it doesn’t work for Earth because year_discovered has an incorrect value:

earth = YamlElixir.read_from_file!("yaml/earth.yaml")
Planet |> Ash.Changeset.for_create(:create, earth) |> Ash.create!()

Version 2: Loading planets at compile time

Since the list of planets is fixed and won’t change, we can load the resources at compile time and use Ash.DataLayer.Simple.set_data to define a :read action. We can also define some calculated fields:

defmodule Planet2 do
  use Ash.Resource, domain: Demo2

  attributes do
    attribute :num, :integer, primary_key?: true, allow_nil?: false, public?: true
    attribute :name, :string, allow_nil?: false, public?: true
    attribute :year_discovered, :integer, public?: true
    attribute :mass, :float, allow_nil?: false, public?: true
    attribute :radius, :float, allow_nil?: false, public?: true
  end

  @planets Path.wildcard("yaml/*.yaml")
           |> Enum.map(fn path ->
             YamlElixir.read_from_file!(path)
           end)
           |> Enum.map(fn planet ->
             Map.update!(planet, "year_discovered", fn x -> if is_binary(x), do: nil, else: x end)
           end)

  actions do
    defaults [create: :*]

    read :read do
      primary? true

      prepare fn query, _context ->
        planets =
          @planets
          |> Enum.map(fn planet ->
            __MODULE__ |> Ash.Changeset.for_create(:create, planet) |> Ash.create!()
          end)

        Ash.DataLayer.Simple.set_data(query, planets)
      end
    end
  end

  calculations do
    calculate :volume, :float, expr(3.1415926 * radius * radius * radius)
    calculate :bigger_than_earth?, :boolean, expr(1 < radius)  # Earth's radius is 1.0 in the data
    calculate :density, :float, expr(mass / volume)
  end
end

defmodule Demo2 do
  use Ash.Domain

  resources do
    resource Planet2
  end
end

Properly setting the simple data layer is all that is needed to get full Ash functionality. For example, we can now filter the Planet2 resource by a calculated field:

require Ash.Query

Planet2 |> Ash.Query.filter(bigger_than_earth? == true) |> Ash.read!() |> Enum.map(&amp; &amp;1.name)

or get a resource given its primary key and load its calculated fields:

Planet2 |> Ash.get!(4, load: [:volume, :density])

For small datasets, we can probably ignore the query parameter, but for bigger or more complex cases, we can handle special cases. For example, Ash.get! uses a filter id == internally, which would set the query.filter.id field, so we can define more complex logic in our prepare statement.