Home Posts Tags Post Search Tag Search

Post 72

Ash Framework 03 - Extending Reasources with Business Logic

Published on: 2025-09-14 Tags: elixir, Blog, Side Project, LiveView, Html/CSS, Phoenix, Ash, Framework
Chapter 2: Extending Reasources with Business Logic (33)
    Reasources and Relationships
        We have created a single reasource now lets create an album reasource. 
            mix ash.gen.resource Tunez.Music.Album --extend postgres

        We now have some new files, we want to add these things to the DB:
            Artist, Album name, Year released, Image

        Let's head over to lib/tunez/music/album.ex
            attributes do
                uuid_primary_key :id
                attribute :name, :string do
                    allow_nil? false
                end
                attribute :year_released, :integer do
                    allow_nil? false
                end
                attribute :cover_image_url, :string
                create_timestamp :inserted_at
                update_timestamp :updated_at
            end

        As you might notice you don't see a artist that will be a relationship.

        "Defining Relationships"
            There are different types of relationship 
            has_many: One has Many (Posts)
            belongs_to: One belongs to One (Post)
            has_one: One has One (User Profile)
            many_to_many: Many to Many (tags)

            Not we can head to lib/tunez/music/artist.ex and add in the relationship
                relationships do
                    belongs_to :artist, Tunez.Music.Artist
                end

                relationships do
                    has_many :albums, Tunez.Music.Album do
                end
            
            Now that we have that setup we can generate and set the migration
                mix ash.codegen create_album

            We now have the new DB and the relationship between the artist and albums. We still need to index from the foriegn key. to do that we need to head back to album.ex and set the index?: to true
                postgres do
                # ...
                references do
                    reference :artist, index?: true➤
                end

            This changed the DB so we can now run the code.gen again to create the snapshot and the migrations.
                mix ash.codegen create_album
                mix ash.migrate
            

        "Album Actions"
            As with before we need to set all the actions for the albums. What is great is that we can make sure that we utilize ASH to make sure that you only pull the albums that are related to the artist.
                actions do
                    defaults([:read, :destroy])

                    create :create do
                    accept([:name, :year_released, :cover_image_url, :artist_id])
                    end

                    update :update do
                    accept([:name, :year_released, :cover_image_url])
                    end
                end

            Now we can add in the new resource to the Module Music. Same as before we can add in the album now.
                resource Tunez.Music.Album do
                    define :create_album, action: :create
                    define :get_album_by_id, action: :read, get_by: :id
                    define :update_album, action: :update
                    define :destroy_album, action: :destroy
                end

            They gave us some sample items and albums we want to now run the mix to add them in and then mix seed to get the rest of the albums after uncommenting the right line in the mix.exs.
                mix run priv/repo/seeds/02-albums.exs
                mix seed
                # There might be some issues here with older data being left behind ignore it for now.

        "Creating and Updating Albums"
            Now let's make sure that we have the ability to add and update albums within the site. 
            lib/tunez_web/albums/form_live.ex
                form = Tunez.Music.form_to_create_album()

                # Then we need to add in the validate
                def handle_event("validate", %{"form" => form_data}, socket) do
                    socket =
                    update(socket, :form, fn form ->
                        AshPhoenix.Form.validate(form, form_data)
                    end)
                    {:noreply, socket}
                end

                # Then a save event
                def handle_event("save", %{"form" => form_data}, socket) do
                    case AshPhoenix.Form.submit(socket.assigns.form, params: form_data) do
                    {:ok, _album} ->
                        socket =
                        socket
                        |> put_flash(:info, "Album created successfully")
                        |> push_navigate(to: ~p"/")

                        {:noreply, socket}

                    {:error, form} ->
                        socket =
                        socket
                        |> put_flash(:error, "Could not create album")
                        |> assign(:form, form)

                        {:noreply, socket}
                    end
                end

                # Last a new mount for when we are updating an event
                def mount(%{"id" => album_id}, _session, socket) do
                    album = Tunez.Music.get_album_by_id!(album_id)
                    form = Tunez.Music.form_to_update_album(album)

                    socket =
                    socket
                    |> assign(:form, to_form(form))
                    |> assign(:page_title, "Update Album")

                    {:ok, socket}
                end

            "Using Artist Data on the Album Form"
                Okay so now we need to be sure that we have the right artist for the album that we are creating. We know the ID of the artist so we can now access that and then make sure the we add that to the new form.
                    artist = Tunez.Music.get_artist_by_id!(album.artists_id)
                Then we need to add in the value of the artist to the form on the page
                    <.input name="artist_id" label="Artist" value={@artist.name} disabled />

                    # Then we need to update the new mount to be sure that we have the artist in the socket and then change the first mount to be sure that we have it in there as well
                    |> assign(:artist, artist)
                    mount(%{"artist_id" => artist_id})

                Now let's figure out how to add in the artist to the save but not the update. We can do this but setting the way in which you even get the form done. You can set the forms in the domain. We will use the form to do this.
                    forms do
                        form :create_album, args: [:artist_id]
                    end

    Loading Related Reasource Data
        Now we can make sure that we have the right list of albums within the ShowLive for the artist. We should head to lib/tunez_web/live/artists/show_live.ex then we can agument the artist to also get the album information as well.
            artist = Tunez.Music.get_artist_by_id!(artist_id, load: [:albums])
            # Now change the assign to remove the album line and the general album as well.
            # change the render as well as we no longer have the value for the :album
        
        We have the first index set now we should be able to set the form live for the album as well. lib/tunez_web/live/albums/form_live.ex and now you can set the artist with the load as they are related.
            album = Tunez.Music.get_album_by_id!(album_id, load: [:artist])
            ...
            |> assign(:artist, album.artist)
        
        To go one step further you can even make sure that things are ordered withing the artist album. lib/tunez/music/artist.ex

    Structured Data with Validations and Identities
        Consistant Data with Validations
            We are going to make sure that when we have a year_released its a valid year and even a valid image URL if we have one, we can even be sure that we are making sure we are not adding the same album twice. Let's head over to lib/music/album.ex
                validations do
                    validate(
                    numericality(:year_released,
                        greater_than: 1950,
                        less_than_or_equal_to: &__MODULE__.next_year/0
                    ),
                    where: [present(:year_released)],
                    message: "must be between 1950 and next year"
                    )

                    validate(
                    match(
                        :cover_image_url,
                        ~r"^(https://|/images/).+(\.png|\.jpg)$"
                    ),
                    where: [changing(:cover_image_url)],
                    message: "must start with https:// or /images/"
                    )
                end

        Unique Data with Identities
            Now lets make sure that we have the right validations for the picture URL. This requires a different set of rules to run. This will create an index in the 2 db so that it can check for those values within the DB so we will need to run a command ofter we do that.
                identities do
                    identity(:unique_album_names_per_artist, [:artist_id, :name],
                    message: "already exists for this artist"
                    )
                end

                # Then
                mix ash.codegen add_unique_album_names_per_artist
                mix ash.migrate
            To see what changed head to priv/repo/migrations/[timestamp]_add_unique_album_names_per_artist.exs

    Deleting All of the Things
        Deleting Album Data
            lib/tunez_web/live/artists/show_live.ex
                def handle_event("destroy-album", %{"id" => album_id}, socket) do
                    case Tunez.Music.destroy_album(album_id) do
                    :ok ->
                        socket =
                        socket
                        |> update(:artist, fn artist ->
                            Map.update!(artist, :albums, fn albums ->
                            Enum.reject(albums, &(&1.id == album_id))
                            end)
                        end)
                        |> put_flash(:info, "Album deleted successfully")

                        {:noreply, socket}

                    {:error, error} ->
                        Logger.info("Could not delete album '#{album_id}': #{inspect(error)}")

                        socket =
                        socket
                        |> put_flash(:error, "Could not delete album")

                        {:noreply, socket}
                    end
                end

        Cascading Deletes with AshPostgres
            We can now utilize the belongs_to to make sure that everything is deleted when we delete an artist. We want to delete the related data on delete of the higher level data. Once this is done we will need to codegen and then migrate the database as well
            lib/tunez/music/album.ex
                postgres do
                    table("albums")
                    repo(Tunez.Repo)

                    references do
                    reference(:artist, index?: true, on_delete: :delete)
                    end
                end

                # Then
                mix ash.codegen configure_reference_for_album_artist_id
                mix ash.migrate
            
    Changing Data with Actions
        So much of what we have done so far is setting a lot of data and built-in functions like inserted_at etc, this was all taken care of by ASH. But there is a way to do this manually. Let's go over a few of those now.
        
        Defining an Inline Change
            We want to keep track of any change to an artists name but tracking all previous artists names. lib/tunez/music/artist.ex, as with all the others this is a db change so we need a codegen and then a migration.
                attributes do
                    # ...
                    attribute :previous_names, {:array, :string} do
                        default []
                    end
                    # ...
                end
            
                # Then
                mix ash.codegen add_previous_names_to_artists
                mix ash.migrate

            Okay so we have the column in the db for the previous_names, but that isn't enough as we now need to update the actions to not just use the defualt and then we need to be sure to add the names to the list. Same file also make sure to remove the update from the defaults first the general syntax will look like this.
                update :update do
                    accept([:name, :biography])
                    change(fn changeset, _context -> changeset end)
                end

                # This is the actual code we want.
                actions do
                    defaults([:create, :read, :destroy])
                    default_accept([:name, :biography])

                    update :update do
                    require_atomic?(false)
                    accept([:name, :biography])

                    change(
                        fn changeset, _context ->
                        new_name = Ash.Changeset.get_attribute(changeset, :name)
                        previous_name = Ash.Changeset.get_data(changeset, :name)
                        previous_names = Ash.Changeset.get_data(changeset, :previous_names)

                        names =
                            [previous_name | previous_names]
                            |> Enum.uniq()
                            |> Enum.reject(fn name -> name == new_name end)

                        Ash.Changeset.change_attribute(changeset, :previous_names, names)
                        end,
                        where: [changing(:name)]
                    )
                    end
                end
            
        Defining a Change Module
            Guess what you can do all that with a much easier and faster built-in way!!! lib/tunez/music/changes/update_previous_names.ex, there is not command for this so just create the folder and file and then update the resource.
                defmodule Tunez.Music.Changes.UpdatePreviousNames do
                    use Ash.Resource.Change

                    @impl true
                    def change(changeset, _opts, _context) do
                        Ash.Changeset.before_action(changeset, fn changeset ->
                        new_name = Ash.Changeset.get_attribute(changeset, :name)
                        previous_name = Map.get(changeset.data, :name)
                        previous_names = Map.get(changeset.data, :previous_names) || []

                        names =
                            [previous_name | previous_names]
                            |> Enum.uniq()
                            |> Enum.reject(&(&1 == new_name))

                        Ash.Changeset.change_attribute(changeset, :previous_names, names)
                        end)
                    end
                end

                # Then change the action in the resource lib/tunez/music/artist.ex
                update :update do
                    require_atomic?(false)
                    accept([:name, :biography])

                    change(Tunez.Music.Changes.UpdatePreviousNames,
                        where: [changing(:name)]
                    )
                end

        Changes Run More Often than You Might Think!
            Keep in mind that any time you: 
                Building the inital form
                Authorization checks
                Every validation check
                When submitting the form
            You are running a change. As such you should wrap them in hooks such as Ash.Changeset.before_action or Ash.Changeset.after_action. So the UpdatePreviousNames would look like this.
                def change(changeset, _opts, _context) do
                    Ash.Changeset.before_action(changeset, fn ->
                    # The code previously in the body of the function
                    # It can still use any `opts` or `context` passed in to the top-level
                    # change function, as well
                    new_name = Ash.Changeset.get_attribute(changeset, :name)
                    previous_name = Ash.Changeset.get_data(changeset, :name)
                    previous_names = Ash.Changeset.get_data(changeset, :previous_names)

                    names =
                        [previous_name | previous_names]
                        |> Enum.uniq()
                        |> Enum.reject(fn name -> name == new_name end)

                    Ash.Changeset.change_attribute(changeset, :previous_names, names)
                    end)
                end
            This is going to run right before the action is completed that calls it, in our case the change name.
        
        Rendering the Previous Names in the UI
            This will be quick so just add this to the code and you will see the older names.
            lib/tunez_web/live/artists/show_live.ex
            <.header>
                <.h1>...</.h1>
                <:subtitle :if={@artist.previous_names != []}>➤
                    formerly known as: {Enum.join(@artist.previous_names, ", ")}➤
                </:subtitle>
                ...
            </.header>