ash/old_documentation/topics/expressions.md
2022-04-04 14:55:05 -04:00

6.2 KiB

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

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