Home Posts Post Search Tag Search

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>