Ramal.Aggregate (Ramal v0.2.0)

Copy Markdown View Source

Aggregates for Ramal.aggregate/4: a pure computation over a list of records, and the delta path that uses it.

The aggregates Ash hands to the data layer never see a branch's overlay. Ramal.aggregate/4 therefore answers in one of two ways:

  • delta (the default). Let K be the primary keys in the branch's overlay for the resource. The database aggregates every matching row except K (delta/5, which is Ash.count/2, Ash.sum/3 and the like with a key filter added). The branch then reads only the K rows through the patched path, which gives exactly the branch's version of them, filtered by the whole query. combine/5 joins the two. The cost grows with the size of the overlay, not with the size of the table. It does not apply when the query has a limit, an offset or a distinct, because the aggregate then depends on the order of rows the delta does not have.
  • read (compute/4). Read every matching row through the patched path and aggregate in Elixir. Exact for any query, and as expensive as the read.

The kinds follow Ash's: :count (rows), :exists, :sum, :min, :max and :avg. As in SQL, nil values are skipped, and :sum, :min, :max and :avg of no values are nil. :sum of decimals is a Decimal, :avg is a float (Ash types it as :float, lib/ash/query/aggregate.ex). :min and :max order values the way a runtime sort does (the runtime sort in Ash.Actions.Sort), so dates, decimals and strings compare by their type.

Confirmed against Ash 3.37 for the delta path: the aggregate action (lib/ash/actions/aggregate.ex) builds its query with Ash.Query.for_read/4, so the action's policies and preparations apply, but it does not run the before_action and after_action hooks that Ramal.Preparation installs. The database part is therefore never patched, which is what the delta needs. The rows it aggregates are not fingerprinted either, so they do not join the merge staleness check (ADR 0013).

Example

iex> records = [%{head: 30}, %{head: nil}, %{head: 12}]
iex> Ramal.Aggregate.compute(:sum, :head, records, Ramal.Test.Farm.Herd)
42
iex> Ramal.Aggregate.compute(:count, nil, records, Ramal.Test.Farm.Herd)
3
iex> Ramal.Aggregate.combine(:sum, :head, Ramal.Test.Farm.Herd, 100, [%{head: 30}, %{head: nil}])
130

Summary

Types

An aggregate kind.

Functions

Whether the delta path is valid for query with key_count overlaid rows. A limit, an offset or a distinct make the aggregate depend on which rows come first, which the delta does not know.

Joins the database's answer for the rows outside the overlay with the branch's rows inside it. base is the database's value for kind: an integer for :count, a boolean for :exists, the value or nil for :sum, :min and :max, and {sum, non_nil_count} for :avg. rows are the branch's version of the overlaid rows that match the query.

Computes kind over records, which are rows of resource. field is the attribute to aggregate; :count and :exists ignore it. Raises ArgumentError for an unknown kind or a field that is not an attribute of resource.

The delta path of Ramal.aggregate/4: kind over query for a branch whose overlay holds keys (the primary keys of the overlaid rows of the resource). Returns {:ok, value} or the error Ash returned. opts are the read options (:actor, :authorize?, :tenant, :domain).

The kinds compute/4 knows.

Raises ArgumentError unless kind is known and, for the kinds that read a value, field is an attribute of resource. Ramal.aggregate/4 calls it before reading so that both of its paths refuse the same input.

Types

kind()

@type kind() :: :count | :exists | :sum | :min | :max | :avg

An aggregate kind.

Functions

applies?(query, key_count)

@spec applies?(Ash.Query.t(), non_neg_integer()) :: boolean()

Whether the delta path is valid for query with key_count overlaid rows. A limit, an offset or a distinct make the aggregate depend on which rows come first, which the delta does not know.

Example

iex> Ramal.Aggregate.applies?(Ash.Query.new(Ramal.Test.Farm.Herd), 3)
true
iex> Ramal.Aggregate.applies?(Ash.Query.limit(Ramal.Test.Farm.Herd, 2), 3)
false

combine(kind, field, resource, base, rows)

@spec combine(kind(), atom() | nil, module(), term(), [map()]) :: term()

Joins the database's answer for the rows outside the overlay with the branch's rows inside it. base is the database's value for kind: an integer for :count, a boolean for :exists, the value or nil for :sum, :min and :max, and {sum, non_nil_count} for :avg. rows are the branch's version of the overlaid rows that match the query.

Example

iex> Ramal.Aggregate.combine(:count, nil, Ramal.Test.Farm.Herd, 10, [%{}, %{}])
12
iex> Ramal.Aggregate.combine(:max, :head, Ramal.Test.Farm.Herd, 30, [%{head: 35}])
35
iex> Ramal.Aggregate.combine(:avg, :head, Ramal.Test.Farm.Herd, {60, 2}, [%{head: 30}])
30.0
iex> Ramal.Aggregate.combine(:sum, :head, Ramal.Test.Farm.Herd, nil, [])
nil

compute(kind, field, records, resource)

@spec compute(kind(), atom() | nil, [map()], module()) :: term()

Computes kind over records, which are rows of resource. field is the attribute to aggregate; :count and :exists ignore it. Raises ArgumentError for an unknown kind or a field that is not an attribute of resource.

Example

iex> Ramal.Aggregate.compute(:max, :head, [%{head: 30}, %{head: 12}], Ramal.Test.Farm.Herd)
30
iex> Ramal.Aggregate.compute(:avg, :head, [%{head: 30}, %{head: 12}], Ramal.Test.Farm.Herd)
21.0
iex> Ramal.Aggregate.compute(:exists, nil, [], Ramal.Test.Farm.Herd)
false

delta(query, kind, field, opts, keys)

@spec delta(Ash.Query.t(), kind(), atom() | nil, keyword(), [term()]) ::
  {:ok, term()} | {:error, term()}

The delta path of Ramal.aggregate/4: kind over query for a branch whose overlay holds keys (the primary keys of the overlaid rows of the resource). Returns {:ok, value} or the error Ash returned. opts are the read options (:actor, :authorize?, :tenant, :domain).

Call it only inside Ramal.run/2, and only when applies?/2 is true.

kinds()

@spec kinds() :: [kind()]

The kinds compute/4 knows.

Example

iex> Ramal.Aggregate.kinds()
[:count, :exists, :sum, :min, :max, :avg]

validate!(kind, field, resource)

@spec validate!(kind(), atom() | nil, module()) :: :ok

Raises ArgumentError unless kind is known and, for the kinds that read a value, field is an attribute of resource. Ramal.aggregate/4 calls it before reading so that both of its paths refuse the same input.

Example

iex> Ramal.Aggregate.validate!(:sum, :head, Ramal.Test.Farm.Herd)
:ok
iex> Ramal.Aggregate.validate!(:count, nil, Ramal.Test.Farm.Herd)
:ok