We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
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>