docs: document expressions

This commit is contained in:
Zach Daniel 2021-07-01 22:07:31 -04:00
parent c3282cd79b
commit 3aa6b6f49f
2 changed files with 174 additions and 2 deletions

View file

@ -0,0 +1,170 @@
# 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:
```elixir
# 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:
```elixir
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:
```elixir
# 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:
```elixir
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:
```elixir
aggregates do
first :first_name, :profile, :first_name
first :last_name, :profile, :last_name
end
calculations do
argument :separator, :string, default: " "
calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name)
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:
```elixir
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:
```elixir
alias MyApp.Calculations.Concat
calculations do
calculate :full_name, {Concat, keys: [:first_name, :last_name, separator: " "]}
end
```

View file

@ -861,12 +861,14 @@ defmodule Ash.Resource.Dsl do
3.) Set `always_select?` on the attribute in question
""",
examples: [
"calculate :full_name, :string, MyApp.MyResource.FullName, select: [:first_name, :last_name]",
{
"`Ash.Calculation` implementation example:",
"calculate :full_name, :string, {MyApp.FullName, keys: [:first_name, :last_name]}, select: [:first_name, :last_name]"
},
"calculate :full_name, :string, full_name([:first_name, :last_name]), select: [:first_name, :last_name]"
{
"`expr/1` example:",
"calculate :full_name, expr(first_name <> \" \" <> last_name "
}
],
target: Ash.Resource.Calculation,
args: [:name, :type, :calculation],