How to Use Ash Calculations to Sum total of list items Without Hitting the Data Layer

I want to calculate the sub_total for a sale (sum of line_items.line_total) without querying the data layer. how can I achieve that?

Here’s my current setup for the sub_total calculation in the Sale resource:

calculations do
  calculate :sub_total, :decimal, expr(sum(line_items.line_total)) do
    description "Sub total amount for the sale before discounts or taxes"
    public? true
  end
end`

I’d like to calculate the sub_total using Ash.calculate!/3 by passing line_items data in-memory, like this:

sale_attrs = %{line_items: [%{line_total: 12}, %{line_total: 15}]}
Ash.calculate!(Sale, :sub_total, refs: sale_attrs)`

For context, I’m already doing something similar for line_total in the SaleLineItem resource, which works without hitting the data layer:

calculations do
  calculate :line_total, :decimal, expr(quantity * price) do
    public? true
    description "Total amount for the line item"
  end
end`

# I can compute line_total like this:
line_item_attrs = %{quantity: 2, price: 10}
Ash.calculate!(SaleLineItem, :line_total, refs: line_item_attrs)`

How can I configure the sub_total calculation to work with in-memory data (sale_attrs) and avoid hitting the data layer, similar to my line_total calculation?

:thinking: I’m not sure you’ll be able to do it for aggregates TBH. You could try w/ reuse_values?: true option though.

This would be a great feature to have.

Does it not work?

No, It didn’t work.

sale_attrs = %{line_items: [%{line_total: 12}, %{line_total: 15}]}
Ash.calculate!(Zippiker.Sales.Sale, :sub_total, refs: sale_attrs, reuse_values?: true)

Gives this error

Ash.calculate!(MyApp.Sales.Sale, :sub_total, refs: sale_attrs, reuse_values?: true)
** (RuntimeError) Expression `sum(line_items.line_total)` is invalid. `line_items.line_total` is not a valid relationship path from MyApp.Sales.Sale.
    (ash 3.5.14) lib/ash/filter/filter.ex:3252: Ash.Filter.resolve_call/2
    (ash 3.5.14) lib/ash/filter/filter.ex:3892: anonymous fn/3 in Ash.Filter.do_hydrate_refs/2
    (elixir 1.17.2) lib/enum.ex:4858: Enumerable.List.reduce/3
    (elixir 1.17.2) lib/enum.ex:2585: Enum.reduce_while/3
    (ash 3.5.14) lib/ash/filter/filter.ex:3891: Ash.Filter.do_hydrate_refs/2
    (ash 3.5.14) lib/ash/filter/filter.ex:3803: Ash.Filter.do_hydrate_refs/2
    (ash 3.5.14) lib/ash/actions/read/calculations.ex:89: Ash.Actions.Read.Calculations.calculate/3
    (ash 3.5.14) lib/ash.ex:1934: Ash.calculate!/3
    iex:17: (file)

  calculate :sub_total, :decimal, expr(sum(line_items, field: :line_total)) do
    description "Sub total amount for the sale before discounts or taxes"
    public? true
  end

the field is separate from the relationship path

1 Like

Thanks @zachdaniel , but it is still failing. It seems like it will still have to hit the table. I wanted to store the logic to compute sub_total in a calculation without having to hit the database, especially for uncreated orders, but it seems like I will have to hit the database.

I extracted it into a calculation module and realised that this is being treated as a query to a relationship, thus the failure to calculate without querying the database or setting the tenant especially since my app is a multitenant app. It does not make sense why the line item calculation works, but not the sub_total calculation on sale.

defmodule MyApp.Sales.Sale.Calculations.SubTotal do
  use Ash.Resource.Calculation

  @impl true
  def calculate(sale, opts, arguments) do
    {:ok, Decimal.new(0)}
  end
end

Calculation definition

  calculations do
    calculate :sub_total, :decimal, MyApp.Sales.Sale.Calculations.SubTotal do
      description "Sub total amount for the sale before any discounts or taxes"
      public? true
    end
  end

Error

{:error,
 %Ash.Error.Invalid{
   bread_crumbs: ["Error returned from: MyApp.Sales.Sale.read"], 
   query: "#Query<>", 
   errors: [
     %Ash.Error.Invalid.TenantRequired{
       resource: MyApp.Sales.Sale,
       splode: Ash.Error,
       bread_crumbs: ["Error returned from: MyApp.Sales.Sale.read"],
       vars: [],
       path: [],
       stacktrace: #Splode.Stacktrace<>,
       class: :invalid
     }
   ]
 }}

You can set tenant when running calculations, even if its not actually necessary for the calculation to run.

1 Like

It was simpler than I thought! I followed the contract of extracting a calculation in its module and it worked.

# MyApp.Sales.Sale resource
  calculations do
    calculate :sub_total, :decimal, MyApp.Sales.Sale.Calculations.SubTotal
  end

Calculation Module

defmodule MyApp.Sales.Sale.Calculations.SubTotal do
  use Ash.Resource.Calculation

  def description, do: "Calculates the sub total of a sale."
  def public?, do: true

  @impl true
  def load(_query, opts, _context) do
    [line_items: [:line_total, :quantity, :price]]
  end

  @impl true
  def calculate(sales, _opts, _arguments) do
    Enum.map(sales, &calculate_sub_total/1)
  end

  defp calculate_sub_total(%{line_items: line_items}) do
    line_items
    |> Enum.map(& &1.line_total)
    |> Enum.reduce(Decimal.new(0), &Decimal.add/2)
  end
end

Now I am able to successful calculate subtotal without hitting the DB

sale_attrs = %{line_items: [%{line_total: 12}, %{line_total: 15}]}
Ash.calculate!(Zippiker.Sales.Sale, :sub_total, refs: sale_attrs)

# Decimal.new("27")
1 Like

I just wrote an article about this. You can read it here: https://medium.com/@lambert.kamaro/how-to-reuse-business-logic-with-ash-calculations-andsave-time-like-a-pro-5eb95388c80d

3 Likes