We can't find the internet
Attempting to reconnect
Something went wrong!
Hang in there while we get back on track
Post 75
Ash Framework 04 - Better Search
Published on: 2025-09-26
Tags:
elixir, Blog, Side Project, Libraries, LiveView, Html/CSS, Phoenix, Ash, Framework
Chapter 3: Creating a Better Search UI (59)
Custom Actions with Arguments
We want to implement a way to search for an artist and even a piece of information with a string. Something like.
iex> Tunez.Music.search_artists("fur")
{:ok, [%Tunez.Music.Artist{name: "Valkyrie's Fury"}, ...]}
Designing a Search Action
Lets head over to the lib/tunez/music/artist.ex and add in a read :search action. We can make it take some parameters and set some constraints and even set a default.
read :search do
argument(:query, :ci_string) do
constraints(allow_empty?: true)
default("")
end
filter(expr(contains(name, ^arg(:query))))
end
Filters with Expressions
Lets go over that last line of code and see it in action. When we run the iex session be sure to add in "require Ash.Query"
iex -S mix
iex(1)> require Ash.Query
Ash.Query
iex(2)> Ash.Query.filter(Tunez.Music.Album, year_released == 2024)
#Ash.Query<resource: Tunez.Music.Album,
filter: #Ash.Filter<year_released == 2024>>
iex(3)> |> Ash.read()
SELECT a0."id", a0."name", a0."inserted_at", a0."updated_at",
a0."year_released", a0."artist_id", a0."cover_image_url" FROM "albums" AS
a0 WHERE (a0."year_released"::bigint = $1::bigint) [2024]
{:ok, [%Tunez.Music.Album{year_released: 2024, ...}, ...]}
Now the filter is not limited to just == you can use an where you just need to wrap it in a call to expr.
ex(4)> Tunez.Music.Artist
Tunez.Music.Artist
iex(5)> |> Ash.Query.for_read(:search, %{query: "co"})
#Ash.Query<
resource: Tunez.Music.Artist,
arguments: %{query: #Ash.CiString<"co">},
filter: #Ash.Filter<contains(name, #Ash.CiString<"co">)>
>
iex(6)> |> Ash.read()
SELECT a0."id", a0."name", a0."biography", a0."previous_names",
a0."inserted_at", a0."updated_at" FROM "artists" AS a0 WHERE
(a0."name"::text ILIKE $1) ["%co%"]
{:ok, [#Tunez.Music.Artist<name: "Crystal Cove", ...>, ...]}
This did what we want by allowing case-insentive substring matches.
Speeding Things Up with Custom Database Indexes
This is a very non performant way of doing thing so we can add in a GIN index to help on the name column. First we need to inable it and then start to use it. Let's get it installed first.
lib/tunes/repo.ex
@impl true
def installed_extensions do
# Add extensions here, and the migration generator will install them.
["ash-functions", "pg-trgm"]
end
Then head to lib/tunez/music/artist.ex
postgres do
table("artists")
repo(Tunez.Repo)
custom_indexes do
index("name gin_trgm_ops", name: "artists_name_trgm_index", using: "GIN")
end
end
Normally AshPostgres will generate the names of indexes by itself from the fields but since we are creating a custom index. Now run the new the new migrations.
mix ash.codegen add_gin_index_for_artist_name_search
mix ash.migrate
Integrating Search into the UI
Now lets integrate it into out first catalog.
"A Code Interface with Arguments"
lib/tunez/music.ex
resource Tunez.Music.Artist do
# ...
define :search_artists, action: :search, args: [:query]
end
We set a new resouce that we can use in music domain. We can now try it out in an iex session.
iex(1)> h Tunez.Music.search_artists
def search_artists(query, params \\ nil, opts \\ nil)
Calls the search action on Tunez.Music.Artist.
"Searching from the Catalog"
We want to be able to copy and replicate the results that we get from the site so we need to be able to get to them with URLs. Lets head over to lib/tunez_web/live/artists/index_live.ex: This is what it looks like without a param in the URL
def handle_params(_params, _url, socket) do
artists = Tunez.Music.read_artists!()
socket =
socket
|> assign(:artists, artists)
# ...
# Lets pattern match the param and set some values in the query.
def handle_params(params, _url, socket) do
query_text = Map.get(params, "q", "")
artists = Tunez.Music.search_artists!(query_text)
socket =
socket
|> assign(:query_text, query_text)
|> assign(:artists, artists)
# ...
Now we can add in box to the header to be able to search with a bar.
<.header responsive={false}>
<.h1>Artists</.h1>
<:action>
<.search_box query={@query_text} method="get"
data-role="artist-search" phx-submit="search" />
</:action>
<:action>
<.button_link
# ...
Dynamically Sorting Artists
Now that we have the search set we can now move onto the sort so a user can get the right artist faster.
Letting Users Set a Sort Method
Start with the UI then we can implement the needed code. lib/tunez_web/live/artists/index_live.ex
<.header responsive={false}>
<.h1>Artists</.h1>
<:action><.sort_changer selected={@sort_by} /></:action>
...
# Now lets set the new param in the handle_params
def handle_params(params, _url, socket) do
sort_by = nil
# ...
socket =
socket
|> assign(:sort_by, sort_by)
# ...
# The sort changer is below and it will call the handle_event for "change-sort". Now let's add in the validation
def handle_params(params, _url, socket) do
sort_by = Map.get(params, "sort_by") |> validate_sort_by()
# ...
The Base Query for a Read Action
We set up the UI now lets implement it. Here is the basic way in which we would run a query. Notice what we are asking in this read. We are setting the way in which we are sorting we need to implement that ourselves.
iex(2)> Tunez.Music.Artist
Tunez.Music.Artist
iex(3)> |> Ash.Query.for_read(:read)
Ash.Query<resource: Tunez.Music.Artist>
iex(4)> |> Ash.Query.sort(name: :asc)
#Ash.Query<resource: Tunez.Music.Artist, sort: [name: :asc]>
iex(5)> |> Ash.Query.limit(1)
#Ash.Query<resource: Tunez.Music.Artist, sort: [name: :asc], limit: 1>
iex(6)> |> Ash.read()
SELECT a0."id", a0."name", a0."biography", a0."inserted_at", a0."updated_at"
FROM "artists" AS a0 ORDER BY a0."name" LIMIT $1 [1]
{:ok, [#Tunez.Music.Artist<...>]}
Using sort_input for Succinct yet Expressive Sorting
Normally you can use sort and then set things like name: :asc, inserted_at: :desc you can even test this out.
iex(6)> Tunez.Music.search_artists("the", [query: [sort: [name: :asc]]])
{:ok,
[
#Tunez.Music.Artist<name: "Nights in the Nullarbor", ...>,
#Tunez.Music.Artist<name: "The Lost Keys", ...>
]}
But we can use sort_input a bit differently. So we can just set a list of key words and the it will pull from those. But in order to utilize this we need to make some changes to the resource, in order to use it, sort_input will need PUBLIC atrributes. Let's head over to lib/tunez/music/artist.ex
atrributes do
# ...
attribute :name, :string do
allow_nil?(false)
public?(true)
end
# ...
create_timestamp(:inserted_at, public?: true)
update_timestamp(:updated_at, public?: true)
end
Head into an iex and test this out
iex(6) > Tunez.Music.search_artists("the", [query: [sort_input: "-name"]])
[debug] QUERY OK source="artists" db=0.9ms queue=0.5ms idle=1270.9ms
SELECT a0."id", a0."name", a0."biography", a0."inserted_at", a0."previous_names", a0."updated_at" FROM "artists" AS a0 WHERE (a0."name"::text ILIKE $1) ORDER BY a0."name" DESC ["%the%"]
↳ anonymous fn/3 in AshPostgres.DataLayer.run_query/2, at: lib/data_layer.ex:827
{:ok,
[
%Tunez.Music.Artist{
id: "f6c77d42-2c90-4654-853f-37c071d9ef87",
name: "The Lost Keys",
previous_names: [],
biography: "The Lost Keys are a blues rock band hailing from New Orleans, Louisiana, formed in 2014. The band comprises a group of musicians who found each other through...
Now we can leverage this into a single string of values that can be added into the params. lib/tunez_web/live/artists/index_live.ex
def handle_params(params, _url, socket) do
# ...
artists = Tunez.Music.search_artists!(query_text, query: [sort_input: sort_by])
# Now just a quick tweek to the sort_options
defp sort_options do
[
{"recently updated", "-updated_at"},
{"recently added", "-inserted_at"},
{"name", "name"}
]
end
We now can change the way in-which we sort the data and it will update on the URL as well so you can copy and paste a URL to your friends.
Pagination of Search Results
Now lets add in some paginagtion so that you can also be sure to have everything in a more consice manner.
Adding Pagination Support to the search Action
First lets head to lib/tunez/music/artist.ex and add in an other part for the search.
read :search do
# ...
pagination offset?: true, default_limit: 12
end
It can support both types if pagination amount of records or after record x. You can test it out as well.
iex(1)> Tunez.Music.search_artists!("cove")
#Ash.Page.Offset<
results: [#Tunez.Music.Artist<name: "Crystal Cove", ...>],
limit: 12,
offset: 0,
count: nil,
more?: false,
...
>
Showing Paginated Data in the Catalog
So now we need to understand what page we are on and the search artists will not have that information. So we can leverage that with the handle_params lib/tunez/live/artists/index_live.ex since it now will return a struct for the page you are recieving not and "artist"
def handle_params(params, _url, socket) do
# ...
page = Tunez.Music.search_artists!(query_text, query: [sort_input: sort_by])
socket =
socket
|> assign(:query_text, query_text)
|> assign(:page, page)
# ...
# Now lets set the render and html code.
<div :if={@page.results == []} class="p-8 text-center">
<.icon name="hero-face-frown" class="w-32 h-32 bg-gray-300" />
<br /> No artist data to display!
</div>
<ul class="gap-6 lg:gap-12 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
<li :for={artist <- @page.results}>
<.artist_card artist={artist} />
</li>
</ul>
# Now we only show 1 page of results and we will need a way to get to the next page of artists.
<Layouts.app {assigns}>
# ...
<.pagination_links page={@page} query_text={@query_text}
sort_by={@sort_by} />
</Layouts.app>
# Now we need to make them work so lets use an other built-in funcionality of ASH. AshPhoenix.LiveView, this will help us determine if or when we can get to an other page. We will do this in the pagination_links function.
<div
:if={AshPhoenix.LiveView.prev_page?(@page) ||
AshPhoenix.LiveView.next_page?(@page)}
class="flex justify-center pt-8 space-x-4"
>
<.button_link data-role="previous-page" kind="primary" inverse
patch={~p"/?#{query_string(@page, @query_text, @sort_by, "prev")}"}
disabled={!AshPhoenix.LiveView.prev_page?(@page)}
>
« Previous
</.button_link>
<.button_link data-role="next-page" kind="primary" inverse
patch={~p"/?#{query_string(@page, @query_text, @sort_by, "next")}"}
disabled={!AshPhoenix.LiveView.next_page?(@page)}
>
Next »
</.button_link>
</div>
# Now we can set the query_string helper and we are more than on our way
def query_string(page, query_text, sort_by, which) do
case AshPhoenix.LiveView.page_link_params(page, which) do
:invalid -> []
list -> list
end
|> Keyword.put(:q, query_text)
|> Keyword.put(:sort_by, sort_by)
|> remove_empty()
end
# You can now do some tests but we still need to make sure that we are only loading the right artists for each page, back to handle_params
#...
page_params = AshPhoenix.LiveView.page_from_params(params, 12)
page = Tunez.Music.search_artists!(query_text, query: [sort_input: sort_by], page: page_params)
This utilized the right information and made sure that we are getting only the right artists for the right page.
No DB field? No Problems, with Calculations
Calculations are a great way to get information that is calculated on demand but they must be loaded with the data.
Calculating Data with Style
We can change the album resource to deal with Calculations, lib/tunez/music/album.ex. As you will see in this you are going to build the data for how long ago it was created.
defmodule Tunez.Music.Album do
# ...
calculations do
calculate :years_ago, :integer, expr(2025 - year_released)
end
If we wanted to use some more dynamic values we could define a separate calculate, we will use that later to define the seconds on a track. Here is a little test.
iex(1) > Tunez.Music.get_artist_by_id(«uuid», load: [albums: [:years_ago]])
{:ok, #Tunez.Music.Artist<
albums: [
#Tunez.Music.Album<year_released: 2022, years_ago: 3, ...>,
#Tunez.Music.Album<year_released: 2012, years_ago: 13, ...>
],
...
>}
Now lets add some or funcionality
calculations do
calculate :years_ago, :integer, expr(2025 - year_released)
calculate :string_years_ago,
:string,
expr("wow, this was released " <> years_ago <> " years ago!")
end
Calculations with Related Records
Now we want to build some calculations for each artist that will contain: This will be done in the artists resource, lib/music/artist.ex
The Number of albums
The year of the latest album
The most recent album cover
"Counting Albums for an Artist"
defmodule Tunez.Music.Artist do
# ...
calculations do
calculate :album_count, :integer, expr(count(albums))
end
end
Here is a test.
iex(1)> Tunez.Music.search_artists("a", load: [:album_count])
SELECT a0."id", a0."name", a0."biography", a0."previous_names",
a0."inserted_at", a0."updated_at", coalesce(s1."aggregate_0", $1::bigint)
::bigint::bigint FROM "artists" AS a0 LEFT OUTER JOIN LATERAL (SELECT
sa0."artist_id" AS "artist_id", coalesce(count(*), $2::bigint)::bigint AS
"aggregate_0" FROM "public"."albums" AS sa0 WHERE (a0."id" = sa0."artist_id")
GROUP BY sa0."artist_id") AS s1 ON TRUE WHERE (a0."name"::text ILIKE $3)
ORDER BY a0."id" LIMIT $4 [0, 0, "%a%", 13]
{:ok, %Ash.Page.Offset{...}}
"Finding the Most Recent Album Release Year for an Artist"
calculations do
calculate :album_count, :integer, expr(count(albums))
calculate :latest_album_year_released, :integer,
expr(first(albums, field: :year_released))
end
"Finding the Most Recent Album Cover for an Artist
Okay so normally we would only want to return the covers for those artists that have an album cover to return but Ash will automatically only return non-nil values, although it can still return nil if needs be. So lets add in the album cover and go from there.
calculate :cover_image_url, :string,
expr(first(albums, field: :cover_image_url))
We have now set all those values that will be triggered with the data that we pull as long as we ask for them. Keep in mind that you will only get the data that you ask for even if you need to calculate an other calculation to get it.
Relationship Calculations as Aggregates
There are a bit more indepth than a calculation but use a lot of the same ideas but have a more streamlined syntax we are staying in lib/tunez/music/artist.ex
defmodule Tunez.Music.Artist do
# ...
aggregates do
# calculate :album_count, :integer, expr(count(albums))
count :album_count, :albums
# calculate :latest_album_year_released, :integer,
# expr(first(albums, field: :year_released))
first :latest_album_year_released, :albums, :year_released
# calculate :cover_image_url, :string,
# expr(first(albums, field: :cover_image_url))
first :cover_image_url, :albums, :cover_image_url
end
See how much easier it is to ulitize aggreagates. Now lets implement them.
Using Aggregates like any other Attribute
As it sounds we simply need to add the attribute to to the resouce and we are set to go. Head to lib/tunez_web/live/artists/index_live.ex and head to the artist_card function
<div id={"artist-#{@artist.id}"} data-role="artist-card" class="relative mb-2">
<.link navigate={~p"/artists/#{@artist.id}"}>
<.cover_image image={@artist.cover_image_url} />
</.link>
</div>
We could add in the other calculations but in this case there is yet an other built-in ASH function to prepare the build which will automatically load those values. This would be the "normal" way to do it.
page =
Tunez.Music.search_artists!(query_text,
page: page_params,
query: [sort_input: sort_by],
load: [:album_count, :latest_album_year_released, :cover_image_url]
)
Here is an othe way to do it by going into the read :search and setting a prepare build
read :search do
# ...
prepare build(load: [:album_count, :latest_album_year_released,
:cover_image_url])
end
For our purposese we will go to lib/tunez/music.ex domain and add in the defaults to the search_artists.
define(:search_artists,
action: :search,
args: [:query],
default_options: fn ->
[load: [:album_count, :latest_album_year_released, :cover_image_url]]
end
)
Now let's head back to lib/tunez_web/live/artists/index_live.ex and add in the information that we wanted to load.
def artist_card(assigns) do
~H"""
<% # ... %>
<.artist_card_album_info artist={@artist} />
"""
end
Sorting Based on Aggregate Data
Now we can even add in some sorting params based off those data options. Still in the index_live.ex add these to the sortig options.
defp sort_options do
[
{"recently updated", "-updated_at"},
{"recently added", "-inserted_at"},
{"name", "name"},
{"number of albums", "-album_count"},
{"latest album release", "-latest_album_year_released"}
]
end
Now we simply make them public and we are set head to lib/tunez/music/artst.ex
aggregates do
count :album_count, :albums do
public? true➤
end
first :latest_album_year_released, :albums, :year_released do
public? true➤
end
# ...
end
We did it we are now all set to finish this part of the project!!!