From 3aa6b6f49fa3ee2c903a9c1eb596869ec8928ff6 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 1 Jul 2021 22:07:31 -0400 Subject: [PATCH] docs: document expressions --- documentation/topics/expressions.md | 170 ++++++++++++++++++++++++++++ lib/ash/resource/dsl.ex | 6 +- 2 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 documentation/topics/expressions.md diff --git a/documentation/topics/expressions.md b/documentation/topics/expressions.md new file mode 100644 index 00000000..b1602897 --- /dev/null +++ b/documentation/topics/expressions.md @@ -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 +``` diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex index b72ec308..87f2546b 100644 --- a/lib/ash/resource/dsl.ex +++ b/lib/ash/resource/dsl.ex @@ -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],