Guidance on Dynamic Filtering, Sorting, and Pagination

Hello Ash community, I'm implementing standard filtering, sorting, and pagination features for my contacts module and have defined a read action as follows:
define :list_contacts, action: :read
define :list_contacts, action: :read
In my controller, I parse incoming parameters with default values:
search_query = Map.get(params, "search", "")
current_page = Map.get(params, "page", "1") |> String.to_integer()
sort_by = Map.get(params, "sort_by", "created_at")
sort_dir = Map.get(params, "sort_dir", "desc")
search_query = Map.get(params, "search", "")
current_page = Map.get(params, "page", "1") |> String.to_integer()
sort_by = Map.get(params, "sort_by", "created_at")
sort_dir = Map.get(params, "sort_dir", "desc")
I invoke the function as:
Contacts.list_contacts!(actor: current_user)
Contacts.list_contacts!(actor: current_user)
I'd like to understand how to pass these parameters to effectively filter, sort, and paginate the contacts list. Specifically: - How can I apply a case-insensitive search filter to multiple attributes and only when search_query is present? - What's the recommended way to implement dynamic sorting based on sort_by and sort_dir? - How should I handle pagination using current_page? - Are there best practices for sanitizing and validating these parameters before applying them? Any guidance or examples on implementing these features would be greatly appreciated. Thanks in advance!
14 Replies
franckstifler
franckstifler•7d ago
I think first thing your function invocation is not right. It should be something like: Contacts.list_contacts!(params, actor: current_user), where params is a map. If you want to apply case-insensitive search you'll need a preparation on your read action.
read :read do
argument :search, :string
argument :sort, :string

prepare fn query, _context ->
sort = Ash.Query.get_argument(query, :sort)

query =
case sort do
nil ->
query

"" ->
query

sort ->
Ash.Query.sort(query, ^sort)
end

search = Ash.Query.get_argument(query, :search)

case search do
nil ->
query

"" ->
query

search ->
search = "%#{search}%"

Ash.Query.filter(query, ilike(email, ^search) or ilike(phone_number, ^search))
end
end
end
read :read do
argument :search, :string
argument :sort, :string

prepare fn query, _context ->
sort = Ash.Query.get_argument(query, :sort)

query =
case sort do
nil ->
query

"" ->
query

sort ->
Ash.Query.sort(query, ^sort)
end

search = Ash.Query.get_argument(query, :search)

case search do
nil ->
query

"" ->
query

search ->
search = "%#{search}%"

Ash.Query.filter(query, ilike(email, ^search) or ilike(phone_number, ^search))
end
end
end
https://hexdocs.pm/ash/Ash.Query.html#sort/3 you can see how to pass the options to sort here. I tend to use the "-name" syntax. To call your action you'll do: Contacts.list_contacts!(%{search: "something to search", sort: "-name,phone,-date_of_birth"}, actor: current_user, page: page_config) Now thing like pagination are handled with the page option: Contacts.list_contacts!(params, actor: current_user, page: page_config) where page_config depends wether you're using a keyset or an offset pagination. https://hexdocs.pm/ash/pagination.html#offset-pagination-1 please read all this docs to better understand how to use pagination.
ZachDaniel
ZachDaniel•7d ago
Look into Ash.Query.filter_input and Ash.Query.sort_input 🙂
Joan Gavelán
Joan GavelánOP•7d ago
Hi @franckstifler , thanks for the reply. Unfortunately, the code you provided has invalid syntax. It did give me some ideas though I am trying this:
prepare fn query, _context ->
if Ash.Query.get_argument(query, :search) do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^arg(:search))))
)
else
query
end
end
prepare fn query, _context ->
if Ash.Query.get_argument(query, :search) do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^arg(:search))))
)
else
query
end
end
But doesn't seem to work either. Any ideas @ZachDaniel?
ZachDaniel
ZachDaniel•7d ago
Will need more info than "doesn't work" 🙂
Joan Gavelán
Joan GavelánOP•7d ago
Hope this helps
No description
Joan Gavelán
Joan GavelánOP•7d ago
My action:
read :list_contacts do
argument :search, :string

prepare fn query, _context ->
if Ash.Query.get_argument(query, :search) do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^arg(:search))))
)
else
query
end
end

pagination offset?: true, keyset?: true, required?: false
end
read :list_contacts do
argument :search, :string

prepare fn query, _context ->
if Ash.Query.get_argument(query, :search) do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^arg(:search))))
)
else
query
end
end

pagination offset?: true, keyset?: true, required?: false
end
How I'm calling it:
contacts =
Contacts.list_contacts!(
%{search: "test"},
actor: current_user,
page: [limit: @page_limit, count: true, offset: offset]
)
contacts =
Contacts.list_contacts!(
%{search: "test"},
actor: current_user,
page: [limit: @page_limit, count: true, offset: offset]
)
ZachDaniel
ZachDaniel•7d ago
Ah, right Use ^query.arguments.search
Joan Gavelán
Joan GavelánOP•6d ago
Great. It's working now. I'll continue with the remaining features and let you know how it goes. Thanks! Hey Zach. I've gotten this far with everything working smoothly:
read :list_contacts do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

prepare fn query, _context ->
if search = query.arguments[:search] do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^search)))
)
else
query
end
end

prepare fn query, _context ->
if sort = query.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end
end

prepare fn query, _context ->
page = query.arguments[:page] |> String.to_integer()
offset = (page - 1) * 10

Ash.Query.page(query, limit: 10, offset: offset, count: true)
end

pagination offset?: true, required?: false
end
read :list_contacts do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

prepare fn query, _context ->
if search = query.arguments[:search] do
Ash.Query.filter_input(
query,
expr(contains(string_downcase(name), string_downcase(^search)))
)
else
query
end
end

prepare fn query, _context ->
if sort = query.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end
end

prepare fn query, _context ->
page = query.arguments[:page] |> String.to_integer()
offset = (page - 1) * 10

Ash.Query.page(query, limit: 10, offset: offset, count: true)
end

pagination offset?: true, required?: false
end
And my controller:
def index(conn, params) do
current_user = conn.assigns.current_user
contacts = Contacts.list_contacts!(params, actor: current_user)

page_count = ceil(contacts.count / contacts.limit)
contacts = %{
data: contacts.results |> serialize_contacts(),
pagination: %{page_count: page_count}
}

conn
|> assign_prop(:contacts, contacts)
|> assign_prop(:params, params)
|> render_inertia("Contacts/Index")
end
End
def index(conn, params) do
current_user = conn.assigns.current_user
contacts = Contacts.list_contacts!(params, actor: current_user)

page_count = ceil(contacts.count / contacts.limit)
contacts = %{
data: contacts.results |> serialize_contacts(),
pagination: %{page_count: page_count}
}

conn
|> assign_prop(:contacts, contacts)
|> assign_prop(:params, params)
|> render_inertia("Contacts/Index")
end
End
I believe there's still some room for improvement though. Is it possible to transform the paginated data within the action itself, so that I can return the transformed data directly and avoid handling the transformation in the controller?
ZachDaniel
ZachDaniel•6d ago
You'd need to use a generic action to do some additional processing of the paginated results
Joan Gavelán
Joan GavelánOP•5d ago
I got it working like this:
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run fn input, context ->
require Ash.Query
import Ash.Expr, only: [expr: 1]

query = __MODULE__

# Apply search filter if provided
query =
if search = input.arguments[:search] do
Ash.Query.filter(
query,
expr(contains(string_downcase(name), string_downcase(^search)))
)
else
query
end

# Apply sorting if provided
query =
if sort = input.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end

# Calculate pagination
limit = 10
current_page = input.arguments[:page] |> String.to_integer()
offset = (current_page - 1) * limit

# Apply pagination
query = Ash.Query.page(query, limit: limit, offset: offset, count: true)

# Execute the query
case Ash.read(query, actor: context.actor) do
{:ok, contacts} ->
total_pages = ceil(contacts.count / contacts.limit)

result = %{
data: ContactlyWeb.Serializers.serialize_contacts(contacts.results),
pagination: Contactly.Pagination.build_pagination(current_page, total_pages)
}

{:ok, result}

{:error, error} ->
{:error, error}
end
end
end
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run fn input, context ->
require Ash.Query
import Ash.Expr, only: [expr: 1]

query = __MODULE__

# Apply search filter if provided
query =
if search = input.arguments[:search] do
Ash.Query.filter(
query,
expr(contains(string_downcase(name), string_downcase(^search)))
)
else
query
end

# Apply sorting if provided
query =
if sort = input.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end

# Calculate pagination
limit = 10
current_page = input.arguments[:page] |> String.to_integer()
offset = (current_page - 1) * limit

# Apply pagination
query = Ash.Query.page(query, limit: limit, offset: offset, count: true)

# Execute the query
case Ash.read(query, actor: context.actor) do
{:ok, contacts} ->
total_pages = ceil(contacts.count / contacts.limit)

result = %{
data: ContactlyWeb.Serializers.serialize_contacts(contacts.results),
pagination: Contactly.Pagination.build_pagination(current_page, total_pages)
}

{:ok, result}

{:error, error} ->
{:error, error}
end
end
end
This is really good. One question though, what should I look into so I can make this logic reusable for other resources? In a real-world application I'm developing, I'll have about five different resources that will require the same dynamic filtering, sorting, pagination and serialization.
ZachDaniel
ZachDaniel•5d ago
You can extract that into a shared action implementation, which you then provide options to potentially
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run {MyApp.Actions.ReadAndSerialize, serializer: :serialize_contacts}
end
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run {MyApp.Actions.ReadAndSerialize, serializer: :serialize_contacts}
end
Joan Gavelán
Joan GavelánOP•4d ago
Alright. This is my implementation:
defmodule Contactly.Actions.GenericListing do
use Ash.Resource.Actions.Implementation

require Ash.Query

import Ash.Expr

@impl true
def run(input, opts, context) do
resource = opts[:resource]
search_by = opts[:search_by]
serializer = opts[:serializer]

# Base query
query = Ash.Query.new(resource)

# Apply search filter if provided
query =
if search_query = input.arguments[:search] do
filter = build_search_filter(search_by, search_query)
Ash.Query.filter(query, ^filter)
else
query
end

# Apply sorting if provided
query =
if sort = input.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end

# Calculate pagination
limit = 10
current_page = input.arguments[:page] |> String.to_integer()
offset = (current_page - 1) * limit

# Apply pagination
query = Ash.Query.page(query, limit: limit, offset: offset, count: true)

# Execute the query
case Ash.read(query, actor: context.actor) do
{:ok, page} ->
total_pages = ceil(page.count / page.limit)

result = %{
data: apply(ContactlyWeb.Serializers, serializer, [page.results]),
pagination: Contactly.Pagination.build_pagination(current_page, total_pages)
}

{:ok, result}

{:error, error} ->
{:error, error}
end
end

defp build_search_filter([key], search) do
expr(
contains(
string_downcase(^ref(key)),
string_downcase(^search)
)
)
end

defp build_search_filter(keys, search) when is_list(keys) do
# Turn each key into an Ash.Expr.t()
[first | rest] = Enum.map(keys, &build_search_filter([&1], search))

# Fold them into one big OR expression
Enum.reduce(rest, first, fn next_filter, acc ->
expr(^acc or ^next_filter)
end)
end
end
defmodule Contactly.Actions.GenericListing do
use Ash.Resource.Actions.Implementation

require Ash.Query

import Ash.Expr

@impl true
def run(input, opts, context) do
resource = opts[:resource]
search_by = opts[:search_by]
serializer = opts[:serializer]

# Base query
query = Ash.Query.new(resource)

# Apply search filter if provided
query =
if search_query = input.arguments[:search] do
filter = build_search_filter(search_by, search_query)
Ash.Query.filter(query, ^filter)
else
query
end

# Apply sorting if provided
query =
if sort = input.arguments[:sort] do
Ash.Query.sort_input(query, sort)
else
query
end

# Calculate pagination
limit = 10
current_page = input.arguments[:page] |> String.to_integer()
offset = (current_page - 1) * limit

# Apply pagination
query = Ash.Query.page(query, limit: limit, offset: offset, count: true)

# Execute the query
case Ash.read(query, actor: context.actor) do
{:ok, page} ->
total_pages = ceil(page.count / page.limit)

result = %{
data: apply(ContactlyWeb.Serializers, serializer, [page.results]),
pagination: Contactly.Pagination.build_pagination(current_page, total_pages)
}

{:ok, result}

{:error, error} ->
{:error, error}
end
end

defp build_search_filter([key], search) do
expr(
contains(
string_downcase(^ref(key)),
string_downcase(^search)
)
)
end

defp build_search_filter(keys, search) when is_list(keys) do
# Turn each key into an Ash.Expr.t()
[first | rest] = Enum.map(keys, &build_search_filter([&1], search))

# Fold them into one big OR expression
Enum.reduce(rest, first, fn next_filter, acc ->
expr(^acc or ^next_filter)
end)
end
end
And I'm using it like this:
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run {Contactly.Actions.GenericListing,
resource: __MODULE__, search_by: [:name, :email], serializer: :serialize_contacts}
end
action :list_contacts, :map do
argument :search, :string
argument :sort, :string
argument :page, :string, default: "1"

run {Contactly.Actions.GenericListing,
resource: __MODULE__, search_by: [:name, :email], serializer: :serialize_contacts}
end
Love it. I think this is a beautiful piece of reusable action. Please tell me if you see something that it's worth refactoring otherwise I'll be good to go
ZachDaniel
ZachDaniel•4d ago
Looks great!
Joan Gavelán
Joan GavelánOP•4d ago
Awesome! I'll tag this as solved. Thanks a lot for the support Zach.

Did you find this page helpful?