Powered by AppSignal & Oban Pro

Ash Calculations for Fun and Profit

ash-calculations.livemd

Ash Calculations for Fun and Profit

Application.put_env(:ash, :validate_domain_resource_inclusion?, false)
Application.put_env(:ash, :validate_domain_config_inclusion?, false)

Mix.install(
  [
    {:ash, "~> 3.0"},
    {:ash_phoenix, "~> 2.3"},
    {:kino_explorer, "~> 0.1.20"},
    {:kino_phoenix_live_view, "~> 0.1.4"}
  ],
  consolidate_protocols: false
)

Introduction

Filter forms can quickly become difficult to manage and often they lead to a lot of filtering logic scattered across LiveViews and/or LiveComponents. This example demonstrates how Ash embedded resources and calculations can simplify filter forms while providing a scalable, maintainable architecture.

> ℹ️ NOTE: This is an interactive Elixir LiveBook that you can run on your computer. Go to https://livebook.dev/ to learn more. > Run in Livebook

Key Concepts Demonstrated

This LiveBook showcases several powerful Ash patterns:

  • Embedded Resources as Form State Models - Instead of managing filter state directly in LiveView, we model it as an embedded resource with attributes, validations, and calculations
  • Progressive Query Building with Calculations - Calculations that build on each other to create a pipeline of query transformations
  • Faceted Search - Using calculations to generate counts for filter options dynamically
  • AshPhoenix Form Integration - Seamless integration between embedded resources and Phoenix forms

Why Embedded Resources for Filters?

Traditional approaches often scatter filter logic across LiveViews. Using an embedded resource provides:

  • Single source of truth for filter state and logic
  • Built-in validation of filter parameters
  • Reusable calculations that can be loaded on-demand
  • Type safety and casting through Ash’s attribute system
  • Form integration via AshPhoenix.Form
  • Testability - Each piece of logic can be tested in isolation

Ash Setup

Let’s start by defining the Ash resources required for this demo. We’re using Ash.DataLayer.Ets as the data layer to keep the dependencies minimal, but this pattern works equally well with PostgreSQL or any other Ash data layer.

The Blog Post Resource

Our example models a simple blog system with posts that have authors, categories, and publishing states:

defmodule Blog.Post do
  use Ash.Resource,
    domain: Blog,
    data_layer: Ash.DataLayer.Ets

  defmodule Status do
    use Ash.Type.Enum, values: [:draft, :published, :archived]
  end

  actions do
    defaults([:read, create: :*, update: :*])
  end

  attributes do
    uuid_primary_key(:id)

    attribute :author, :string do
      allow_nil?(false)
      public?(true)
    end

    attribute :title, :string do
      allow_nil?(false)
      public?(true)
    end

    attribute :category, :string do
      allow_nil?(false)
      public?(true)
    end

    attribute :status, Status do
      allow_nil?(false)
      public?(true)
      default(:draft)
    end
  end
end
{:module, Blog.Post, <<70, 79, 82, 49, 0, 0, 251, ...>>, :ok}

The Blog Domain

Domains in Ash organize related resources and provide a public API. Here we define a simple domain with a code interface for creating posts:

defmodule Blog do
  use Ash.Domain

  resources do
    resource(Blog.Post) do
      define(:create_post, action: :create)
    end
  end
end

Application.put_env(:blog, :ash_domains, [Blog])
Ash.Info.mermaid_overview(:blog) |> Kino.Mermaid.new()
{"caption":null,"diagram":"classDiagram\n    Blog\n    class Blog.Post {\n        Domain: Blog\n        Source: Projects/lightning-talks/talks/ash-calculations/ash-calculations.livemd#cell:acfphvlv2vu7qyif\n\n        Ash.Type.UUID id\n        Ash.Type.String author\n        Ash.Type.String title\n        Ash.Type.String category\n        Blog.Post.Status status\n        update(Ash.Type.String author, Ash.Type.String title, Ash.Type.String category, Blog.Post.Status status)\n        create(Ash.Type.String author, Ash.Type.String title, Ash.Type.String category, Blog.Post.Status status)\n        read()\n    }\n\n\n","download":true}

Sample Data Generation

To demonstrate our filter functionality, let’s load some sample blog posts. The data includes posts with various authors, categories, and statuses to showcase the filtering capabilities:

df =
  Kino.FS.file_path("posts.csv")
  |> Explorer.DataFrame.from_csv!()
#Explorer.DataFrame<
  Polars[51 x 4]
  title string ["Understanding GenServers in Elixir", "Building Real-Time Features with Phoenix LiveView", "Exploring Ash Framework for Rapid API Development", "Optimizing Ecto Queries for Large Datasets", "How to Deploy a Phoenix App with Docker and Fly.io", ...]
  category string ["elixir", "phoenix", "ash", "elixir", "phoenix", ...]
  status string ["draft", "published", "archived", "published", "draft", ...]
  author string ["John", "Peter", "Bob", "Peter", "Peter", ...]
>
df
|> Explorer.DataFrame.to_rows()
|> Ash.bulk_create!(Blog.Post, :create)

11:28:34.598 [debug] Creating 51 Blog.Post:

%{id: "7259940d-c377-4bbb-8a81-6d673d5c5398", status: :draft,
 title: "Understanding GenServers in Elixir", author: "John",
 category: "elixir"}
%{id: "1626ad2b-6ebc-40ff-9039-9ddfe7344cc0", status: :published,
 title: "Building Real-Time Features with Ph..., author: "Peter",
 category: "phoenix"}
%{id: "fc6d3930-b94f-42b2-8285-85651989f3e5", status: :archived,
 title: "Exploring Ash Framework for Rapid A..., author: "Bob", category: "ash"}
%{id: "22461acc-ddf4-4d48-bd77-efec47bc32ea", status: :published,
 title: "Optimizing Ecto Queries for Large D..., author: "Peter",
 category: "elixir"}
%{id: "3296d012-bd0e-426b-870a-5d420e008091", status: :draft,
 title: "How to Deploy a Phoenix App with Do..., author: "Peter",
 category: "phoenix"}
%{id: "6f03b8ee-ad23-49bb-bb81-9fe6118c1226", status: :published,
 title: "Ash Authentication: A Practical Gui..., author: "Peter",
 category: "ash"}
%{id: "b643ca98-a208-45c5-b035-8fd5b4e1d16f", status: :archived,
 title: "Using Oban for Background Jobs in E..., author: "Peter",
 category: "elixir"}
%{id: "ea9ea497-017c-4325-8f10-a23c87f8898e", status: :published,
 title: "Real-World Authorization with Phoen..., author: "Peter",
 category: "phoenix"}
%{id: "efff9742-d7b0-400c-83bb-1247a539c1aa", status: :draft,
 title: "Building Declarative APIs with Ash ..., author: "Peter",
 category: "ash"}
%{id: "3b0c3a85-0512-44a0-b395-a0e586be3ce9", status: :published,
 title: "Pattern Matching Tips You Might Not..., author: "Paul",
 category: "elixir"}
%{id: "69094612-7b4f-41cf-b018-498692ce661d", status: :published,
 title: "WebSocket Scaling with Phoenix Chan..., author: "Alice",
 category: "phoenix"}
%{id: "bbcf07f0-5ca1-48ee-860b-88dc84938706", status: :archived,
 title: "Ash Data Layer Deep Dive", author: "Alice", category: "ash"}
%{id: "8a3d2817-72bf-4445-a85e-e358bcc5a628", status: :draft,
 title: "Supercharging Your Mix Tasks", author: "Peter", category: "elixir"}
%{id: "84ad8ff3-65b6-4abd-a37c-b793d55cbd6c", status: :archived,
 title: "Multi-Tenant Architectures in Phoen..., author: "Alice",
 category: "phoenix"}
%{id: "77ce395f-8ea5-40de-989a-f0364fe28564", status: :published,
 title: "Declarative Workflows in Ash Flow", author: "Peter", category: "ash"}
%{id: "57abb320-fd27-4b7c-a419-30f3a456abf1", status: :published,
 title: "Testing Strategies for Elixir Appli..., author: "Alice",
 category: "elixir"}
%{id: "fd8547ed-56f4-45da-a143-f74a1f132034", status: :draft,
 title: "Optimizing Asset Delivery with Phoe..., author: "Peter",
 category: "phoenix"}
%{id: "81b8f273-f86c-4783-9ef9-2cee49dec87b", status: :draft,
 title: "Integrating Ash with LiveView", author: "Peter", category: "ash"}
%{id: "d6157361-a45b-485a-861c-c7b9fd85a34c", status: :archived,
 title: "Designing Fault-Tolerant Systems wi..., author: "Peter",
 category: "elixir"}
%{id: "d109fe43-fa32-4268-ad7a-eaf3705079a4", status: :published,
 title: "Deploying Phoenix Apps to Kubernetes", author: "Peter",
 category: "phoenix"}
%{id: "672f5022-8476-4c83-998b-ac0ff99b51d5", status: :draft,
 title: "Understanding GenServers in Elixir", author: "Peter",
 category: "elixir"}
%{id: "ed6192b8-1919-4b3e-b6b1-a33eb87066c1", status: :published,
 title: "Building Real-Time Features with Ph..., author: "Peter",
 category: "phoenix"}
%{id: "b0d43cf7-a5f1-465a-934f-3fd486bac62e", status: :archived,
 title: "Exploring Ash Framework for Rapid A..., author: "Alice",
 category: "ash"}
%{id: "b85d2564-a59a-46c4-8698-c2d3acc64e51", status: :published,
 title: "Optimizing Ecto Queries for Large D..., author: "Peter",
 category: "elixir"}
%{id: "5119e984-72a5-473a-b8e3-c81bb7d8f454", status: :draft,
 title: "How to Deploy a Phoenix App with Do..., author: "John",
 category: "phoenix"}
%{id: "2535c5bd-d647-421d-a847-c43f86f03bdb", status: :published,
 title: "Ash Authentication: A Practical Gui..., author: "Peter",
 category: "ash"}
%{id: "2480f992-a730-4df1-81b7-0e653b672398", status: :archived,
 title: "Using Oban for Background Jobs in E..., author: "Peter",
 category: "elixir"}
%{id: "dce873ad-ef40-4769-8520-542dace8f0f5", status: :published,
 title: "Real-World Authorization with Phoen..., author: "Alice",
 category: "phoenix"}
%{id: "be93e30c-dd58-46b4-a5d3-4900b796c0a5", status: :draft,
 title: "Building Declarative APIs with Ash ..., author: "Peter",
 category: "ash"}
%{id: "0934c990-19a8-4ca0-91f8-d774e163b262", status: :published,
 title: "Pattern Matching Tips You Might Not..., author: "Paul",
 category: "elixir"}
%{id: "6c4e1caa-6e99-49af-ad9a-efa970c14dbc", status: :published,
 title: "WebSocket Scaling with Phoenix Chan..., author: "Peter",
 category: "phoenix"}
%{id: "03b2131a-8bbe-4f70-97a2-d1145a01ba62", status: :archived,
 title: "Ash Data Layer Deep Dive", author: "John", category: "ash"}
%{id: "32f1ab28-0df3-4aef-9b01-cd5b4d9a444e", status: :draft,
 title: "Supercharging Your Mix Tasks", author: "Peter", category: "elixir"}
%{id: "c084aacb-96ea-43ed-93f3-b048fded7a32", status: :archived,
 title: "Multi-Tenant Architectures in Phoen..., author: "Peter",
 category: "phoenix"}
%{id: "d02f3b18-6e69-41c8-a277-d5ae52fbecdb", status: :published,
 title: "Declarative Workflows in Ash Flow", author: "Peter", category: "ash"}
%{id: "d805bec3-ae25-4a24-88ff-12ce3f8ed4fb", status: :published,
 title: "Testing Strategies for Elixir Appli..., author: "John",
 category: "testing"}
%{id: "31b17dc1-57e1-4ecd-8124-59c720b04d61", status: :draft,
 title: "Optimizing Asset Delivery with Phoe..., author: "Peter",
 category: "phoenix"}
%{id: "783f071b-510e-400a-8a7c-affa02b2b7fa", status: :draft,
 title: "Integrating Ash with LiveView", author: "Peter", category: "ash"}
%{id: "adee3cd1-c7f1-4167-885a-ef5a9ead35cc", status: :archived,
 title: "Designing Fault-Tolerant Systems wi..., author: "Paul",
 category: "elixir"}
%{id: "16ede4e8-ef75-4914-a310-1fc52c11299a", status: :published,
 title: "Deploying Phoenix Apps to Kubernetes", author: "Thomas",
 category: "devops"}
%{id: "1e95d9de-256e-4786-90ab-0c2cabcbd9bc", status: :published,
 title: "Continuous Deployment with GitHub A..., author: "Thomas",
 category: "devops"}
%{id: "f4299af1-3638-4471-aef9-85b3f4f994e8", status: :published,
 title: "Monitoring Phoenix Applications wit..., author: "Thomas",
 category: "devops"}
%{id: "7e7abae9-b6f7-49b3-b4e5-cd4771b77591", status: :draft,
 title: "Property-Based Testing with StreamD..., author: "Thomas",
 category: "testing"}
%{id: "5ca95930-2d17-48dd-9162-87e4a9d42238", status: :published,
 title: "How Elixir Handles Concurrency the ..., author: "Thomas",
 category: "elixir"}
%{id: "88ca5001-fc2b-435d-b399-0e1de8fda5e4", status: :draft,
 title: "Using Swoosh for Transactional Emai..., author: "Peter",
 category: "phoenix"}
%{id: "756cdf71-0e76-4e2e-88f2-f8a2b62a9ce8", status: :published,
 title: "Scaling Ash APIs in Production", author: "Thomas", category: "ash"}
%{id: "dbda51bf-426f-452c-8ed0-23ef750f2b7a", status: :published,
 title: "Building a Simple Chatbot with Elix..., author: "Thomas",
 category: "ai"}
%{id: "76e10f21-abda-494a-b367-fc8c6b6b8953", status: :draft,
 title: "AI-Powered Error Triage in Phoenix ..., author: "Thomas",
 category: "ai"}
%{id: "63b92e1e-85d7-4779-aaee-3b47254f9d74", status: :archived,
 title: "CI/CD Pipelines for Elixir Projects", author: "Thomas",
 category: "devops"}
%{id: "4e91baac-26fe-4631-b62d-2a9c31565aa9", status: :published,
 title: "Testing GraphQL Endpoints in Phoenix", author: "Thomas",
 category: "testing"}
%{id: "81d15f6c-01f7-4013-b889-90c262a664e6", status: :published,
 title: "Event-Driven Architectures with Eli..., author: "Thomas",
 category: "elixir"}
%Ash.BulkResult{status: :success, errors: nil, records: nil, notifications: nil, error_count: 0}

Let’s verify our data loaded correctly and see what we’re working with:

Blog.Post
|> Ash.read!()
|> Enum.take(5)
|> Kino.DataTable.new(name: "Sample Posts", keys: [:title, :author, :category, :status])
[%{id: "03b2131a-8bbe-4f70-97a2-d1145a01ba62", status: :archived, title: "Ash Data Layer Deep Dive", author: "John", category: "ash", calculations: %{}, aggregates: %{}}, %{id: "0934c990-19a8-4ca0-91f8-d774e163b262", status: :published, title: "Pattern Matching Tips You Might Not Know", author: "Paul", category: "elixir", calculations: %{}, aggregates: %{}}, %{id: "1626ad2b-6ebc-40ff-9039-9ddfe7344cc0", status: :published, title: "Building Real-Time Features with Phoenix LiveView", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "16ede4e8-ef75-4914-a310-1fc52c11299a", status: :published, title: "Deploying Phoenix Apps to Kubernetes", author: "Thomas", category: "devops", calculations: %{}, aggregates: %{}}, %{id: "1e95d9de-256e-4786-90ab-0c2cabcbd9bc", status: :published, title: "Continuous Deployment with GitHub Actions and Fly.io", author: "Thomas", category: "devops", calculations: %{}, aggregates: %{}}]

Filter Resource - The Heart of Our Solution

Ash embedded resources are a great way to encapsulate form filter logic! They allow us to use actions and validations, which would otherwise be implemented in a LiveView or LiveComponent.

Requirements

In this example, we want to build a search page that does the following (similar to how Galaxus works):

  • The page should have a search input field, where user can search posts by title
  • On the left, we want to show a set of facets (attribute values of the search results with the number of results per value)
  • The filter should generate a results_query (an Ash.Query that, when read, will return the results.
  • We want count the number of records that matched the search and facet filter

The Filter Pipeline Pattern

Our filter resource implements a pipeline pattern using calculations:

  1. base_query → The starting point (all posts)
  2. search_query → Apply text search to base_query
  3. search_count → Count results after search
  4. result_query → Apply all other filters to search_query
  5. result_count → Count final filtered results
  6. facets → Generate counts for each filterable field

This separation allows us to:

  • Generate facets based on the search results (not the final filtered results)
  • Show counts at each stage of filtering
  • Reuse query logic across different contexts
  • Provide a better user experience with progressive filtering
defmodule Blog.Post.Filter do
  @moduledoc """
  An embedded resource that models filter state for blog posts.

  This resource encapsulates all filter logic, including:
  - Filter attributes (what can be filtered)
  - Query building through calculations
  - Facet generation for UI
  - Validation of filter parameters
  """

  use Ash.Resource,
    data_layer: :embedded,
    domain: Blog

  require Ash.Query

  alias Blog.Post
  alias Blog.Post.Status
  alias Blog.Post.Filter.Calculations

  actions do
    defaults([:read, create: :*, update: :*])
  end

  changes do
    # Automatically load all calculations when the filter is updated
    # This ensures the UI always has fresh data
    change(
      load([
        :base_count,
        :search_query,
        :search_count,
        :result_query,
        :result_count,
        :status_facets,
        :category_facets,
        :author_facets
      ])
    )
  end

  attributes do
    # The starting query - can be customized for different contexts
    # For example: passing a pre-filtered query for user-specific posts
    attribute :base_query, :struct do
      constraints(instance_of: Ash.Query)
      allow_nil?(false)
      default(fn -> Ash.Query.new(Post) end)
      public?(true)
    end

    # Free-text search across titles
    attribute :search, :string do
      allow_nil?(true)
      public?(true)
    end

    # Filter by category (single selection)
    attribute :category, :string do
      allow_nil?(true)
      public?(true)
    end

    # Filter by multiple statuses
    attribute :states, {:array, Status} do
      constraints(remove_nil_items?: true)
      allow_nil?(true)
      public?(true)
      default([:published])
    end

    # Filter by multiple authors
    attribute :authors, {:array, :string} do
      constraints(remove_nil_items?: true)
      allow_nil?(true)
      public?(true)
    end

    # Filter to show only the current user's posts
    attribute :mine?, :boolean do
      allow_nil?(false)
      public?(true)
      default(false)
    end
  end

  calculations do
    # Count of all available posts
    calculate :base_count, :integer do
      calculation({Calculations.Count, query: :base_query})
    end

    # Query with search applied
    calculate :search_query, :struct do
      constraints(instance_of: Ash.Query)
      calculation({Calculations.SearchQuery, query: :base_query})
    end

    # Count after search (before other filters)
    calculate :search_count, :integer do
      calculation({Calculations.Count, query: :search_query})
    end

    # Final query with all filters applied
    calculate :result_query, :struct do
      constraints(instance_of: Ash.Query)
      calculation({Calculations.ResultQuery, query: :search_query})
    end

    # Final count of filtered results
    calculate :result_count, :integer do
      calculation({Calculations.Count, query: :result_query})
    end

    # Facets for UI - based on search_query to show available options
    calculate :category_facets, :map do
      calculation({Calculations.Facet, field: :category, query: :search_query})
    end

    calculate :status_facets, :map do
      calculation({Calculations.Facet, field: :status, query: :search_query})
    end

    calculate :author_facets, :map do
      calculation({Calculations.Facet, field: :author, query: :search_query})
    end
  end

  code_interface do
    define(:new, action: :create, args: [{:optional, :base_query}])
    define(:update, action: :update)
  end
end
{:module, Blog.Post.Filter, <<70, 79, 82, 49, 0, 3, 208, ...>>, :ok}

Understanding the Calculation Chain

The power of this approach lies in how calculations build on each other:

graph LR
    BQ[base_query
All Posts] BC[base_count
Total Count] SQ[search_query
Posts matching search term] SC[search_count
Count after search] RQ[result_query
Final filtered posts] RC[result_count
Final count] CF[category_facets
Count per category] SF[status_facets
Count per status] AF[author_facets
Count per author] BQ --> BC BQ --> SQ SQ --> SC SQ --> RQ SQ --> CF SQ --> SF SQ --> AF RQ --> RC style BQ fill:#e1f5fe style SQ fill:#fff3e0 style RQ fill:#e8f5e9 style BC fill:#f3e5f5 style SC fill:#f3e5f5 style RC fill:#f3e5f5 style CF fill:#fce4ec style SF fill:#fce4ec style AF fill:#fce4ec

Each calculation receives the filter record and can access other calculated values through loading. This creates a reactive system where changing the filter automatically recalculates dependent values.

Key Benefits:

  • Lazy Loading: Calculations are only computed when requested
  • Caching: Calculated values are cached on the record
  • Composability: Calculations can use other calculations as inputs
  • Testability: Each calculation can be tested independently

Query Building Calculations

Module Calculations vs Expression Calculations

We use module calculations here because:

  • Complex Logic: Our filtering logic is too complex for simple expressions
  • Reusability: These calculations can be shared across resources
  • Performance: We can optimize query building in Elixir code
  • Debugging: Easier to debug and test than embedded expressions

Each calculation module follows the same pattern:

  1. load/3 - Declare dependencies (which attributes/calculations to load)
  2. calculate/3 - Transform the input data to produce the calculated value

Search Query Calculation

This calculation applies text search to the base query:

defmodule Blog.Post.Filter.Calculations.SearchQuery do
  @moduledoc """
  Ash calculation that creates a query with search terms applied.

  This calculation:
  - Takes the base query from the filter
  - Applies text search if a search term is present
  - Splits search terms and matches all of them (AND logic)
  - Returns the modified query for further processing
  """

  use Ash.Resource.Calculation

  require Ash.Query

  @impl true
  def calculate(filters, opts, context) do
    Enum.map(filters, &amp;search_query(&amp;1, opts, context))
  end

  defp search_query(filter, opts, ctx) do
    query = Keyword.get(opts, :query)

    filter
    |> Map.fetch!(query)
    |> apply_search(filter.search, ctx)
  end

  defp apply_search(query, search, _ctx) when is_binary(search) do
    terms = String.split(search)

    # Each term must be present in the title (AND logic)
    Enum.reduce(terms, query, fn term, query ->
      Ash.Query.filter(query, contains(string_downcase(title), string_downcase(^term)))
    end)
  end

  defp apply_search(query, _search, _ctx) do
    query
  end
end
{:module, Blog.Post.Filter.Calculations.SearchQuery, <<70, 79, 82, 49, 0, 0, 21, ...>>,
 {:apply_search, 3}}

Result Query Calculation

This calculation applies all non-search filters to create the final query:

defmodule Blog.Post.Filter.Calculations.ResultQuery do
  @moduledoc """
  Ash calculation that creates the final result query.

  This calculation:
  - Takes the search_query as input
  - Applies category, status, and ownership filters
  - Handles actor-based filtering (mine? attribute)
  - Returns the final query for execution
  """

  use Ash.Resource.Calculation

  require Ash.Query

  @impl true
  def calculate(filters, opts, context) do
    Enum.map(filters, &amp;result_query(&amp;1, opts, context))
  end

  defp result_query(filter, opts, ctx) do
    query = Keyword.get(opts, :query)

    filter
    |> Ash.load!(query, actor: ctx.actor)
    |> Map.fetch!(query)
    |> apply_filters(filter, ctx)
  end

  defp apply_filters(query, filter, ctx) do
    query
    |> apply_filter({:category, filter.category}, ctx)
    |> apply_filter({:states, filter.states}, ctx)
    |> apply_filter({:authors, filter.authors}, ctx)
    |> apply_filter({:mine?, filter.mine?}, ctx)
  end

  defp apply_filter(query, {:category, category}, _ctx) when is_binary(category) do
    Ash.Query.filter(query, category == ^category)
  end

  defp apply_filter(query, {:states, [_ | _] = states}, _ctx) do
    Ash.Query.filter(query, status in ^states)
  end

  defp apply_filter(query, {:authors, [_ | _] = authors}, _ctx) do
    Ash.Query.filter(query, author in ^authors)
  end

  defp apply_filter(query, {:mine?, true}, ctx) do
    case ctx.actor do
      nil -> Ash.Query.add_error(query, :mine?, "Actor is required")
      actor -> Ash.Query.filter(query, author == ^actor)
    end
  end

  defp apply_filter(query, _filter, _ctx) do
    query
  end
end
{:module, Blog.Post.Filter.Calculations.ResultQuery, <<70, 79, 82, 49, 0, 0, 30, ...>>,
 {:apply_filter, 3}}

Filter Calculations

Count Calculation

A reusable calculation that counts the results of any query:

defmodule Blog.Post.Filter.Calculations.Count do
  @moduledoc """
  Ash calculation that counts query results.

  This is a generic calculation that can count the results
  of any query passed via options. It's used multiple times
  in the filter resource to show counts at different stages.
  """

  use Ash.Resource.Calculation

  require Logger

  @impl true
  def load(_query, opts, _ctx) do
    # Declare that we need the specified query to be loaded
    Keyword.fetch!(opts, :query)
  end

  @impl true
  def calculate(filters, opts, ctx) do
    Enum.map(filters, &amp;count(&amp;1, opts, ctx))
  end

  defp count(filter, opts, ctx) do
    query = Keyword.fetch!(opts, :query)

    # load the required query to make sure it is up to date!
    filter
    |> Ash.load!(query, actor: ctx.actor)
    |> Map.fetch!(query)
    |> Ash.count!(actor: ctx.actor)
  end
end
{:module, Blog.Post.Filter.Calculations.Count, <<70, 79, 82, 49, 0, 0, 18, ...>>, {:count, 3}}

Important Performance

This example is counting the records in memory because we are using an ETS backend. In real life you would like to use Ecto to do the counting:

defp alert_counts(query) do
  with {:ok, count_query} <- alert_count_query(query) do
    {:ok, Iris.Repo.all(count_query)}
  end
end

defp alert_count_query(query) do
  with {:ok, ecto_query} <- Ash.Query.data_layer_query(query) do
    ecto_query =
      ecto_query
      |> exclude(:select)
      |> select([:id, :status, :severity])
      |> exclude(:order_by)

    alert_count_query =
      from a in subquery(ecto_query),
        group_by: [a.status, a.severity],
        select: %{status: a.status, severity: a.severity, count: count(a.id)}

    {:ok, alert_count_query}
  end
end

Faceted Search with Calculations

Facets show how many results match each filter option. Our approach:

  • Calculate facets from the search_query (not the fully filtered results)
  • This lets users see what filters are available based on their search
  • Counts update dynamically as filters are applied

Example: If a user searches for “Elixir”, the category facets will show:

  • elixir (5)
  • phoenix (2) ← Posts mentioning “Elixir” in Phoenix category
  • ash (1) ← Posts mentioning “Elixir” in Ash category

This provides better UX than traditional approaches where facets might disappear when selected.

defmodule Blog.Post.Filter.Calculations.Facet do
  @moduledoc """
  Ash calculation that generates facet counts for filtering.

  Facets show the count of results for each unique value
  of a field. This helps users understand:
  - What filter options are available
  - How many results match each option
  - Which filters will yield results

  We calculate facets from the search_query (not result_query)
  so users can see all available options, not just the currently
  filtered ones.
  """

  use Ash.Resource.Calculation

  @impl true
  def calculate(filters, opts, ctx) do
    Enum.map(filters, &amp;facet_count(&amp;1, opts, ctx))
  end

  defp facet_count(filter, opts, ctx) do
    field = Keyword.fetch!(opts, :field)
    query = Keyword.fetch!(opts, :query)

    # load the required query to make sure it is up to date!
    filter
    |> Ash.load!(query, actor: ctx.actor)
    |> Map.fetch!(query)
    |> Ash.read!()
    |> Enum.group_by(&amp;Map.get(&amp;1, field))
    |> Enum.map(fn {facet, results} -> {facet, Enum.count(results)} end)
    |> Enum.sort_by(&amp;elem(&amp;1, 1), :desc)
  end
end
{:module, Blog.Post.Filter.Calculations.Facet, <<70, 79, 82, 49, 0, 0, 20, ...>>, {:facet_count, 3}}

Testing the Filter Resource

Let’s test our filter resource independently before integrating with LiveView:

Basic Filter Creation

# Create a new filter with defaults
default_filter = Blog.Post.Filter.new!()
%Blog.Post.Filter{
  base_query: #Ash.Query,
  category: nil,
  states: [:published],
  search: nil,
  mine?: false,
  base_count: 51,
  search_query: #Ash.Query,
  search_count: 51,
  result_query: #Ash.Query>,
  result_count: 25,
  category_facets: [
    {"elixir", 15},
    {"phoenix", 14},
    {"ash", 13},
    {"devops", 4},
    {"testing", 3},
    {"ai", 2}
  ],
  status_facets: [published: 25, draft: 15, archived: 11],
  author_facets: [{"Peter", 26}, {"Thomas", 11}, {"Alice", 6}, {"John", 4}, {"Paul", 3}, {"Bob", 1}],
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

Applying Search

# Apply a search term
search_filter = Blog.Post.Filter.update!(default_filter, %{search: "elixir"})
IO.inspect(search_filter.search_count, label: "Posts matching 'elixir'")

# View the facets after search
IO.inspect(search_filter.category_facets, label: "Category facets after search")

search_filter
Posts matching 'elixir': 12
Category facets after search: [{"elixir", 9}, {"ai", 1}, {"devops", 1}, {"testing", 1}]
%Blog.Post.Filter{
  base_query: #Ash.Query,
  category: nil,
  states: [:published],
  search: "elixir",
  mine?: false,
  base_count: 51,
  search_query: #Ash.Query<
    resource: Blog.Post,
    filter: #Ash.Filter
  >,
  search_count: 12,
  result_query: #Ash.Query<
    resource: Blog.Post,
    filter: #Ash.Filter
  >,
  result_count: 5,
  category_facets: [{"elixir", 9}, {"ai", 1}, {"devops", 1}, {"testing", 1}],
  status_facets: [published: 5, archived: 5, draft: 2],
  author_facets: [{"Peter", 4}, {"Thomas", 4}, {"John", 2}, {"Alice", 1}, {"Paul", 1}],
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

Combining Filters

# Apply multiple filters
combined_filter =
  Blog.Post.Filter.update!(default_filter, %{
    search: "websocket",
    category: "phoenix",
    states: [:published, :draft]
  })

IO.inspect(combined_filter.result_count, label: "Final filtered count")

# Get the actual results
results = Ash.read!(combined_filter.result_query)
Kino.DataTable.new(results, name: "Filtered Results", keys: [:title, :author, :category, :status])
Final filtered count: 2
[%{id: "69094612-7b4f-41cf-b018-498692ce661d", status: :published, title: "WebSocket Scaling with Phoenix Channels", author: "Alice", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "6c4e1caa-6e99-49af-ad9a-efa970c14dbc", status: :published, title: "WebSocket Scaling with Phoenix Channels", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}]

Actor-based Filtering

# Test the "mine?" filter with an actor
peter_filter =
  Blog.Post.Filter.new!()
  |> Blog.Post.Filter.update!(%{mine?: true}, actor: "Peter")

IO.inspect(peter_filter.result_query, label: "Peter's posts")

# View Peter's posts
Ash.read!(peter_filter.result_query)
|> Kino.DataTable.new(name: "Peter's Posts", keys: [:title, :author, :status])
Peter's posts: #Ash.Query<
  resource: Blog.Post,
  filter: #Ash.Filter
>
[%{id: "1626ad2b-6ebc-40ff-9039-9ddfe7344cc0", status: :published, title: "Building Real-Time Features with Phoenix LiveView", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "22461acc-ddf4-4d48-bd77-efec47bc32ea", status: :published, title: "Optimizing Ecto Queries for Large Datasets", author: "Peter", category: "elixir", calculations: %{}, aggregates: %{}}, %{id: "2535c5bd-d647-421d-a847-c43f86f03bdb", status: :published, title: "Ash Authentication: A Practical Guide", author: "Peter", category: "ash", calculations: %{}, aggregates: %{}}, %{id: "6c4e1caa-6e99-49af-ad9a-efa970c14dbc", status: :published, title: "WebSocket Scaling with Phoenix Channels", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "6f03b8ee-ad23-49bb-bb81-9fe6118c1226", status: :published, title: "Ash Authentication: A Practical Guide", author: "Peter", category: "ash", calculations: %{}, aggregates: %{}}, %{id: "77ce395f-8ea5-40de-989a-f0364fe28564", status: :published, title: "Declarative Workflows in Ash Flow", author: "Peter", category: "ash", calculations: %{}, aggregates: %{}}, %{id: "b85d2564-a59a-46c4-8698-c2d3acc64e51", status: :published, title: "Optimizing Ecto Queries for Large Datasets", author: "Peter", category: "elixir", calculations: %{}, aggregates: %{}}, %{id: "d02f3b18-6e69-41c8-a277-d5ae52fbecdb", status: :published, title: "Declarative Workflows in Ash Flow", author: "Peter", category: "ash", calculations: %{}, aggregates: %{}}, %{id: "d109fe43-fa32-4268-ad7a-eaf3705079a4", status: :published, title: "Deploying Phoenix Apps to Kubernetes", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "ea9ea497-017c-4325-8f10-a23c87f8898e", status: :published, title: "Real-World Authorization with Phoenix and Bodyguard", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}, %{id: "ed6192b8-1919-4b3e-b6b1-a33eb87066c1", status: :published, title: "Building Real-Time Features with Phoenix LiveView", author: "Peter", category: "phoenix", calculations: %{}, aggregates: %{}}]

Phoenix LiveView Integration

This livebook is using kino_phoenix_livebook. It comes with a default layout that does not include DaisyUI. That’s why I define a custom layout for our LiveView.

defmodule ExampleWeb.Layout do
  use Phoenix.Component

  alias KinoPhoenixLiveView.ProxyEndpoint

  def render("root.html", assigns) do
    ~H"""
    
    
      
        
        
        <.live_title>
          <%= assigns[:page_title] || "Phoenix Playground" %>
        
        
      
      
        
        
        
        

        
          // Set global hooks and uploaders objects to be used by the LiveSocket,
          // so they can be overwritten in user provided templates.
          window.hooks = {}
          window.uploaders = {}

          let liveSocket =
            new window.LiveView.LiveSocket(
              "<%= ProxyEndpoint.static_path("/proxylive") %>",
              window.Phoenix.Socket,
              { hooks, uploaders, transport: window.Phoenix.LongPoll }
            )
          liveSocket.connect()
        
        
          <%= @inner_content %>
        
      
    
    """
  end
end
{:module, ExampleWeb.Layout, <<70, 79, 82, 49, 0, 0, 29, ...>>, {:render, 2}}

Core Components

The follwing module defines some form components similar to the default core components found in every Phoenix project.

On thing to point out is the function for the custom input type facet-filter, that takes a list of facets (list of labels and counts):

[
  {"Option 1", 42},
  {"Option 2", 23},
  {"Option 3", 23},
  {"Option 4", 2},
]

When multiple is true, they and renders as checkboxes, otherwise as radio-buttons.

defmodule ExampleWeb.CoreComponents do
  @moduledoc false

  use Phoenix.Component

  alias Phoenix.HTML.FormField

  attr(:id, :any, default: nil)
  attr(:name, :any)
  attr(:label, :string, default: nil)

  attr(:hint, :string,
    default: nil,
    doc: "hint text displayed as a tooltip next to the label"
  )

  attr(:value, :any)

  attr(:type, :string,
    default: "text",
    values: ~w(checkbox color date datetime datetime-local email file month number password
               search select tel text textarea time url week hidden facet-filter)
  )

  attr(:field, FormField,
    doc: "a form field struct retrieved from the form, for example: @form[:email]"
  )

  attr(:errors, :list, default: [])
  attr(:checked, :boolean, doc: "the checked flag for checkbox inputs")
  attr(:prompt, :string, default: nil, doc: "the prompt for select inputs")

  attr(:options, :list, doc: "the options to pass to Phoenix.HTML.Form.options_for_select/2")

  attr(:multiple, :boolean, default: false, doc: "the multiple flag for select inputs")
  attr(:class, :string, default: nil, doc: "the input class to use over defaults")

  attr(:error_class, :string,
    default: nil,
    doc: "the input error class to use over defaults"
  )

  attr(:rest, :global,
    include: ~w(accept autocomplete capture cols disabled form list max maxlength min minlength
                multiple pattern placeholder readonly required rows size step)
  )

  def input(%{field: %FormField{} = field} = assigns) do
    errors = if Phoenix.Component.used_input?(field), do: field.errors, else: []

    assigns
    |> assign(field: nil, id: assigns.id || field.id)
    |> assign(:errors, errors)
    |> assign_new(:name, fn ->
      if assigns.multiple, do: field.name <> "[]", else: field.name
    end)
    |> assign_new(:value, fn -> field.value end)
    |> input()
  end

  def input(%{type: "facet-filter"} = assigns) do
    %{multiple: multiple, value: value} = assigns

    assigns =
      assigns
      |> assign(:type, if(multiple, do: "checkbox", else: "radio"))
      |> assign_new(:selection, fn -> List.wrap(value) end)

    ~H"""
    
      {@label}

      

      
         All
      

      No filters

      
         "-sm"]}
          type={@type}
          name={@name}
          aria-label={option}
          value={option}
          checked={option in @selection}
        />
        
          {option}{count}
        
      

      <.error :for={msg <- @errors}>{msg}
    
    """
  end

  def input(%{type: "checkbox"} = assigns) do
    assigns =
      assign_new(assigns, :checked, fn ->
        Phoenix.HTML.Form.normalize_value("checkbox", assigns[:value])
      end)

    ~H"""
    
      
        
        
          {@label}
        
      
      <.error :for={msg <- @errors}>{msg}
    
    """
  end

  # All other inputs text, datetime-local, url, password, etc. are handled here...
  def input(assigns) do
    ~H"""
    
      
        <.input_label label={@label} />
        
      
      <.error :for={msg <- @errors}>{msg}
    
    """
  end

  attr(:label, :string, default: nil)

  def input_label(assigns) do
    ~H"""
    
      {@label}
    
    """
  end

  # Helper used by inputs to generate form errors
  def error(assigns) do
    ~H"""
    

{render_slot(@inner_block)}

"""
end slot :tab do attr(:name, :string) attr(:label, :string) end attr(:change, :any, required: true) attr(:selected, :string, required: true) attr(:class, :any, default: nil) attr(:rest, :global) def tabs(assigns) do ~H""" <%= for tab <- @tab do %> {render_slot(tab)} <% end %> """ end end
{:module, ExampleWeb.CoreComponents, <<70, 79, 82, 49, 0, 0, 165, ...>>, {:tabs, 1}}

The Beauty of AshPhoenix.Form

Notice how clean our LiveView becomes when filter logic lives in the embedded resource:

  • No manual changeset building
  • No filter validation in the LiveView
  • Automatic form error handling
  • All business logic contained in the resource

The LiveView only handles:

  1. Creating the filter resource
  2. Updating it through AshPhoenix.Form
  3. Rendering the UI

Form State Management

The filter resource acts as our form’s state container:

defmodule ExampleWeb.LiveView do
  use Phoenix.LiveView

  import ExampleWeb.CoreComponents

  alias Blog.Post.Filter

  require Logger

  def mount(_params, _session, socket) do
    socket
    |> assign(:current_user, "Peter")
    |> assign_form()
    |> assign(:tab, "results")
    |> ok()
  end

  defp assign_form(socket, filter \\ Filter.new!()) do
    %{current_user: current_user} = socket.assigns

    filter_form =
      filter
      |> AshPhoenix.Form.for_update(:update, as: "filter")
      |> to_form()

    posts = Ash.read!(filter.result_query, actor: current_user)

    socket
    |> assign(:posts, posts)
    |> assign(:filter, filter)
    |> assign(:filter_form, filter_form)
  end

  def render(assigns) do
    ~H"""
    
      
        My Blog
      
      
        
      
    

    
      
        <.filter_form for={@filter_form} />
      

      <.tabs selected={@tab} change="tab:change" class="grow">
        <:tab name="results" label="Results">
          <.post_list posts={@posts} />
        

        <:tab name="filter" label="Filter">
          
{inspect(@filter, pretty: true)}
"""
end def handle_event("tab:change", %{"tab" => tab}, socket) do socket |> assign(:tab, tab) |> noreply() end def handle_event("filter:update", %{"filter" => filter_params}, socket) do %{filter_form: filter_form, current_user: current_user} = socket.assigns # AshPhoenix.Form handles validation and updates case AshPhoenix.Form.submit(filter_form, params: filter_params, action_opts: [actor: current_user]) do {:ok, filter} -> socket |> assign_form(filter) |> noreply() {:error, filter_form} -> socket |> assign(:filter_form, filter_form) |> noreply() end end attr(:for, :any) def filter_form(assigns) do ~H""" <.form :let={form} for={@for} as={:filter} phx-change="filter:update" phx-submit="filter:update" > <.input type="text" field={form[:search]} placeholder={"Search #{@for.data.base_count} posts"} class="input w-full rounded-full" /> Only mine <.input type="checkbox" field={form[:mine?]} label="Show only my posts?" class="toggle toggle-primary" /> <.input type="facet-filter" label="Category" field={form[:category]} options={@for.data.category_facets} /> <.input type="facet-filter" label="Status" field={form[:states]} options={@for.data.status_facets} multiple /> <.input type="facet-filter" label="Author" field={form[:authors]} options={@for.data.author_facets} multiple /> """ end attr :posts, :list, required: true def post_list(assigns) do ~H"""
  • {post.title} {post.status} in {post.category} by {post.author}
"""
end defp noreply(socket), do: {:noreply, socket} defp ok(socket), do: {:ok, socket} end
{:module, ExampleWeb.LiveView, <<70, 79, 82, 49, 0, 0, 89, ...>>, {:ok, 1}}

Interactive Demo

Now let’s see our filter in action! The form below demonstrates:

  • Real-time facet updates as you type
  • Multi-select filters for status and author
  • Single-select category filter
  • Progressive filtering with count displays

Try different combinations to see how the calculations update automatically:

KinoPhoenixLiveView.new(
  path: "/proxy/apps/ash-calculations",
  live_view: ExampleWeb.LiveView,
  root_layout: ExampleWeb.Layout
)