AshReports.Charts.DataSourceHelpers (ash_reports v0.1.0)

Helper functions for optimizing chart data source queries.

Avoiding N+1 Query Problems

When working with large datasets, eagerly loading relationships with Ash.Query.load/2 can cause severe performance issues known as "N+1 query problems". This occurs when loading a relationship for each record in a collection, resulting in N individual queries.

Example Problem

# ❌ BAD - This creates an N+1 query problem
data_source(fn ->
  InvoiceLineItem
  |> Ash.Query.load(product: :category)  # Loads product for EVERY line item!
  |> Ash.read!(domain: MyApp.Domain)
end)

With 325,000 line items, this performs 325,000+ individual lookups. This can take 8+ minutes on large datasets!

Solution Pattern

# ✅ GOOD - Load relationships separately and join in memory
data_source(fn ->
  # 1. Load main records without relationships
  items = InvoiceLineItem |> Ash.read!(domain: MyApp.Domain)

  # 2. Get unique related IDs
  product_ids = items |> Enum.map(& &1.product_id) |> Enum.uniq()

  # 3. Load related records once
  products =
    Product
    |> Ash.read!(domain: MyApp.Domain)
    |> build_lookup_map(:id)

  # 4. Join in memory using helper
  chart_data = join_and_aggregate(items, products, :product_id)
end)

This reduces execution time from 8 minutes to <1 second on large datasets!

Using the Helpers

This module provides helper functions to make the optimized pattern easier to implement:

See individual function documentation for detailed usage examples.

Summary

Functions

Builds a fast lookup map from a collection of records.

Extracts unique ID values from a collection of records.

Joins source records with related records using a lookup map.

Loads related records in a single batch query.

Optimized pattern for loading records with a single relationship.

Warns if records were loaded with relationships (potential N+1 issue).

Functions

build_lookup_map(records, key_field)

@spec build_lookup_map(list(), atom()) :: map()

Builds a fast lookup map from a collection of records.

This creates a map where keys are the specified field values and values are the records themselves, enabling O(1) lookups instead of O(N) scans.

Parameters

  • records - Collection of records to index
  • key_field - Field name to use as the map key (atom)

Returns

A map with key_field values as keys and records as values.

Examples

# Create lookup map by ID
products = [%Product{id: 1, name: "A"}, %Product{id: 2, name: "B"}]
lookup = build_lookup_map(products, :id)
# => %{1 => %Product{id: 1, name: "A"}, 2 => %Product{id: 2, name: "B"}}

# Create lookup map by name
lookup = build_lookup_map(products, :name)
# => %{"A" => %Product{id: 1, name: "A"}, "B" => %Product{id: 2, name: "B"}}

extract_unique_ids(records, id_field)

@spec extract_unique_ids(list(), atom()) :: list()

Extracts unique ID values from a collection of records.

Filters out nil values and returns only unique IDs.

Parameters

  • records - Collection of records
  • id_field - Field name containing the ID (atom)

Returns

List of unique, non-nil ID values.

Examples

line_items = [
  %{product_id: 1},
  %{product_id: 1},
  %{product_id: 2},
  %{product_id: nil}
]

extract_unique_ids(line_items, :product_id)
# => [1, 2]

join_with_lookup(source_records, lookup_map, foreign_key)

@spec join_with_lookup(list(), map(), atom()) :: [tuple()]

Joins source records with related records using a lookup map.

This performs an in-memory join by looking up related records for each source record.

Parameters

  • source_records - Collection of source records
  • lookup_map - Map created by build_lookup_map/2
  • foreign_key - Field in source records containing the lookup key

Returns

List of tuples {source_record, related_record} for matching records.

Examples

products_map = build_lookup_map(products, :id)

joined = join_with_lookup(line_items, products_map, :product_id)
# => [
#   {%LineItem{product_id: 1}, %Product{id: 1}},
#   {%LineItem{product_id: 2}, %Product{id: 2}}
# ]

load_with_relationship(source_module, related_module, foreign_key, opts)

@spec load_with_relationship(module(), module(), atom(), keyword()) ::
  {:ok, {list(), map()}} | {:error, term()}

Optimized pattern for loading records with a single relationship.

This is a convenience function that combines the common pattern of loading records, extracting IDs, loading related records, and creating a lookup map.

Parameters

  • source_module - Ash resource module for source records
  • related_module - Ash resource module for related records
  • foreign_key - Field in source records pointing to related records
  • opts - Keyword list of options
    • :domain - Ash domain (required)
    • :preload - Fields to preload on related records (optional)

Returns

{:ok, {source_records, lookup_map}} or {:error, reason}

Examples

{:ok, {line_items, products_map}} = load_with_relationship(
  InvoiceLineItem,
  Product,
  :product_id,
  domain: MyApp.Domain,
  preload: :category
)

# Now use the lookup map
enriched_items = Enum.map(line_items, fn item ->
  product = products_map[item.product_id]
  %{item | product: product}
end)

warn_if_loaded(records, relationship_fields)

@spec warn_if_loaded(list(), [atom()]) :: list()

Warns if records were loaded with relationships (potential N+1 issue).

This can be used as a runtime check to detect potentially inefficient queries. Checks the first record in the collection to see if relationship fields are loaded.

Parameters

  • records - Collection of records to check
  • relationship_fields - List of relationship field names (atoms)

Returns

The original records (passthrough for piping).

Examples

InvoiceLineItem
|> Ash.read!(domain: MyApp.Domain)
|> warn_if_loaded([:product, :invoice])
# Logs warning if product or invoice are loaded