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 Resources with Business Logic (33)

Resources and Relationships

We have created a single resource; now let’s create an album resource.

mix ash.gen.resource Tunez.Music.Album --extend postgres

We now have new files. We want to add these fields to the DB: Artist, Album name, Year released, Image.

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

Notice we don’t yet have an artist; that will be a relationship.

Defining Relationships

Different types of relationships:

  • 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)

Add the relationship in lib/tunez/music/artist.ex:

relationships do
  belongs_to :artist, Tunez.Music.Artist
end

relationships do
  has_many :albums, Tunez.Music.Album
end

Generate and run the migration:

mix ash.codegen create_album

Now we need to index the foreign key. Back in album.ex:

postgres do
  # ...
  references do
    reference :artist, index?: true
  end
end

Run codegen and migrations again:

mix ash.codegen create_album
mix ash.migrate

Album Actions

Set the actions for albums, making sure only related albums are accessible:

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

Add the resource to the Tunez.Music module:

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

Seed the albums:

mix run priv/repo/seeds/02-albums.exs
mix seed

Creating and Updating Albums

In lib/tunez_web/albums/form_live.ex:

form = Tunez.Music.form_to_create_album()

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

Save:

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

Mount for updating:

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

Get the artist by ID:

artist = Tunez.Music.get_artist_by_id!(album.artist_id)

Add to the form:

<.input name="artist_id" label="Artist" value={@artist.name} disabled />

Update mount to assign the artist:

|> assign(:artist, artist)
mount(%{"artist_id" => artist_id})

Add artist_id to the form args:

forms do
  form :create_album, args: [:artist_id]
end

Loading Related Resource Data

Load albums in the artist show view (lib/tunez_web/live/artists/show_live.ex):

artist = Tunez.Music.get_artist_by_id!(artist_id, load: [:albums])

Assign artist to album form:

album = Tunez.Music.get_album_by_id!(album_id, load: [:artist])
|> assign(:artist, album.artist)

Order albums in lib/tunez/music/artist.ex.

Structured Data with Validations and Identities

Consistent Data with Validations

Validate year_released and cover_image_url:

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

Add unique album names per artist:

identities do
  identity(:unique_album_names_per_artist, [:artist_id, :name],
    message: "already exists for this artist"
  )
end

Run codegen and migrate:

mix ash.codegen add_unique_album_names_per_artist
mix ash.migrate

Deleting All of the Things

Deleting Album Data

In 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

Set on_delete: :delete in album.ex:

postgres do
  table("albums")
  repo(Tunez.Repo)

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

Run codegen and migrate:

mix ash.codegen configure_reference_for_album_artist_id
mix ash.migrate

Changing Data with Actions

Defining an Inline Change

Track previous artist names in lib/tunez/music/artist.ex:

attributes do
  attribute :previous_names, {:array, :string} do
    default []
  end
end

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

Create lib/tunez/music/changes/update_previous_names.ex:

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

Update artist.ex action:

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!

Wrap changes in before_action or after_action hooks to run them at the right time.

Rendering the Previous Names in the UI

In 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>