Calculations and Aggregates
These two features look similar at first — both expose computed fields on a resource — and for that reason Ash keeps them in separate DSL blocks.
-
A calculation is a value derived from the record itself:
full_name = first_name <> " " <> last_name,discount_percent = (price - sale_price) / price * 100. -
An aggregate is a value derived from related records: number of posts per user, sum of line-item totals per order, first comment on an article.
Both are opt-in: you declare them on the resource, then request them
via load: on a read call (or pre-load them by default in a custom
read action). They do not inflate your base struct until you ask for
them.
Setting Up a Fresh Ash App
The fastest way to get started with Ash is Igniter. One command scaffolds the project, pulls Ash (and whatever else you ask for), and wires up the config.
$ mix archive.install hex igniter_new
$ mix igniter.new app --install ash --yes
$ cd app
For a Phoenix application with Ash and PostgreSQL support:
$ mix igniter.new app --with phx.new --install ash,ash_postgres --yes
$ cd app
Igniter creates the App.Repo wrapper (when ash_postgres is
included), the application’s supervision tree, a sensible
.formatter.exs, and the Postgres config for every environment. You
still need to add the config :app, :ash_domains, [App.Shop] line to
config/config.exs once you define your first domain, which we do in
each chapter that uses this include.
Interactive web installer
You can also use the interactive web installer at https://ash-hq.org/#get-started to get a custom Igniter command tailored to your needs.
Manual setup alternative
If you want to see every file Igniter would create, walk through
the Ash Setup Guide. For a purely
manual route, mix new --sup app, add {:ash, "~> 3.0"} to
mix.exs, run mix deps.get, and configure .formatter.exs with
import_deps: [:ash]. Everything else in this chapter then matches
the Igniter flow.
The worked example is a small blog with User and Post:
defmodule App.Blog.Post do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Blog,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :title, :string, public?: true, allow_nil?: false
attribute :published, :boolean, public?: true, default: false
end
relationships do
belongs_to :author, App.Blog.User do
allow_nil? false
public? true
source_attribute :user_id
end
end
actions do
default_accept [:title, :published, :user_id]
defaults [:create, :read, :update, :destroy]
end
end
Calculations: derived fields on one record
Inside a calculations do … end block on the User resource, add a
full_name field built from two attributes:
defmodule App.Blog.User do
use Ash.Resource,
data_layer: Ash.DataLayer.Ets,
domain: App.Blog,
otp_app: :app
attributes do
uuid_primary_key :id
attribute :first_name, :string, public?: true, allow_nil?: false
attribute :last_name, :string, public?: true, allow_nil?: false
end
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name) do (1)
public? true
end
end
actions do
default_accept [:first_name, :last_name]
defaults [:create, :read, :update, :destroy]
end
end
| 1 | Three positional arguments: the name, the Ash type, and the
expression. The expression uses expr/1, the same DSL that powers
filters and policies, so you can reference attributes, relationship
paths, arguments, and Ash’s built-in functions. |
The domain exposes it with a normal code interface:
defmodule App.Blog do
use Ash.Domain, otp_app: :app
resources do
resource App.Blog.User do
define :create_user, action: :create
define :get_user, action: :read, get_by: :id
end
resource App.Blog.Post do
define :create_post, action: :create
define :list_posts, action: :read
end
end
end
Try it out. Calculations are not loaded automatically; pass
load: on the call (or on a custom read action):
$ iex -S mix
iex(1)> alice = App.Blog.create_user!(%{first_name: "Alice", last_name: "Aardvark"})
iex(2)> alice.full_name
#Ash.NotLoaded<:calculation, field: :full_name> (1)
iex(3)> {:ok, loaded} = App.Blog.get_user(alice.id, load: [:full_name])
iex(4)> loaded.full_name
"Alice Aardvark"
| 1 | Same #Ash.NotLoaded<…> story as relationships: the field
exists on the struct but has to be asked for. |
Arguments on a calculation
Calculations can take arguments, which lets the caller parameterise the value at load time:
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name) do
public? true
end
calculate :greeting, :string, expr(^arg(:salutation) <> " " <> first_name) do
public? true
argument :salutation, :string, allow_nil?: false
end
end
The caller passes the argument via a keyword list:
iex> App.Blog.get_user!(alice.id,
load: [greeting: %{salutation: "Hello,"}]
).greeting
"Hello, Alice"
Aggregates: derived fields from related records
Aggregates live in their own block and work on relationships. The
simplest is count/2:
relationships do
has_many :posts, App.Blog.Post do
public? true
end
end
aggregates do
count :posts_count, :posts do (1)
public? true
end
count :published_posts_count, :posts do
public? true
filter expr(published == true) (2)
end
end
| 1 | count :posts_count, :posts creates a lazy :integer field on
User that counts related posts when loaded. |
| 2 | filter expr(…) narrows the aggregate. With
AshPostgres.DataLayer this becomes a COUNT(*) FILTER (WHERE
published = true) — no N+1. |
Seed some data and load both aggregates in one read:
iex(1)> alice = App.Blog.create_user!(%{first_name: "Alice", last_name: "Aardvark"})
iex(2)> bob = App.Blog.create_user!(%{first_name: "Bob", last_name: "Buffalo"})
iex(3)> App.Blog.create_post!(%{title: "A1", user_id: alice.id, published: true})
iex(4)> App.Blog.create_post!(%{title: "A2", user_id: alice.id, published: false})
iex(5)> App.Blog.create_post!(%{title: "A3", user_id: alice.id, published: true})
iex(6)> App.Blog.create_post!(%{title: "B1", user_id: bob.id, published: true})
iex(7)> {:ok, loaded} =
App.Blog.get_user(alice.id,
load: [:full_name, :posts_count, :published_posts_count]
)
iex(8)> %{loaded.full_name => %{all: loaded.posts_count, public: loaded.published_posts_count}}
%{"Alice Aardvark" => %{all: 3, public: 2}}
Other aggregate types, all declared the same way:
-
sum—sum :total_sold, :line_items, :quantity -
first—first :latest_comment_body, :comments, :bodywith asortblock to pick which row wins. -
list—list :tag_names, :tags, :namereturns the full list. -
exists—exists :has_published_post?, :postswith an optionalfilter. -
avg,min,max— the usual suspects.
Sideload by default
If you always want the calculation or aggregate on every read, put it on a custom read action:
actions do
default_accept [:first_name, :last_name]
defaults [:create, :update, :destroy]
read :read do
primary? true
prepare build(load: [:full_name, :posts_count])
end
end
Now App.Blog.list_users!() returns users with :full_name and
:posts_count already populated, without the caller having to
remember load:.
When to use which
-
Field depends on attributes of the same record, no DB roundtrip needed?
calculate. -
Field depends on related records (count of comments, sum of line items, most recent reply)?
aggregate. -
Need both? Use them together. A calculation can reference an aggregate inside its
expr(…)— Ash evaluates aggregates first, so a derived:popularitycalculation can doexpr(posts_count * 10 + likes_count).
With AshPostgres, both calculations and aggregates compile
to SQL. get_user!(id, load: [:posts_count]) runs exactly one
query that includes the count as a subquery, not a separate
SELECT count(*). ETS does the bookkeeping in Elixir and is fine
for development.
|