Expressions
Ash has an "expression" syntax, which can be used in filters and calculations. More uses will likely be implemented in the future.
Expressions are not evaluated in-line. They are stored for later use and may be translated to SQL/Elixir to be executed. To include a variable somewhere in an expression, use a ^
, similar to how Ecto/pattern matching works. For example: Ash.Query.filter(id == ^id)
. That would filter a resource to records where their id equals the variable id
.
Notice
The expression syntax is young, and in some cases may be missing basic operators/functions/syntax.
Please open a proposal issue in Ash. I generally release new versions of the project quickly, so if your proposal (and/or PR if you end up implementing the fix) will likely be released very soon. If you are using the ash_postgres
datalayer, then you can often use fragment/*
as an escape hatch. It works just like Ecto's fragment
.
Filters
Ash.Query.filter/2
is a macro that accepts an expression by default. Here are some examples:
# simple boolean operators
Ash.Query.filter(User, email == "foo@bar.com")
# simple function calls
Ash.Query.filter(User, is_nil(deactivated_at))
# boolean operators
Ash.Query.filter(User, is_nil(deactivated_at) or email == "foo@bar.com")
# using fragment with a field reference
search = "Bob Sagat"
Ash.Query.filter(User, fragment("levenshtein(?, ?)", first_name, ^search))
# referencing a related resource
Ash.Query.filter(User, profile.first_name == "Zach Daniel")
You can use expressions in the filter
of a read action as well. There are two important differences between Ash.Query.filter/2
:
- You have to call
Ash.Query.expr/1
, which is automatically imported in that context - It is technically a filter template, which allows you to add some amount of dynamism to the expression, since it is statically embedded in the resource.
Filter templates support referencing fields on the actor, via {:_actor, :field}
, arguments of the read action, via {:_arg, :field}
, and values in the context, via {:_context, :field}
. For readability, corresponding functions, actor/1
, arg/1
, and context/1
that simply returns those values.
Here are some examples:
read :current_user do
filter expr(id == ^actor(:id))
end
read :by_id do
argument :id, :uuid, allow_nil?: false
filter expr(id == ^arg(:id))
end
read :active do
filter expr(not(is_nil(activated_at)))
end
Referencing related values
When referencing related values, if the reference is a has_one
or belongs_to
, the filter does exactly what it looks like (matches if the related value matches). If it is a has_many
or a many_to_many
, it matches if any of the related records match.
Referencing aggregates and calculations
Aggregates are simple, insofar as all aggregates can be referenced in filter expressions (if you are using a data layer that supports it).
For calculations, only those that define an expression can be referenced in other expressions. See the section below on declaring calculations with expressions.
Here are some examples:
# given a `full_name` calculation
Ash.Query.filter(User, full_name == "Hob Goblin")
# given a `full_name` calculation that accepts an argument called `delimiter`
Ash.Query.filter(User, full_name(delimiter: "~") == "Hob~Goblin")
Calculations
There are two ways to make a calculation with an expression. The simplest, is to define the expression in-line with expr/1
. The other is to use a custom Ash.Calculation
module, and define an expression/2
callback. This should return the expression that will ultimately be used. Doing this can allow you to define Elixir code that calculates the value of the expression (in calculate/3
) as well. This means that, if the calculation is loaded but not referenced in a filter, sort or calculation, it can be calculated at runtime in Elixir. Eventually, logic will be added to support determining if an expression can be done at runtime, and this optimization will be added to inline expr/1
calculations as well.
Using expr/1
Calculations can reference aggregates, other calculations, and attributes (but not relationships). Additionally, calculation expressions act as filter templates (see the filter template section above for more).
For example:
calculations do
calculate :full_name, :string, expr(first_name <> " " <> last_name)
end
If you want to refer to a related value, you can use the first
aggregate. For example to support referencing first_name
and last_name
on a user record where that information is stored in a related profile:
aggregates do
first :first_name, :profile, :first_name
first :last_name, :profile, :last_name
end
calculations do
calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do
argument :separator, :string, default: " "
end
end
As an aside: this also allows loading that value on the user, e.g Ash.Query.load(User, [:first_name, :last_name])
Using calculation modules
An example calculation module to accomplish similar concat
behavior as the examples above:
defmodule MyApp.Calculations.Concat do
@moduledoc false
use Ash.Calculation
require Ash.Query
def init(opts) do
if opts[:keys] && is_list(opts[:keys]) && Enum.all?(opts[:keys], &is_atom/1) do
{:ok, opts}
else
{:error, "Expected a `keys` option for which keys to concat"}
end
end
def select(_query, opts) do
opts[:keys]
end
def expression(opts, _) do
Enum.reduce(opts[:keys], nil, fn key, expr ->
if expr do
if opts[:separator] do
Ash.Query.expr(expr <> ^opts[:separator] <> ref(^key))
else
Ash.Query.expr(expr <> ref(^key))
end
else
Ash.Query.expr(ref(^key))
end
end)
end
def calculate(records, opts, _) do
Enum.map(records, fn record ->
Enum.map_join(opts[:keys], opts[:separator] || "", fn key ->
to_string(Map.get(record, key))
end)
end)
end
end
This can now be reused throughout your application, for example:
alias MyApp.Calculations.Concat
calculations do
calculate :full_name, {Concat, keys: [:first_name, :last_name, separator: " "]}
end