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(& &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.