Calculations have two aspects. Those defined within a:
- Resource - these use the Resource DSL for forming the Calculations.
- Query.
NOTE: A few points still need research and experimentation
Before we get started, let’s create some test data - with and without middle_names:
iex -S mix phx.server
# Create a Customer and a technician
customer = (
Support.User
|> Ash.Changeset.for_create(
:new_customer, %{first_name: "Ratna", last_name: "Sönam", email: "ratna@example.com"}
)
|> Support.AshApi.create!()
)
technician = (
Support.User
|> Ash.Changeset.for_create(
:new_employee, %{first_name: "Nyima", middle_name: "Druk",
last_name: "Sönam", email: "nyima@example.com",
department_name: "Tech Support"}
)
|> Support.AshApi.create!()
)
We will use these two accounts for testing in this section.
Resource Calculations
Calculations allow us to extend our resources from the Guides we can see the example of how to combine attributes and create a calculated field called :full_name
:
# lib/support/resources/user.ex
# ...
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
# ...
Example 1
Shortly we will see how this works but for now it might be interesting to see how Ash represents this:
recompile()
Support.User
|> Ash.Query.load([:full_name])
# #Ash.Query<
# resource: Support.User,
# calculations: %{
# full_name: #Ash.Resource.Calculation.Expression<[expr: first_name <> " " <> last_name]> - %{context: %{}}
# }
I am still exploring, but I believe if you follow the Expressions Docs you will be able to build the calculations you need.
Note: Filters use expressions too (so probably useful to spend time exploring these).
Example 2
We can also see from the Docs that there’s a built in calculation allowing us to accomplish the same thing with:
# lib/support/resources/user.ex
# ...
calculations do
# we can call a built-in calculation `concat`
calculate :full_name, :string, concat([:first_name, :last_name], " ")
end
# ...
In this case the internal representation looks like:
recompile()
Support.User
|> Ash.Query.load([:full_name])
#Ash.Query<
# resource: Support.User,
# calculations: %{
# full_name: #Ash.Resource.Calculation.Concat<[keys: [:first_name, :last_name], separator: " "]> - %{context: %{}}
# }
Retrieving Calculations
As we have seen above to ‘view’ the calculation can retrieve the calculated resource with the ’load’ function with the format:
recompile()
require Ash.Query
# we can retrieve the calculated resource with the 'load' function :
Support.User
|> Ash.Query.load([:full_name])
|> Support.AshApi.read!()
# you should get something like:
# [
# #Support.User<
# full_name: "Nyima Sönam",
# ...
# calculations: %{},
# ...
# >,
# ...
# ]
The calculated attribute full_name
should now be visible. We will cover the calculations: %{}
field shortly.
Calculations used within Queries
Calculated results once loaded can be used within the query - for example we can sort with it:
Support.User
|> Ash.Query.load([:full_name])
|> Ash.Query.sort(full_name: :asc)
|> Support.AshApi.read!()
or within a filter Query:
Support.User
|> Ash.Query.load([:full_name])
|> Ash.Query.filter(full_name == "Marpo Sönam")
|> Support.AshApi.read!()
The be sure to filter it with:
require Ash.Query
Support.User
|> Ash.Query.filter(all_assigned_tickets > 0) # prevent divide by zero
|> Ash.Query.filter(assigned_open_percent > 0.25)
|> Ash.Query.load(:assigned_open_percent)
|> Support.AshApi.read!()
Complexer Resource Calculations
WORK IN PROGRESS
If the calculation is difficult to write as a Resource Calculation, ie:
# lib/support/resources/user.ex
# ...
calculations do
calculate :full_name, :string, expr([first_name, last_name] |> Enum.join(" "))
calculate :formal_name, :string, expr(
last_name <> ", " <> (
[first_name, middle_name]
|> Enum.map(fn string -> is_binary(string) end)
|> Enum.join(" ")
)
)
end
# ...
when try to call these we get errors such as:
Support.User
|> Ash.Query.new()
|> Ash.Query.load([:full_name, :formal_name])
|> Support.AshApi.read!()
# warning: variable "first_name" does not exist and is being expanded to "first_name()", please use parentheses to remove the ambiguity or change the variable name
# lib/support/resources/user.ex:86: Support.User
#
# == Compilation error in file lib/support/resources/user.ex ==
# ** (CompileError) lib/support/resources/user.ex:86: undefined function first_name/0 (there is no such import)
Second Try
We need to carefully study Ash Expressions - which isn’t as flexible as elixir - as it is designed to work on multiple Data Layers.
# lib/support/resources/user.ex
# ...
calculations do
calculate :with_middle_name, :string,
expr(if exists(middle_name),
do: first_name <> " " <> middle_name <> " " <> last_name,
else: first_name <> " " <> last_name
)
end
end
# ...
when try to call these we get errors such as:
recompile()
require Ash.Query
Support.User
|> Ash.Query.new()
|> Ash.Query.load(:with_middle_name)
|> Support.AshApi.read!()
# Ash.Query.load([:with_middle_name])
# warning: the following fields are unknown when raising Ash.Error.Query.NoSuchFunction: [resource: Support.User]. Please make sure to only give known fields when raising or redefine Ash.Error.Query.NoSuchFunction.exception/1 to discard unknown fields. Future Elixir versions will raise on unknown fields given to raise/2
Custom Calculation Extensions
A good approach to complex calculations is to write a custom calculation!
For more complex calculations we can define our own calculations for example:
To build a reusable Resource Custom Resource Calculation we can do something like the following:
# lib/support/calculations/formal_name.ex
defmodule Support.Calculation.UserFormalName do
use Ash.Calculation
# records is a list of all the records being processed
# opts is a list of keys - which we could use to dynamically build the calculation
# context is a map of any additional information needed to build the calculation
@impl true
def calculate(records, _opts, _context) do
# each record is calculated individually
# (implement the `expression` callback function to move to the DataLayer_
Enum.map(records, fn record ->
last_join_first(record) # the actual calculation
end)
end
defp last_join_first(record) do
record.last_name <> ", " <> ([record.first_name, record.middle_name]
|> Enum.reject(&is_nil/1)
|> Enum.reject(fn string -> string == "" end)
|> Enum.join(" ")
)
end
# You can implement this callback to make this calculation possible in the data layer
# *and* in elixir. Ash expressions are already executable in Elixir or in the data layer,
# but this gives you fine grain control over how it is done
# @impl true
# def expression(opts, context) do
# end
end
# we will add it to the User Resource with:
# lib/support/resources/user.ex
defmodule Support.User do
# ...
calculations do
calculate :full_name, :string, concat([:first_name, :last_name], " ") # built-in calculator
calculate :formal_name, :string, {Support.Calculation.UserFormalName} # our custom calculator
end
# ...
end
TEST
recompile()
# call the Resource Calculation
Support.User
|> Ash.Query.new()
|> Ash.Query.load([:formal_name])
|> Support.AshApi.read!()
# now you should see something like:
# #Support.User<
# formal_name: "Customer, Friendly5",
# full_name: "Friendly5 Customer",
Query Calculation (submitting in-line expressions)
BROKEN - DO NOT USE THIS SECTION! This section needs work!
We can do Query Calculations too:
on the fly Query calculations - don’t work, I must be overlooking something
Support.User
|> Ash.Query.new()
|> Ash.Query.calculate(:both_names, :string, expr(first_name <> " " <> last_name))
|> Ash.Query.calculate(:names_both, :string, concat([:first_name, :last_name], " "))
|> Ash.Query.load([:full_name])
|> Support.AshApi.read!()
Support.User
|> Ash.Query.new()
|> Ash.Query.calculate(:username, :string, name <> "-")
|> Ash.Query.load([:full_name])
|> Support.AshApi.read!()
# or mixed
Support.User
|> Ash.Query.new()
|> Ash.Query.calculate(:both_names, concat([:first_name, :last_name], " "))
|> Ash.Query.calculate(:names_both, :string, concat([:first_name, :last_name], " "))
|> Ash.Query.load([:full_name])
|> Support.AshApi.read!()
Query Calculation (accessing Custom Calculator)
If we want to use the custom calculation within a query (and say give it another name) we can do the following:
Support.User
|> Ash.Query.new()
|> Ash.Query.calculate(:legal_name, {Support.Calculation.UserFormalName, []}, :string)
|> Support.AshApi.read!()
# now you should see something like:
# [
# #Support.User<
# ...
# id: "d949be4e-dd0f-484c-8bf2-e81e1b462b57",
# email: "friendly5@customer.com",
# first_name: "Friendly5",
# ...
# aggregates: %{},
# calculations: %{legal_name: "Customer, Friendly5"},
# ...
# >
# ]
Generic Custom Calculator
Now let’s explore a calculator that will work with more than one Resource (or can be used in various ways)
Let’s write a Generic Calculator - that does the same as before, but this time we can choose which attributes will be used and we will include a check in init
that we are using the options correctly.
# lib/support/calculations/first_separator_rest.ex
defmodule Support.Calculation.FirstSeparatorRest do
use Ash.Calculation
# Optional callback - verifies options has a list of keys (as atoms)
@impl true
def init(options) do
key_list = options[:keys]
if is_list(key_list) && (Enum.count(key_list) > 1) && Enum.all?(key_list, &is_atom/1) do
{:ok, options}
else
{:error, "Expected a `keys` option with at least two values to build a formal name"}
end
end
@impl true
def calculate(records, options, context) do
# each record must be calculated individually
# DataLayer API calculations are done with the `expression` callback function
Enum.map(records, fn record ->
first_separator_rest(record, options, context) # the actual calculation
end)
end
defp first_separator_rest(record, [keys: key_list] = _options, context) do
record_values = Enum.map(key_list, fn key ->
# fetch the value from the record based on the key and ensure it is a string
to_string(Map.get(record, key))
end)
[first_value | rest_values] = record_values
rest_values_list = if is_list(rest_values), do: rest_values, else: [rest_values]
# in case no separator was given use ", "
separator = context[:separator] || ", "
first_value <> separator <> (rest_values_list
|> Enum.reject(&is_nil/1) # remove nil values
|> Enum.reject(fn string -> string == "" end) # remove empty strings
|> Enum.join(" ") # join the remaining values into a string with a space separator
)
end
# TODO: not sure how this works yet
# You can implement this callback to make this calculation possible in the data layer
# *and* in elixir. Ash expressions are already executable in Elixir or in the data layer,
# but this gives you fine grain control over how it is done
# @impl true
# def expression(opts, context) do
# end
end
now we can update our User Resource with:
calculations do
# calculate :full_name, :string, expr(first_name <> " " <> last_name)
calculate :full_name, :string, concat([:first_name, :last_name], " ")
# ...
# custom calculations can be called with options
calculate :last_comma_first_name, :string,
{Support.Calculation.FirstSeparatorRest, [keys: [:last_name, :first_name]]}
calculate :last_separator_all_names, :string,
{Support.Calculation.FirstSeparatorRest,
[keys: [:last_name, :first_name, :middle_name]]} do
# not sure how to use this in a resource calculation
argument :separator, :string, constraints: [allow_empty?: true, trim?: false]
end
end
To send the separator
parameter - our load would look like:
Support.User
|> Ash.Query.new()
|> Ash.Query.load(:last_separator_all_names, %{separator: " ~ "})
|> Support.AshApi.read!()
To use the custom calculation as a query call it looks like (notice we can now order the keys in whatever order we want and submit our own separator):
Support.User
|> Ash.Query.new()
|> Ash.Query.calculate(:with_middle_name,
{Support.Calculation.FirstSeparatorRest,
keys: [:first_name, :middle_name, :last_name]},
:string, %{separator: ": "})
|> Support.AshApi.read!()
TEST
So lets create some
Now that we have some users (with and without middle_names):
Support.User
|> Ash.Query.new()
|> Ash.Query.load([:last_comma_first_name, :last_separator_names])
|> Ash.Query.load(:last_separator_all_names, %{separator: " ~ "})
|> Ash.Query.calculate(:dash_name,
{Support.Calculation.FirstSeparatorRest, keys: [:first_name, :last_name]},
:string, %{separator: " - "})
|> Ash.Query.calculate(:with_middle_name,
{Support.Calculation.FirstSeparatorRest, keys: [:last_name, :first_name, :middle_name]},
:string, %{separator: " -- "})
|> Support.AshApi.read!()
# now we should get something like:
# [
# #Support.User<
# last_separator_names: "Sönam, Nyima Druk",
# last_comma_first_name: "Sönam, Nyima",
# ...
# id: "424ef4aa-0bbc-4427-ad59-07a4032b5c8e",
# email: "nyima@example.com",
# first_name: "Nyima",
# middle_name: "Druk",
# last_name: "Sönam",
# ...
# aggregates: %{},
# calculations: %{
# dash_name: "Nyima - Sönam",
# with_middle_name: "Sönam -- Nyima Druk"
# },
# ...
# >
# ]