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