Home Posts Post Search Tag Search

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

Custom Actions with Arguments

  • Goal: search for artists or information using a string:

    iex> Tunez.Music.search_artists("fur")
    {:ok, [%Tunez.Music.Artist{name: "Valkyrie's Fury"}, ...]}

Designing a Search Action

  • Add a read :search action in lib/tunez/music/artist.ex:

    read :search do
      argument(:query, :ci_string) do
        constraints(allow_empty?: true)
        default("")
      end
    
      filter(expr(contains(name, ^arg(:query))))
    end

Filters with Expressions

  • Use Ash.Query.filter/2 for filtering:

    iex> require Ash.Query
    iex> Ash.Query.filter(Tunez.Music.Album, year_released == 2024) |> Ash.read()
  • Supports case-insensitive substring matches:

    iex> Tunez.Music.Artist |> Ash.Query.for_read(:search, %{query: "co"}) |> Ash.read()

Speeding Things Up with Custom Database Indexes

  • Add GIN index for name column to speed up searches:

    # lib/tunez/repo.ex
    def installed_extensions, do: ["ash-functions", "pg-trgm"]
    
    # 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
  • Run migration:

    mix ash.codegen add_gin_index_for_artist_name_search
    mix ash.migrate

Integrating Search into the UI

  • Define search in domain:

    resource Tunez.Music.Artist do
      define :search_artists, action: :search, args: [:query]
    end
  • Handle search params in LiveView (index_live.ex):

    query_text = Map.get(params, "q", "")
    artists = Tunez.Music.search_artists!(query_text)
    socket = assign(socket, :query_text, query_text) |> assign(:artists, artists)
  • Add search box to header in template:

    <.search_box query={@query_text} method="get" phx-submit="search" />

Dynamically Sorting Artists

Letting Users Set a Sort Method

  • Add sort changer in UI:

    <.sort_changer selected={@sort_by} />
  • Capture sorting in handle_params:

    sort_by = Map.get(params, "sort_by") |> validate_sort_by()
    socket = assign(socket, :sort_by, sort_by)

Base Query for a Read Action

  • Example query:

    Tunez.Music.Artist
    |> Ash.Query.for_read(:read)
    |> Ash.Query.sort(name: :asc)
    |> Ash.Query.limit(1)
    |> Ash.read()

Using sort_input

  • Allows multiple sort fields:

    Tunez.Music.search_artists("the", [query: [sort_input: "-name"]])
  • Attributes must be public:

    attribute :name, :string, allow_nil?: false, public?: true
    create_timestamp(:inserted_at, public?: true)
    update_timestamp(:updated_at, public?: true)
  • Update handle_params to pass sort input to search:

    artists = Tunez.Music.search_artists!(query_text, query: [sort_input: sort_by])
  • Example sort options:

    [
      {"recently updated", "-updated_at"},
      {"recently added", "-inserted_at"},
      {"name", "name"}
    ]

Pagination of Search Results

Adding Pagination Support

  • Add pagination to read :search:

    read :search do
      pagination offset?: true, default_limit: 12
    end
  • Returns Ash.Page.Offset struct with results.

Showing Paginated Data in the Catalog

  • Handle pages in LiveView:

    page = Tunez.Music.search_artists!(query_text, query: [sort_input: sort_by], page: page_params)
    socket = assign(socket, :page, page)
  • Render paginated results and navigation:

    <.pagination_links page={@page} query_text={@query_text} sort_by={@sort_by} />
  • Generate page links:

    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

No DB Field? No Problem—Calculations

Calculating Data

  • Example in Album resource:

    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
  • Load calculations with related records:

    Tunez.Music.get_artist_by_id(uuid, load: [albums: [:years_ago]])

Relationship Calculations as Aggregates

  • Define aggregates in Artist resource:

    aggregates do
      count :album_count, :albums
      first :latest_album_year_released, :albums, :year_released
      first :cover_image_url, :albums, :cover_image_url
    end
  • Use aggregates like attributes in LiveView:

    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]
    )
  • Default load for domain:

    define(:search_artists,
      action: :search,
      args: [:query],
      default_options: fn -> [load: [:album_count, :latest_album_year_released, :cover_image_url]] end
    )

Sorting Based on Aggregate Data

  • Add sorting options for aggregates:

    [
      {"recently updated", "-updated_at"},
      {"recently added", "-inserted_at"},
      {"name", "name"},
      {"number of albums", "-album_count"},
      {"latest album release", "-latest_album_year_released"}
    ]
  • Make aggregates public in artist.ex:

    aggregates do
      count :album_count, :albums, public?: true
      first :latest_album_year_released, :albums, :year_released, public?: true
    end