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

5 Likes

@kamaroly Thanks for pointing out, I have a similar use case except for invoice and invoice lines. I am able to manually calculate invoice line amount and invoice total amount using calculations you described. But I am not able to figure out is how to integrate it with phoenix form. What I want to achieve, is when creating new invoice and typing quantity and unit price the line amount will be automatically calculated and the total amount of the invoice, as well. For line items I am using AshPhoenix.Form.add_form, AshPhoenix.Form.remove_form and AshPhoenix.Form.sort_forms. It looks like I cannot use form params to set the amount attribute, as it is not attribute but calculation, the same with total_amount of the invocie. Are you able to point me to the right direction? What came to my mind is to have separate socket.assings for total_amount and calculate it in phx-change event but I don’t know what about line items and how to associate it with correct line and sorting…

@paveltu , I would use prepare_params callback. AshPhoenix.Form invokes prepare_params callback function is before each form validation or submission. You can then do your calculations and update the form params.

 defp get_form(%{actor: actor} = assigns) do
    MyApp.Sales.Sale
    |> Form.for_create(
      :create,
      actor: actor,
      prepare_params: &prepare_params(&1, &2, assigns)
    )
    |> to_form()
  end

  defp prepare_params(params, _validate, %{actor: actor} = assigns) do
    # Do your logic to get each line item. 
    quantity = Map.get(params, "quantity", 0) 
    price = Map.get(params, "price", 0)
    total = Decimal.mult(quantity, price)

    Map.put(params, "total", total)
  end

There’s another alternative approach I have been using that makes it simpler to test and maintain.

Each changes to the form such as adding new line items, change customers, add tax, paymet, discount or anything. I save them to the database then I listen to the pubsub event and reload the sale with needed calculations, aggregates and relationship.
See below example.

def mount(%{"sale_id" => sale_id}, _session, socket) do
    if connected?(socket) do
      MyAppWeb.Endpoint.subscribe("notes:" <> sale_id)
      MyAppWeb.Endpoint.subscribe("sales:" <> sale_id)
      MyAppWeb.Endpoint.subscribe("sale_line_item:" <> sale_id)
      MyAppWeb.Endpoint.subscribe("sale_discounts:" <> sale_id)
      MyAppWeb.Endpoint.subscribe("sale_payments:" <> sale_id)
    end

    socket
    |> assign(:sale_id, sale_id)
    |> assign_sale_order()
    |> assign_form()
    |> ok(layout: {MyAppWeb.Sales.Layouts, :sale})
  end

  def handle_event("save", %{"form" => attrs}, socket) do
    case Form.submit(socket.assigns.form, params: attrs) do
      {:ok, _sale} ->
        socket
        |> put_flash(:info, "Updated successfully.")
        |> assign_sale_order()
        |> assign_form()
        |> noreply()

      {:error, form} ->
        socket
        |> assign(:form, form)
        |> put_flash(:error, "Could not update this sale")
        |> noreply()
    end
  end

  def handle_info(_event=%Phoenix.Socket.Broadcast{}, socket) do
    socket
    |> assign_sale_order()
    |> noreply()
  end

  defp assign_sale_order(socket) do
    assign(socket, :sale, get_sale(socket.assigns))
  end

  defp assign_form(%{assigns: %{sale: sale, current_user: actor}} = socket) do
    form = Form.for_update(sale, :update, actor: actor) |> to_form()
    assign(socket, :form, form)
  end

  defp get_sale(%{sale_id: id, user: actor}) do
    Ash.get!(MyApp.Sales.Sale, id,
      actor: actor,
      load: [:notes, :discounts, :taxes, :payments, line_items: :inventory_item,  customer: :addresses]
    )
  end