A Gentle Primer to Ash

Learn by Trying

LiveBook is the interactive notebook for Elixir (like Jupyter/Quarto). It is quick and foolproof to setup, and let you tinker with code along with explanations on the side.

For this tutorial, I have prepared a set of LiveBooks (each corresponding to a section) as well as the CSV needed. Instead of just reading, you can run the code and see it in action. I encourage you to experiment and see what happens.

Ash Framework (🔗 website) is a set of tools for Elixir, built by Zach Daniels (@ZachSDaniel1) with sponsorship of Alembic. It is incredibly thoughtful and thorough. The exhaustiveness makes it hard to know what Ash does; the documentation is excellent but is perhaps appeals most to those who have written things the wrong way (perhaps many times) before; dwells in the abstract, and the usage of changeset / Ecto (Elixir’s database “ORM”) might put off some beginners. And I think Ash has a lot to offer for beginners, a tool to grow with and grow into.

This tutorial is a gentle primer, and aims to be approachable in two ways. We will lean on analogies and pictures, and we will use a minimal setup — only LiveBooks and two plain-text CSV files. No formal database, no need to setup a development environment. CSV is a relatable use-case — “the standard way” probably shows code you wrote before. It being a bunch of flat files also make it easy to see what Ash have done for us. If you are like me, there will be a few moments that make you go, “Wait. Did that just happen? How?” It justifies using Ash.

This is going to be a single looong tutorial that is written in public, and will eventually come with some videos, the accompanying LiveBooks / CSVs, and perhaps some notes. I assume basic familiarity with Elixir syntax but no

Before we introduce what we are trying to work with (and hand-roll a solution), let’s try to Explain Ash Like I am Five.

Ash Framework: ELI5

We want a banana cake. We are willing to gather the ingredients, and then think (!) and write (!!) to bring that banana cake about.

We could choose to write a recipe: an ordered list of specific actions. Recipes are procedural:

  1. measure out 100 g of butter, 250 g flour, 100 g sugar, 250 cm3 water, 20 g baking soda, 10 g baking powder
  2. take out 3 ripe bananas and 2 large eggs
  3. using a whisk, beat the eggs
  4. mash the bananas with a fork
  5. melt the butter using a steamer over low fire
  6. add first the wet ingredients, then the dry ingredients, to the Panasonic PM106 bread-maker
  7. use the “#19 quick bread” feature

We call this “do this, do that” the imperative style.

We could, instead, write a work order / wish-list / specification. This (mostly unordered) list is aspirational:

Banana Bread:

  • sweetness: not-too-sweet
  • gluten-free: true
  • vegan: false
  • nuts: [lightly toasted walnut]
  • extras: dulce de leche1

We call this the declarative style.

With Ash Framework, you write work orders and, magically 💫 🔮, the work gets done.

If you are actually 5 years old, magic sounds great. I am guessing you just pretend to be five: the word Magic and those 💫 🔮 emojis brings you bitter memories, of being disillusioned, or of javascript frameworks. Let’s go a little deeper.

Under the imperative recipe paradigm, instructions are precise. Under the control hides brittleness. If the kitchen doesn’t have a bread-maker, or don’t have the right model of the bread-maker, all bets are off. If we want gluten-free banana bread, we will add a nice foot-note “Use Bob Red Mill’s 1-for-1 Gluten Free flour, or a 4:5:1 mix of […]”. Over time, the 7 steps recipe becomes an entangled mess of foot-notes, many conflicting with one another. Dad and mom argue if they should refactor the recipe.

With the declarative work order paradigm, nothing was ever precise. The outcome depends on:

  1. the craftsmanship of the baker taking the work order,
  2. your understanding of what the baker’s limits are, and
  3. whether the baker is too proud to let you into the kitchen.

Coming back to code-land, Ash-the-Baker has some hard-won skills and despite that, stays pretty humble.

  • Don’t have a database? Just substitute Ash.DataLayer.ETS. Or AshCsv like in the upcoming tutorial. Ash is aware of whether you have a database, and thus when to construct SQL queries and when to do the queries in Elixir. (Why use a steamer when you have a microwave?)
  • Gluten-free? Vegan? Ash accommodates most standard requests out of the box.
  • Want your finished banana bread in GraphQL or JSON shapes? No problem.
  • You want to add some some Chili Pepper Infused Garlic Jam™ per your family tradition? Ash lets you in the kitchen to do that part. (Plenty of escape hatches.)

Keep this analogy of recipe vs. work order in mind as we work through Professeur Bunsen’s request.

The Scenario

Prof Bunsen teaches a Potions class. He has already gave tests and a potion-brewing lab, and collected the data into a spreadsheet:

He gave this to you as a CSV file potions_grade.csv, and asks:

  1. calculate the term grade for each student, which is a weighed average of 60% Tests and 40% lab
  2. Adam was left out in the spreadsheet. Could you please add him back in? Oh yea, and I’d like the CSV updated too.
  3. Mozart is German! Please change his name back to Gottlieb in your system. Oh yea, and I’d like the CSV updated too.
  4. can you give me a list, highest score first, of those who passed?

The Potions class made us Elixir devs, and we are glad to help.

We will first do this the standard way, which included:

  • [1-1] extracting data from the CSV,
  • [1-2] transforming the data into something useful, and
  • [1-3] loading the data out to file/screen that fulfil Professor’s requests.

The numbering in Chapter 1 reflects the sequence of steps we take to solve the task. We will refer to this common pattern of Extract -> Transform -> Load by its acronym ETL.

We will then do this the Ash way, which become a taster for the following features one at a time:

  • [2-1] resources and API
  • [2-2] constraints
  • [2-3] calculations & queries
  • [2-4] (bonus) relating the wizard_houses.CSV to scores

The numbering in Chapter 2 reflects our learning progression. We flesh out details of the work order in place.

The following zip file contains LiveBooks corresponding to the chapters.

1. The Standard Way

We make a folder that contains the potions_grade.csv, and create a new LiveBook in there. Our LiveBook is running in standalone mode, and we use the setup dropdown to select the CSV hex package. This is a convenience feature that adds selected packages as a Mix.install.

(The code here breaks each conceptual step into its own procedure, sacrifice terseness and efficiency. If you are not new to Elixir, and just want to see Ash, skip to Chapter 2.)

Video walkthrough of Section 1. The content is parallel with the article and the LiveBooks, and split up into one per section.

1-1. Extracting the CSV

We can refer to the directory that the LiveBook is in (and where potions_grade.csv is located) using __DIR__.

csv_path = __DIR__ <> "/potions_grade.csv"

class_data =
  csv_path
  |> File.stream!([:trim_bom])
  |> CSV.decode!(headers: true)
  |> Enum.take(12)

This gives us a list of maps that is stored in the class_data variable:

[
  %{
    "first_name" => "Rachel",
    "house" => "lion",
    "last_name" => "Rodríguez",
    "practical_score" => "14",
    "student_id" => "101",
    "test_1_score" => "57",
    "test_2_score" => "35"
  },
...
]

Notice that all the map keys are strings, and so are all the values. We need to do some transformation of the map before we can answer Prof Black’s queries.

1-2. Transforming the data

Let’s start by making the map keys into atoms. The outer for comprehension iterates over each student map, whereas the nested for comprehension loops over each pair of key-value within the map, returning the atomized key.

class_data =
  for student <- class_data do
    for {k, v} <- student, into: %{} do
      {String.to_atom(k), v}
    end
  end

Using into: %{} collects each processed student map back as a map, which would otherwise default to collecting in a list.

But all the values are still strings, and we can’t do calculations on strings. Let’s fix that by converting what we know ought to be integers into integers (normally, of course, you’d atomize the keys and convert to integers in one step):

class_data =
  for student <- class_data do
    for {k, v} <- student, into: %{} do
      case k do
        keys when keys in [:student_id, :test_1_score, :test_2_score, :practical_score] -> {k, String.to_integer(v)}
        _ -> {k, v}
      end
    end
  end

We now have our class_data in a format we’d like to work with:

[
  %{
    first_name: "Rachel",
    house: "lion",
    last_name: "Rodríguez",
    practical_score: 14,
    student_id: 101,
    test_1_score: 57,
    test_2_score: 35
  },
...
]

This makes it straight-forward to transform, for example, adding Adam (alas, we didn’t think to check that the test_score should be between 0-100):

class_data =
  class_data ++
    [
      %{
        student_id: 201,
        first_name: "Adam",
        last_name: "Added",
        test_score: 321,
        lab_score: 23
      }
    ]

Or changing Mozart’s name back to Gottlieb (alas, having kept the students’ names as a list, we will have to traverse the list again):

class_data =
  for %{first_name: first_name, last_name: last_name} = student <- class_data do
    case first_name == "Mozart" and last_name == "Amadeus" do
      true  -> %{student | last_name: "Gottlieb"}
      false -> student
    end
  end

It is also easy to calculate the weighted grade:

class_data =
  for student <- class_data do
    weighed_average = 
      (0.6*student[:test_score]) + (0.4*student[:lab_score])
    Map.put_new(student, :weighed_average, weighed_average)
  end
[
  %{
    first_name: "Rachel",
    house: "lion",
    last_name: "Rodríguez",
    practical_score: 14,
    student_id: 101,
    test_1_score: 57,
    test_2_score: 35,
    weighed_average: 28.9
  },
...
]

It all seem good to us, but alas, Professor just realized that Polly got married, and her last_name is now Anna. He edited the shared CSV, but that of course has no effect on what we have loaded into memory.

1-3. Loading back into CSV

Let’s write the new map, with the calculated averages, back into a CSV for on-disk storage:

output_path = "#{__DIR__}/potions_grade_new.csv"
new_csv =
  class_data
  |> CSV.encode(headers: true)
  |> Enum.to_list()

File.write!(output_path, new_csv)

So far so good. When Prof Black gives you another CSV file, you might extract some of the common parts into a module, but chances are there’s going to be some unnecessarily bespoke code that is left over. And as the project grows, the logic starts to dribble in various code fragments.

2. The Ash Way

2-1. Resources & API

The gradebook we had is what we would call a Resource in Ash. You can think of Resources as some class of nouns which have their own internal structure and logic. Our gradebook, for example,2

  • came from a CSV file and should be stored back into it
  • has one entry per student;
  • each student (each entry) is uniquely identified by a student number (no student can share the same number);
  • students have a first name and a last name (but not gender or age etc);
  • there are only two scores (test and lab) and they should be numbers between 0 and 100.
  • some values, like the weighed average, depends on other values and should be calculated when we need them, lest we end up with test_score 100, lab_score 100, weighed_average 50 where we cannot know where the discrepancy arises

We can write these “internal structure and logic” concisely and unambiguously for Ash, using its domain specific language (DSL).

Another low production video 🥲, this time for [2-1].

Elixir equivalent syntax

Before we get into the Ash DSL, a reminder that Elixir gives us certain syntax simplification. The following code are identical:

#1
eat(monkey, :banana, [:speed => quickly, :quantity => 3])


#2a
monkey
|> eat(:banana, [speed: quickly, quantity: 3])

#2b
monkey
|> eat(:banana, speed: quickly, quantity: 3)

#3
eat monkey, :banana, [speed: quickly, quantity: 3]

#4
eat monkey, :banana, speed: quickly, quantity: 3

#1 is a standard function. The first argument can be pushed into the function using the pipe |> operator and that gives us #2a. The last argument is a keyword list, and when a keyword list is the last argument the [ ] can be omitted (giving us #2b). Atom assignments can be expressed as “inverting : placement”, so :quantity => 3 and quantity: 3 are equivalent.

A functions’ parentheses ( ) can be omitted when that does not introduce ambiguity (#1 -> #3b). And if we take out the keyword list square brackets, we get the very terse #4.

Ash code is conventionally written with #3 style. The following are some typical examples and what they really are:

allow_nil? true

# this is just a function call to allow_nil?/1
allow_nil?(true)
attribute :test_score, :integer do
  constraints [
      max: 100,
      min: 0
      ]
end

# again this is a function call, this time to constraints/1, which assigns :max and :min
constraints([:max => 100, :min => 0])

It becomes quite natural. You will, however, need to do some work with the formatter.exs settings to make sure the Ash DSL doesn’t get eaten by your editor.

Writing our minimal Resource

Ash Resources are just a module, so we can start with

defmodule Grade do

end

To make Elixir recognize that Grade should behave like an Ash resource, we tell it with a use expression:

defmodule Grade do
  use Ash.Resource
end

Our gradebook retains its internal logical and structure, regardless of whether it is stored in (backed by) a database, in-memory, a CSV file, or not at all persisted. To tell Ash that we are choosing (today) to use CSV as source/storage, we specify the data_layer option.

defmodule Grade do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer
end

Now we need to state some facts about the CSV file, like where it is to be found, does it have a header, and what are the columns:

defmodule Grade do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    file "#{__DIR__}/potions_grade_ash.csv"
    header? true

    columns [
      :id,
      :first_name,
      :last_name,
      :test_score,
      :lab_score
    ]
  end
end

Our “internal structure and logic” states that, for each entry, we should keep some information, in a specific type. In Ash, each piece of information is called an Attribute, and they are declared inside an attributes do ... end block.

defmodule Grade do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    file "#{__DIR__}/potions_grade_ash.csv"
    header? true

    columns [
      :id,
      :first_name,
      :last_name,
      :test_score,
      :lab_score
    ]
  end

  attributes do
    attribute :id,         :integer do
      primary_key? true
      allow_nil?   false
    end

    attribute :first_name, :string
    attribute :last_name,  :string
    attribute :test_score, :integer
    attribute :lab_score,  :integer
  end
end

Notice that each attribute could have just a type and no other clues, in which case we omit the do...end block. What I tend to do is to start with a minimum specification, and come back to flesh it out. This is easy: the specifications are all in one place, and you will not need to dig through files and lines to find where the logic was implemented.

We have so far specified the structure. What about the logic? What verbs should our gradebook accept? The verbs it accepts is stated inside an actions do ... end block, and we can for now tell Ash we haven’t thought very far, so let’s just do the defaults:

defmodule Grade do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    file "#{__DIR__}/potions_grade_ash.csv"
    header? true

    columns [
      :id,
      :first_name,
      :last_name,
      :test_score,
      :lab_score
    ]
  end

  attributes do
    attribute :id,         :integer do
      primary_key? true
      allow_nil?   false
    end

    attribute :first_name, :string
    attribute :last_name,  :string
    attribute :test_score, :integer
    attribute :lab_score,  :integer
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end

Our baby gradebook specification is now complete, but even (especially?) in this state, we should not be letting everyone tamper with it. People who want to get access should go through a manager.

API

The manager, unsurprisingly, is also just an Elixir module, with behaviours specified by Ash. The manager is the Interface to all of our resources, and thus the behaviour is Ash.Api (application programmatic interface). Let’s call this PotionsClass; maybe we will use it to help manage the potions that the students brew too.

defmodule PotionsClass do
  use Ash.Api

  resources do
    resource Grade
  end
end

Right now the manager is pretty permissive (anyone can come) and create/read/update/destroy using our default actions. The manager can be taught to be suspicious (needing special credentials for different operations), and package our gradebook into different formats (e.g., JSON, graphQL). But that is for another day.

When I write Ash, I tend to start with this API block, listing out the Resources. Then I go into writing minimum specifications for each resource, their relationships, and then come back to flesh out the attributes/actions, and finally back to the API laying out how these should be accessible.

2-2. Constraints

Adding details to our attributes

By stating attribute :test_score, :integer, we ask Ash to cast the CSV string into an integer. We can do ... end better.

There are two categories here. The first category of options, such as allow_nil?/1, can be applied to any attribute. The second category of options is properly termed constraints. What constraints you can apply on an attribute depends on the type. :integers, for example, are simple and accepts only :max and :min; :string can be tested against a regex pattern; atoms can be checked such that only :open and :closed can be valid. And so on. There are currently 25 built-in types, and you can find 🔗 documentation here. (Keeping with Ash providing escape hatch (“letting you in the kitchen”), you can define your own types too.)

In our gradebook, we assume that every field must be filled in (that is, all students should have all of first_name, last_name, test_score, and lab_score), so we can expand all attributes into a do ... end block, and setting allow_nil? to be false. We can additionally add a type-specific constraint to our scores, so that mistakes like setting a score to 321 cannot be written into the CSV. Our completed attribute section would look like this:

  attributes do
    attribute :id, :integer do
      primary_key? true
      allow_nil? false
    end

    attribute :first_name, :string do
      allow_nil? fals
    end

    attribute :last_name, :string do
      allow_nil? false
    end

    attribute :test_score, :integer do
      allow_nil? false
      constraints [
        max: 100,
        min: 0
      ]
    end

    attribute :lab_score, :integer do
      allow_nil? false
      constraints [
        max: 100,
        min: 0
      ]
    end
  end

Later on you will see Ash applying the constraints when we try to bring Adam in while providing 321 as his test_score. Ash will reject our changeset (proposal for changes), and explain clearly why it rejected the proposal. But first, an interlude about what we have written so far.

Interlude: Our situation

By now we have the following structure, a Grade resource that is backed by the potions CSV datalayer, and accessed through the PotionsClass API:

A larger project may have multiple resource inside an API, each connected to their own datalayer and can be accessed in other shapes, but it share similar structure.3

As your project grows, there may be multiple APIs, and resources relating within an API and outside the API:

Over time, your specification becomes more intricate, as you bring in calculated fields, policies for who can access the resources, keeping a paper trail for changes, double-entry book keeping etc. But for the most part there is a standard organization.

For those of you thinking ahead, you may be asking how these must be organized in files and folders. Ash is agnostic to where you place your files / folders, so in short there is no “must”. There is 🔗 suggestion for good practice, however, and it differs slightly from standard Elixir / Phoenix Context practices, so you may want to consult the suggestion and decide how you want to organize your projects.

Seeing our new rules in action

We have our work order drafted; let’s pass it to Ash and see it in action.

To read, we go through our API module PotionsClass:

PotionsClass.read!(Grade)

# which is equivalent to
Grade
|> PotionsClass.read!

This gives us a list of Grade structs:

[
  %Grade{
    __meta__: #Ecto.Schema.Metadata<:built, "">,
    id: 101,
    first_name: "Rachel",
    last_name: "Rodríguez",
    test_score: 57,
    lab_score: 14,
    aggregates: %{},
    calculations: %{},
    __metadata__: %{selected: [:id, :first_name, :last_name, :test_score, :lab_score]},
    __order__: nil,
    __lateral_join_source__: nil
  }, ...
]

You can see that Ash Resources use Ecto (Elixir’s “ORM”) under the hood, but they are otherwise “just a struct” and we can handle them just like any other maps:

Grade
|> PotionsClass.read!()
|> Enum.map(&(&1.first_name))
|> Enum.sort()
["Ala", "Go", "Jeong-Hui", "Jikky", "Mark", "Mozart", "Polly", "Rachel", "Severus", "Sue",
 "Zach", "花道"]

To create/update resources, we first make a changeset, a proposal of changes. Ash-the-Baker will check the proposal, and let you know if what you propose is valid. Let’s try to create Adam:

change_proposal =
  Ash.Changeset.for_create(
    Grade,
    :create,
    %{
      id: "new student",
      first_name: "Adam",
      last_name: "Added",
      test_score: 321, # <- yikes typo
      lab_score: 23
    }
  )
#Ash.Changeset<
  action_type: :create,
  action: :create,
  attributes: %{first_name: "Adam", last_name: "Added", lab_score: 23},
  errors: [
    %Ash.Error.Changes.InvalidAttribute{
      field: :test_score,
      message: "must be less than or equal to %{max}",
      value: 321,
      ...
    },
    %Ash.Error.Changes.InvalidAttribute{
      field: :id,
      message: "is invalid",
      value: "new student",
      ...
    }
  ],
  data: ...,
  valid?: false
>

Ash examines the proposal declares it invalid. Notice that the errors are listed for you, and informative. If you submit the proposal and ask for the change in one step you will get the errors in your terminal. Let’s fix the errors.

change_proposal =
  Ash.Changeset.for_create(
    Grade, # we want the for_create function to act on a Grade resource,
    :create, # using the :create action (that was default)
    %{ # with this map as the data source
      id: 150,
      first_name: "Adam",
      last_name: "Added",
      test_score: 32,
      lab_score: 23
    }
  )

change_proposal
|> PotionsClass.create!()

This passes, and perhaps surprisingly, the changes are automatically reflected in your CSV file. Ash managed that for you.

Note that the idiomatic way of writing the above is using a pipeline. This is what you will see in the official documentation:

Grade
|> Ash.Changeset.for_create(
    :create,
    %{
      id: 150,
      first_name: "Adam",
      last_name: "Added",
      test_score: 32,
      lab_score: 23
    }
  )
|> PotionsClass.create!()

2-3. Calculations & Queries

For weighed_average and full_name, they should be derived from the scores and names, but not as separate fields (columns). Imagine when we see “Polly”, “Polley”, “Polly Anna”; it is not clear whether Polly has married and changed her full name (but a mistake means we forgot about the last name), or the mistake is made in the full name.

For these derived fields, we use calculations. As you may expect by now, Ash provides within Resources the calculations block:

defmodule Grade do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    [...]
  end

  attributes do
    [...]
  end

  calculations do
    # our calculate do-end blocks go here
  end

  actions do
    [...]
  end
end

Each calculate block takes the name of the field, its type, and an Ash expression. Ash expressions, wrapped inside an expr(), let you reference field names as variables. Calculates are not confined to numerical calculations, but could be a wide variety of Elixir code. We will show some integer, string, and conditional here:

  calculations do
    calculate :weighed_average, :float, expr(0.6 * test_score + 0.4 * lab_score)
    calculate :full_name, :string, expr(first_name <> " " <> last_name)
    calculate
      :pass,
      :boolean,
      expr(
        if weighed_average >= 60 do
          true
        else
          false
        end
      )
  end

If you now try to PotionsClass.read!(Grade), you will see response with weighed_average: #Ash.NotLoaded<:calculation>. Calculations can be heavy, and in other ORMs the implicit loading can cause performance problems that are gnarly to track down. Ecto/Ash requires them to be explicitly loaded, and the load/1 function is located inside Ash.Query.

require Ash.Query

Grade
|> Ash.Query.load([:weighed_average, :full_name, :pass])
|> PotionsClass.read!()
[
  #Grade<
    full_name: "Rachel Rodríguez",
    pass: false,
    weighed_average: 39.8,
    __meta__: #Ecto.Schema.Metadata<:built, "">,
    id: 101,
    first_name: "Rachel",
    last_name: "Rodríguez",
    test_score: 57,
    lab_score: 14,
    aggregates: %{},
    calculations: %{},
    ...
  >,
   ...
]

After we load a calculated value, it is part of the map and can be used in filter and sort as normal:

passing_students =
  Grade
  |> Ash.Query.load([:pass, :full_name])
  |> Ash.Query.filter(pass == true)
  |> Ash.Query.sort(:first_name)
  |> PotionsClass.read!()
  |> Enum.map(& &1.full_name)
  |> dbg
["Go Ku", "Jikky Mouse", "Mozart Gottlieb", "Polly Polley", "Severus Snape", "Zach Daniels"]

Fun thing to try here. Open the CSV file in an editor, and manually change Polly’s last name to Anna. Re-run the above cell 🤯 I mean, how?

Ash.Query now lets us pick out a single entry for update, so let’s change Mozart’s last_name:

[mozart] =
  Grade
  |> Ash.Query.filter(first_name == "Mozart")
  |> PotionsClass.read!() # this returns a list containing one %Grade{}

mozart
|> Ash.Changeset.for_update(:update, %{last_name: "Gottlieb"})
|> PotionsClass.update!()

The change is propagated to our datalayer (csv). In the future, there will be a Ash.DataLayer.Multi option where the changes can go into your sqlite file at the same time.

Let’s recap: we have declared a Resource that is backed by a CSV DataLayer, with some on-the-fly fields. We are able to read, create, and update entries. You may feel that that it is all too clumsy, and wish for some cleaner interface where you can just go Grade.read(). That is accomplished in Ash using Code Interface and is the subject of our next section.

2-4. Code Interface

Ash.Query and Ash.Changeset represent low-level approaches, and are useful to know about. There are, however, two issues:

  1. long and verbose. We really shouldn’t need four lines of code just to read a Grade with the weighed_average loaded
  2. likely going to be duplicated elsewhere. If we are re-using some parts of the query, abstracting it out into a semantically meaningful function like passing_students/0 will help stop us from repeating ourselves in multiple places, and provide a central place to find / change the code when necessary.

Code Interfaces let us bring these actions into the Resource, and they are simple as adding a code_interface do ... end block to the resource.

code_interface do
  define_for PotionsClass # we are still interfacing through the API

  define :create # define a set of Grade.create() functions for the :create action
  define :read
  define :update
  define :destroy

  define :passing_students # code interfaces work also for non-default actions
end

actions do
  defaults([:create, :read, :update, :destroy])

  # here's an extra action for later
  read :passing_students do # we define a new action called :passing_students, that is a kind of read
    prepare build(load: [:pass]) # build up the Resource, loading the :pass calculation
    filter expr(pass == true)
  end
end

This creates a “Ash-managed shortcut” for all of our actions. Instead of manually preparing the query / proposal (changeset) and submitting to PotionsClass (API), we can now just do Grade.read() and get back an {:ok, result} tuple:

{:ok,
 [
   #Grade<
     full_name: #Ash.NotLoaded<:calculation>,
     pass: #Ash.NotLoaded<:calculation>,
     weighed_average: #Ash.NotLoaded<:calculation>,
     __meta__: #Ecto.Schema.Metadata<:built, "">,
     id: 101,
     first_name: "Rachel",
     last_name: "Rodríguez",
     ...
   >, ...
  ],
  ...
}

Conveniently, by declaring the :read as a code interface, Ash also gives you the ! version of the function, conveniently plugged into a pipeline:

Grade.read!()
|> Enum.max_by(&(&1.test_score))
#Grade<
  full_name: #Ash.NotLoaded<:calculation>,
  pass: #Ash.NotLoaded<:calculation>,
  weighed_average: #Ash.NotLoaded<:calculation>,
  __meta__: #Ecto.Schema.Metadata<:built, "">,
  id: 112,
  first_name: "Zach",
  last_name: "Daniels",
  test_score: 100,
  lab_score: 100,
  aggregates: %{},
  calculations: %{},
  ...
>

Code interface functions also accepts options. In the case of :read, you can load calculations (and relationships):

Grade.read!(load: [:weighed_average, :full_name])
|> Enum.max_by(&(&1.weighed_average))
#Grade<
  full_name: "Zach Daniels",
  pass: #Ash.NotLoaded<:calculation>,
  weighed_average: 100.0,
  __meta__: #Ecto.Schema.Metadata<:built, "">,
  id: 112,
  first_name: "Zach",
  last_name: "Daniels",
  test_score: 100,
  lab_score: 100,
  aggregates: %{},
  calculations: %{},
  ...
>

The idiomatic Ash way for manipulating Resources is to (1) define meaningful actions, and (2) wrap them into a code interface. As an example, by defining the :passing_student read-type action, our code is not only more terse, but simply more clear as to its intent, and benefits from integration to other parts of Ash.

Grade.passing_students!(load: :full_name)
|> Enum.map(&(&1.full_name))

# ["Polly Polley", "Mozart Amadeus", "Go Ku", "Severus Snape", "Zach Daniels", "Jikky Mouse"]
require Ash.Query

passing_students =
  Grade
  |> Ash.Query.filter(pass == true)
  |> Ash.Query.sort(:first_name)
  |> Ash.Query.load([:full_name])
  |> PotionsClass.read!()

One common need with Resources is to find particular members from the collection. For that Ash provides a convenient short-cut that we can define in our code_interface do ... end block. Like other :read actions, these functions accepts options and provide both the {:ok, result} and ! versions.

code_interface do
  ...
  
  define(:by_id,
    get_by: [:id],
    action: :read
  )

  define(:by_first_name,
    get_by: [:first_name],
    action: :read
  )
end
Grade.by_first_name!("Rachel", 
  load: [
    :full_name, 
    :pass, 
    :weighed_average
    ])
#Grade<
  full_name: "Rachel Rodríguez",
  pass: false,
  weighed_average: 39.8,
  __meta__: #Ecto.Schema.Metadata<:built, "">,
  id: 101,
  first_name: "Rachel",
  last_name: "Rodríguez",
  test_score: 57,
  lab_score: 14,
  aggregates: %{},
  calculations: %{},
  ...
>

Try having a go with the :update, :create, and :destroy actions. Examples are presented for you in the accompanying LiveBook.

2-5. Relationships

In this last section we look at how we associate one Resource with another.

This section/LiveBook is thematically connected, but otherwise standalone: you are not expected to have gone through every previous section to be able to follow. I think this benefits readers who are happy with resources but find relationships complicated.

We thus start with two new CSV files and two new Resources: Student and House. The following diagram shows their relationship:

As usual, we will start from outside in, first defining the API:

defmodule Potions do
  use Ash.Api

  resources do
    resource Potions.Student
    resource Potions.House
  end
end

But before we go further, a brief note about naming. Neither Elixir nor Ash require files to be placed in specific locations, nor do they require modules to be named in a specific way. It is conventional, however, to “namespace” the Resource modules within the API module as if they are related. We would thus use the names to visually group Student and House as Potions.Student and Potions.House respectively.

The Resources are defined as follows: pay attention to how the relationships do…end blocks are constructed. If you are reading this on a desktop, you should see the definition for Potions.Student on the left, Potions.House on the right, offering a parallel with the diagram above. For Students we are bringing in everything we have learnt thus far.

defmodule Potions.Student do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    file "#{__DIR__}/data/2-5_student_ash.csv"
    header? true

    columns [
      :id,
      :first_name,
      :last_name,
      :date_of_birth,
      :familiar,
      :house_id
    ]
  end

  attributes do
    attribute :id, :integer do
      primary_key? true
      allow_nil? false
    end

    attribute :first_name, :string do
      allow_nil? false
    end

    attribute :last_name, :string do
      allow_nil? false
    end

    attribute :date_of_birth, :date
    attribute :familiar, :string
    attribute :house_id, :integer
  end

  calculations do
    calculate(:full_name, :string, expr(first_name <> " " <> last_name))
  end

  # <-- This is new
  relationships do
    belongs_to :house, Potions.House do
      # source_attribute :house_id
      attribute_type :integer
    end
  end
  # -->

  code_interface do
    define_for Potions

    define :create
    define :read
    define :update
    define :destroy

    define :assign_house
  end

  actions do
    defaults [:create, :read, :update, :destroy]

    # <-- we also add an action to show working with relationships
    update :assign_house do
      accept []  # No attributes should be accepted
      argument :house_id, :integer, allow_nil?: false # We accept a house's id as input here

      change manage_relationship(:house_id, :house, type: :append_and_remove)
    end
    # -->
  end
end
defmodule Potions.House do
  use Ash.Resource,
    data_layer: AshCsv.DataLayer

  csv do
    file "#{__DIR__}/data/2-5_house_ash.csv"
    header? true
    columns [:id, :name, :features]
  end

  attributes do
    attribute :id, :integer do
      primary_key? true
      allow_nil? false
    end

    attribute :name, :string do
      allow_nil? false
    end

    attribute :features, :string
  end

  # <-- the reciprocal
  relationships do
    has_many :student, Potions.Student
  end
  # -->

  code_interface do
    define_for Potions

    define :create
    define :read
    define :update
    define :destroy
  end

  actions do
    defaults [:create, :read, :update, :destroy]
  end
end

A brief aside on the primary key, which each record needs to uniquely identify itself. For simplicity, I have chosen to put IDs in the CSV as integers. Using integers for keys is generally bad practice, and Ash defaults to using UUID. We thus need to tell Ash that this is an exception using attribute_type :integer. If we follow the golden path of Ash, and auto-generate UUIDs, our belongs_to statement does not even need the do...end block.

Ash further assumes that you are naming the primary keys to be :id in both Resources. If you name the primary keys something else, then you will need to explicitly state what are the primary key fields using source_attribute / destination_attributes.

With code interfaces defined for our Resources, we can query relationships using load option built into the read!(). Here we are loading both the relationship (:student) and a calculation in that related resource (the students’ :full_name).

Potions.House.read!(load: [
  :student, 
  student: [:full_name]
])
[
  #Potions.House<
    student: [
      #Potions.Student<
        full_name: "Rachel Rodríguez",
        house: #Ash.NotLoaded<:relationship>,
        __meta__: #Ecto.Schema.Metadata<:built, "">,
        id: 101,
        first_name: "Rachel",
        last_name: "Rodríguez",
        date_of_birth: ~D[2000-01-01],
        familiar: "cat",
        house_id: 1,
        aggregates: %{},
        calculations: %{},
        ...
      >,
      ...
    ],
    __meta__: #Ecto.Schema.Metadata<:built, "">,
    id: 1,
    name: "lion",
    features: "loyal and fierce",
    aggregates: %{},
    calculations: %{},
    ...
  >,
  ...
]

What perhaps surprises you (it certainly surprised me!) is that you can compose Ash.Query functions like sort and filter into the value. The following shows how we might get each House‘s related Students sorted by date of birth:

require Ash.Query

Potions.House.read!(
  load: [
    student: # <- look at this magic
      Ash.Query.sort(Potions.Student, date_of_birth: :asc)
      |> Ash.Query.load(:full_name)
  ]
)
|> Enum.map(fn house -> # just some pretty printing
  {
    "#{house.name}",
    for student <- house.student do
      "#{student.full_name}: #{student.date_of_birth}"
    end
  }
end)
[
  {"lion",
   ["Severus Snape: 1943-04-03", "Go Ku: 1970-08-23", "Rachel Rodríguez: 2000-01-01",
    "Ala al-Din Aliev: 2000-05-05"]},
  {"ox", ["Mark McTaggart: 2000-02-02", "Jeong-Hui Jeong: 2000-06-06"]},
  {"eagle", ["花道 櫻木: 1980-08-12", "Zach Daniels: 1985-05-05", "Sue Schorel: 2000-03-03"]},
  {"turtle", ["Mozart Amadeus: 1623-03-15", "Jikky Mouse: 1984-01-07", "Polly Polley: 2000-04-04"]}
]

⚠️ Cautionary note

What you can specify in the Resource does depend on what is available from the Data Layer. AshCsv in particular is rather new, and while it support sort and filter in actions (from 0.9.6 onwards), it does not take aggregates. These will come along using an emulation at the application (Elixir) level, similar to the treatment given for the ETS DataLayer. Until then, you will need to (unidiomatically) filter with Enum.

When we declare our Potions.Student Resource, we defined a :assign_house action (and wrapped into a code interface) for changing a student’s :house.

# Potions.Student

    # <-- we also add an action to show working with relationships
    update :assign_house do
      accept []  # No attributes should be accepted
      argument :house_id, :integer, allow_nil?: false # We accept a house's id as input here

      change manage_relationship(:house_id, :house, type: :append_and_remove)
    end
    # -->

We can select Rachel, and use our code interface to re-assign her:

[rachel] =
  Potions.Student.read!(load: [:house])
  |> Enum.filter(&(&1.first_name == "Rachel"))

Potions.Student.assign_house!(rachel, %{house_id: 3})

Managing relationships is a big topic on its own, and best left for another occasion.

Outro

In this tutorial, we looked why one might want to use Ash, and then progressively looked at how to construct Resources that reads and writes to a CSV (that you can look at transparently), and has attributes, calculations, relationships, actions, and code interfaces.

While this may convince you to not hand-roll some CSV opening / parsing code again, this is a shallow scratch to what Ash accomplishes. The value magnifies as Resources are wired with authorizations / policies, usage in Phoenix / LiveView forms & PubSub, and observability / tracing. Perhaps we will do a follow-up one day, but Ash does have Get Started tutorials, and Stefan Wintermeyer beginner-friendly tutorials that includes Elixir and Phoenix as well.

I hope you both learnt something new, and enjoyed the learning.

Footnotes

  1. caramelized milk, a favorite in Argentina, too sweet for everyone else’s palate. ↩︎
  2. real grade books, of course, are much more complex. What we have listed doesn’t know how to handle absences, or 20% deduction for lateness, nor do they have anywhere for commenting on extenuating circumstances. We may see in another article on Registry, how these might be written nicely in Ash. ↩︎
  3. You may see reference to Registry in the documentation. These were optional groupings to help in implementation, but you can consider them deprecated. ↩︎

Changelog

  • v0.10 (2023-09-28). Completed LiveBooks. Article: outlines, ELI5 & standard sections.
  • v0.40(2023-09-30). Prose for all but 2-4 relationship.
  • v0.80 (2023-10-01). Completed LiveBook package.
  • v0.90 (2023-10-03). Added 2-4 Code Interface section + LiveBook, completed all prose.
  • v1.00 (2023-10-08). Updated Livebooks to use AshCsv 0.9.6. This version of AshCsv allows for filter and sort in actions, and the prose in article is updated to reflect that.

Response

  1. Stefan Avatar
    Stefan

    Wow, thanks.

Leave a Reply