Home Posts Tags Post Search Tag Search

Post 70

Ash Framework 01 - Getting Started

Published on: 2025-09-10 Tags: elixir, Generators , Blog, Side Project, LiveView, Ecto, Phoenix
This is going to be a larger post as I wasn't able to get everything that I did over a couple days into multiple posts. Coded to late most days and just didn't transfer it here.

Chapter 1: Building Our First Resource (1)
    Ash is a set of tools you can use to describe and build the Domain Model. For this we will set up the Tunez Application, install Ash, and build our first reasource.

    Getting the Ball Rolling
        This will be a lightweight spotify app (without the music). 

        Setting Up Your Development Environement
            They created a starter github for this project so run
                git clone https://github.com/sevenseacat/tunez
                cd tunez
                mix setup
                mix phx.server

            IF all that works you have everthing you need. if not make sure you have asdf and insall the files in the .tool-versions

        Welcome to ASH!!
            Now that we know its all working we can now be sure to insall an other depedency which is ingiter, this is already ready to go from the Tunez project. This will set some new deps and formatter lines as well as fixing the config to be sure to have all the right depedency.
                mix igniter.insall ash

            Next we need to set up the post_gres for ash as well. This will do the following add and fetch ash_postgres Hex package, add more autoformatting to config and formatter, update the database to use ASH, update some alias to use ASH instead of Ecto, Generate our first migration, generate the extensions for the config. 
                mix igniter.install ash_postgres

        Reasources and Domains
            One of the most important concept is resource in ASH. They are domain model objects (nouns our app revolves around). 

            Related resources are grouped together into Domains. What this means for our app is that we will define several domainds for distinct ideas within the app, Music, Album, Artist, Track User, Notifications

            We will set attributes for each. which is data that maps to keys of the resource's struct. 

        Generating the Artist Resource
            First we can create the Artist resource. It will hold the artists name and biography. For this we will use a generator with this command. It will do the following: create a new module Tunez.Music.Artist, a new domain module named Tunez.Music (check out lib/tunez/music/artist.ex) 
                mix ash.gen.resource Tunez.Music.Artist -- extend postgres

            Now that we have the general domain we can now populate it with a table and some attributes
                defmodule Tunez.Music.Artist do
                    use Ash.Resource, otp_app: :tunez, domain: Tunez.Music, data_layer: AshPostgres.DataLayer

                    postgres do
                        table("artists")
                        repo(Tunez.Repo)
                    end
                end

            Then we can set some attributes: first we want a primary key UUID key, time stamp fields (create_timestamp, update_timestamp, name, biography) as you can see below a lot of this can be done with just simple keywords.
                attributes do
                    uuid_primary_key(:id)

                    attribute :name, :string do
                    allow_nil?(false)
                    end

                    attribute(:biography, :string)
                    create_timestamp(:inserted_at)
                    update_timestamp(:updated_at)
                end

            We still need to create the database and there is a command for that (ash.codegen) let's start to look into that now.

        Auto-generating Database Migrations
            If you have run with ecto before you will know that everyting needs to stay up to date and if you update a schema or datebases independently you will not have an up to date DB. Ash will sidestep this and make sure that everything is done together. `mix ash.codegen' will do the following:
                create shapshots of your current Reasources
                compare them to the older snapshot (if extisting)
                generate deltas for the changes.

            Let's run that command to create the artists. This created a few files for us, so that we can keep everything up to date: a snapshot priv/resource_snapshots/repo/artists/[*].json, and the migration for the artist resource priv/repo/migrations/[*]_create_artists.ex
                mix ash.codegen create_artists

            Let's check out the migration that was just created.
                def up do
                    create table(:artists, primary_key: false) do
                    add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
                    add :name, :text, null: false
                    add :biography, :text

                    add :inserted_at, :utc_datetime_usec,
                        null: false,
                        default: fragment("(now() AT TIME ZONE 'utc')")

                    add :updated_at, :utc_datetime_usec,
                        null: false,
                        default: fragment("(now() AT TIME ZONE 'utc')")
                    end
                end

                def down do
                    drop table(:artists)
                end
            
            Looks just like something that you would create doing it all yourself. This all came from the schema we created and then the Ash generator. We can now migrate with the mix ash command.
                mix ash.migrate

    Oh, CRUD! - Defining Basic Actions
        Remember that CRUSD is:
            Create
            Read
            Update
            Delete

        We will need to define those actions within our database. Remembering that everything that we want to do can be done within the artists.ex file simply add in an action block and go.

        Defining a Create Action
            All actions require a type (create, read, update, destroy) I will put all the changes to the module below but there will be a few thing to look for. As of right now we just the action and an atom to call it with, and then what it will accept

              actions do
                create :create do
                accept([:name, :biography])
            end
        
        Creating Records via a Changeset
            Create a changeset and then pass it to a process to add it.
                iex -S mix
                Tunez.Music.Artist
                |> Ash.Changeset.for_create(:create, %{
                name: "Valkyrie's Fury",
                biography: "A power metal band hailing from Tallinn, Estonia"
                })
                |> Ash.create()

            Now we can see if it did infact work with a psql command
                psql tunez_dev
                tunez_dev=# select * from artists;
                    -[ RECORD 1 ]----------------------------------------------
                    id | [uuid]
                    name | Valkyrie's Fury
                    biography | A power metal band hailing from Tallinn, Estonia
                    inserted_at | [now]
                    updated_at | [now]
            
            We can now check to see if you can do some other creates with or without name etc.

        Creating Records via a Code Interface
            So we want to now use a Domain to interact with a database so that we can have lower level (reasources) work done through a domain. Let's head to the file /lib/tunez/music.ex. This is a domain.
                resources do
                    resource Tunez.Music.Artist do
                        define :create_artist, action: :create
                    end
                end

            With this in place we can no interact with the create_artist function.
                iex(5)> h Tunez.Music.create_artist

                 def create_artist(params \\ nil, opts \\ nil)                  

                Calls the create action on Tunez.Music.Artist.

                # Inputs

                • name
                • biography

            Okay so now we can use this syntax to create an artist from the music module.
                iex > Tunez.Music.create_artist(%{
                name: "Valkyrie's Fury",
                biography: "A power metal band hailing from Tallinn, Estonia"
                })    

            Let's quickly look at the seed logic in the mix.exs so that we can see what kind of work is done with the `mix seed` command.
                  defp aliases do
                    [
                    setup: ["deps.get", "ash.setup", "assets.setup", "assets.build", "run priv/repo/seeds.exs"],
                    "ecto.setup": ["ecto.create", "ecto.migrate"],
                    seed: [
                        "run priv/repo/seeds/01-artists.exs",

            With this line uncommented we can run the mix seed to create some artists.
                mix seed

        Defining a Read Action
            now we can add in the read action to the resource Artist
                read :read do
                    primary?(true)
                end

            Then add it to the music Domain
                define :read_artist, action: :read

                iex> Tunez.Music.read_artists()

            ``Manually Reading Records Via a Query``
                This will now work for us. Right now as we didn't set some params for the read we will just pull everything the other way is to create a temp artist struct and then pass some queries to it. The basic idea is: create basic query > pipe through functions to add params > Pass to Ash for processing, see below.
                    iex(2)> Tunez.Music.Artist
                    Tunez.Music.Artist
                    iex(3)> |> Ash.Query.for_read(:read)
                    #Ash.Query<resource: Tunez.Music.Artist, action: :read>
                    Then you can pipe that query into Ash’s query functions like sort and limit. The
                    query keeps getting the extra conditions added to it, but it isn’t yet being run
                    in the database.
                    iex(4)> |> Ash.Query.sort(name: :asc)
                    #Ash.Query<resource: Tunez.Music.Artist, action: :read, sort: [name: :asc]>
                    iex(5)> |> Ash.Query.limit(1)
                    #Ash.Query<resource: Tunez.Music.Artist, action: :read, sort: [name: :asc],
                    limit: 1>
                    Then, when it’s time to go, Ash can call it and return the data you requested,
                    with all conditions applied:
                    iex(6)> |> Ash.read()
                    SELECT a0."id", a0."name", a0."biography", a0."inserted_at", a0."updated_at"
                    FROM "artists" AS a0 ORDER BY a0."name" LIMIT $1 [1]
                    {:ok, [#Tunez.Music.Artist<...>]}

            `Reading a Single Record by Primary Key` 
                Right now again if we were to try a read it will retieve everything but there is a simple way to use a resource and be able to pass a param and get a record by that param. Head back to the music domain and then add this.
                    define :get_artist_by_id, action: :read, get_by: :id

        Defining an Update Action
            Now we can add in an update. Remember that we now just need to add in the action and then add it to a module.
                update :update do
                    accept [:name, :biography]
                end

                define :update_artist, action: :update

            How do we use this? Well we need an artist and then we need to send it the information that will be updated.
                iex(3)> artist = Tunez.Music.get_artist_by_id!("an-artist-id")
                #Tunez.Music.Artist<id: "an-artist-id", ...>
                Now we can either use the code interface we added or create a changeset and
                apply it, as we did for create.
                iex(4)> # Via the code interface
                iex(5)> Tunez.Music.update_artist(artist, %{name: "Hello"})
                UPDATE "artists" AS a0 SET "updated_at" = (CASE WHEN $1::text !=
                a0."name"::text THEN $2::timestamp ELSE a0."updated_at"::timestamp END)
                ::timestamp, "name" = $3::text WHERE (a0."id"::uuid = $4::uuid) RETURNING
                a0."id", a0."name", a0."biography", a0."inserted_at", a0."updated_at"
                ["Hello", [now], "Hello", "an-artist-id"]
                {:ok, #Tunez.Music.Artist<id: "an-artist-id", name: "Hello", ...>}
                iex(6)> # Or via a changeset
                iex(7)> artist
                |> Ash.Changeset.for_update(:update, %{name: "World"})
                |> Ash.update()
                «an almost-identical SQL statement»
                {:ok, #Tunez.Music.Artist<id: "an-artist-id", name: "World", ...>}

        Defining a Destroy Action
            Now we simply need to add in a delete (destroy) and we have at least the start of CRUD
                destroy :destroy do
                end

                define :destroy_artist, action: :destroy

            How we will use it is slightly different as we would like to use a changeset
                ex(3)> artist = Tunez.Music.get_artist_by_id!("the-artist-id")
                #Tunez.Music.Artist<id: "an-artist-id", ...>
                iex(4)> # Via the code interface
                iex(5)> Tunez.Music.destroy_artist(artist)
                DELETE FROM "artists" AS a0 WHERE (a0."id" = $1) ["the-artist-id"]
                :ok
                iex(6)> # Or via a changeset
                iex(7)> artist
                |> Ash.Changeset.for_destroy(:destroy)
                |> Ash.destroy()
                DELETE FROM "artists" AS a0 WHERE (a0."id" = $1) ["the-artist-id"]
                :ok

        Default Actions
            Now that we know how to setup some of the actions we can just use a module and set some default actions as ASH is set to just use CRUD off the bat. Let's go to the artist.ex resource and replace everything with this.
                defaults [:create, :read, :update, :destroy]
                default_accept [:name, :biography]

            What is great about this is that the normal default here add in a lot of functionality that you would want (or and not use) like pagination etc.

Next we will add in Phoenix Integrations.