Home Posts Tags Post Search Tag Search

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