ash/documentation/topics/expressions.md
Zach Daniel 5967ed3a48 improvement!: 3.0 (#955)
* improvement!: use `%Ash.NotSelected{}` for unselected values

* improvement!: default `require_atomic?` to `true`

* improvement!: raise errors on unknown generic action arguments

* improvement!: default bulk strategy to `:atomic`

* improvement!: warnings on `require_atomic?` `true` actions
improvement!: revise `Ash.NotSelected` to `Ash.NotLoaded`
improvement!: errors on unknown action inputs across the board

* doc: clarify wording in notifiers.md

closes #889

* improvement!: default `api.authorization.authorize` to `:by_default`

* improvement!: require the api when constructing changesets

this commit also fixes some work from prior commits around
the default value for the `authorize` option

* improvement!: code_interface.define_for -> code_interface.api

`code_interface.define_for` is now `code_interface.api`. Additionally, it is set automatically if the `api` option is specified on `use Ash.Resource`.

* improvement!: remove registries

* improvement!: pubsub notifier default to `previous_values?: false`
improvement!: requires_original_data? callback defaults to false

* improvement!: rename Ash.Calculation -> Ash.Resource.Calculation
improvement!: improve `Ash.Query.Calculation.new` signature
improvement!: anonymous function calculations now take lists and return lists
improvement!: make callback contexts into structs
improvement!: pass context to builtin lifecycle hook changes
improvement!: calculation arguments are now in the `arguments` key of the context

* chore: fix build

* improvement!: remove `aggregates` and `calculations` from `Filter.parse` and `Filter.parse_input`

* improvement: update spark to 2.0

* improvement!: make picosat_elixir optional with `simple_sat`

* improvement!: rename api to domain

* docs: add more info to upgrading guide

* docs: tweak docs formatting

* improvement!: remove `Ash.Changeset.new!`

* docs: update docs for `Ash.Changeset.new/1`

* improvement!: deprecate `private?: false` in favor of `public?: true`

* doc: add upgrade guide for private -> public

* improvement: update reactor to 3.0

* improvement!: default `default_accept` is now `[]`

* improvement!: `Ash.CiString.new/1` returns `nil` on `nil` input

* improvement!(Ash.Reactor): Improve integration with Ash 3.0 changes.

* improvement!: clean up and reorganize `Ash` functions

this is in preparation of deprecating the functions that are defined
on the api

improvement!: remove context-based functionality

* chore: update docs references from `Ash.Domain` to `Ash`

* chore: fix bad merge

* chore: fix context access in atomic changes

* improvement!: Deprecate calling functions on (domain) api in favor of `Ash`

* improvement!: add `attribute_public?` and update `attribute_writable?` behavior

* improvement!: update atomic behaviors, default to invalid

* chore: update downcase docs

* improvement!: changeset.filters -> changeset.filter

* improvement!: remove deprecated functions

* improvement!: remove and simplify `Ash.Filter.TemplateHelpers`

* improvement: import Ash.Expr in modules where it is used
improvement: require Ash.QUery in modules where it makes sense

* fix!: keyword lists are no longer special cased in ash expressions

* improvement: add structs for more context implementations

* chore: small tweaks, finish `:all` -> `:*` conversion

* chore: update DSL docs for multitenancy.global?

* improvement: ensure selects are applied on destroys
chore: remove TODOs

* chore: some docs changes

* improvement!: introduce strict mode to calculations

* chore: update tests

* improvement: support custom expressions

* docs: document custom expressions

* chore: fix and test custom expressions and function fragments
docs: update relevant docs w/ the changes

* improvement!: reverse order of before action & before transaction hooks

* improvement!: default read actions are now paginatable

* improvement!: require explicit accept lists in default actions

* chore: update docs

* improvement!: remove Ash.Flow and Ash.Engine

* chore: unlock unused deps

* chore: don't use unused variable

* chore: include ash flow change in upgrade guide

* improvement!: standardize various exception keys and names

* improvement!: use `Splode` for errors

* improvement: update upgrade guide to include Splode

* feat: code interface on the domain

* improvement: only require primary key if resource has actions or fields
improvement: only build schema if resource has actions or fields
improvement: verify primary key in its own verifier

* improvement: add `resource/1` builtin check

* improvement!: move simple_notifiers to an option instead of a DSL builder
improvement!: update spark for better autocomplete, configure autocomplete for key functions
docs: replace `an domain` with `a domain`

* improvement: better code interface documentation

* fix: set tenant on query so that root calles to Api.aggreagte work as expected (#929)

* chore: fixes from previous improvements

* chore: update splode

* chore: update splode

* improvement!: swap position of sort order and arguments in calculation sorting

* improvement!: add `include_nil?` aggregate option, and default it to `false`

* improvement: support notifiers within actions

* improvement: support specifying multiple filters

* improvement: add `sortable?` flags to all fields
improvement: support multiple filters on relationships

* improvement: support sensitive? on calculations and arguments

* improvement: validate resources in inputs to code interface

* chore: don't require explicit accept lists when using `default_accept :*`

* chore: update spark

* chore: update public attribute handling per 3.0

* improvement: update reactor and tests

* chore: better error message

* chore: fix rebase issue

* chore: handle merge issues
improvement: don't require domain on relationships if destination has domain

* improvement!: errors on unknown inputs for calculations

* improvement: always choose to cast atomic

* improvement: support casting some embeds atomically

* improvement: various 3.0 updates, documented in upgrade.md

* chore: Add failing tests for loads with with explicit domains. (#948)

Co-authored-by: James Harton <james@harton.nz>

* improvement: ensure non-static dynamic domains works

* improvement: add Ash.ToTenant protocol

* chore: add docs for no ToTenant option

* fix: properly construct new query in `build/3`

* chore: update simple_sat dependency

* chore: don't reselect when missing primary keys

* chore: remove IO.inspect

* chore: update spark

* chore: update spark

* improvement: use `Keyword.put_new` in `Ash.Context.to_opts` (#953)

* improvement: support bulk and atomic operations in code interfaces

---------

Co-authored-by: James Harton <james@harton.nz>
Co-authored-by: WIGGLES <55168935+WIGGLES-dev@users.noreply.github.com>
Co-authored-by: Dmitry Maganov <vonagam@gmail.com>
2024-03-27 16:31:59 -04:00

287 lines
13 KiB
Markdown

# Expressions
Ash expressions are used in various places like calculations, filters, and policies, and are meant to be portable representations of elixir expressions. You can create an expression using the `Ash.Expr.expr/1` macro, like so:
```elixir
Ash.Expr.expr(1 + 2)
Ash.Expr.expr(x + y)
Ash.Expr.expr(post.title <> " | " <> post.subtitle)
```
Ash expressions have some interesting properties in their evaluation, primarily because they are made to be portable, i.e executable in some data layer (like SQL) or executable in Elixir. In general, these expressions will behave the same way they do in Elixir. The primary difference is how `nil` values work. They behave the way that `NULL` values behave in SQL. This is primarily because this pattern is easier to replicate to various popular data layers, and is generally safer when using expressions for things like authentication. The practical implications of this are that `nil` values will "poison" many expressions, and cause them to return `nil`. For example, `x + nil` would always evaluate to `nil`. Additionally, `true and nil` will always result in `nil`, _this is also true with or and not_, i.e `true or nil` will return `nil`, and `not nil` will return `nil`.
## Operators
The following operators are available and they behave the same as they do in Elixir, except for the `nil` addendum above.
- `==`
- `!=`
- `>`
- `>=`
- `<`
- `<=`
- `in`
- `*`
- `-`
- `/`
- `<>`
- `||`
- `&&`
- `is_nil` | Only works as an operator in maps/keyword list syntax. i.e `[x: [is_nil: true]]`
## Functions
The following functions are built in. Data Layers can add their own functions to expressions. For example, `AshPostgres` adds `trigram_similarity` function.
The following functions are built in:
- `if` | Works like elixir's `if`.
- `is_nil/1` | Works like elixir's `is_nil`
- `get_path/2` | i.e `get_path(value, ["foo", "bar"])`. This is what expressions like `value[:foo]["bar"]` are turned into under the hood.
- `contains/2` | if one string contains another string, i.e `contains("fred", "red")`
- `length/1` | the length of a list, i.e. `length([:foo, :bar])`
- `type/2` | Cast a given value to a specific type, i.e `type(^arg(:id), :uuid)` or `type(integer_field, :string)`
- `string_downcase/1` | Downcases a string
- `string_join/1` | Concatenates a list of strings, and ignores any nil values
- `string_join/2` | As above, but with a joiner
- `string_split/1` | Splits a string on spaces
- `string_split/2` | As above, but with a specific delimiter
- `string_split/3` | As above, but with options. See the function for the available options.
- `string_length/1` | Returns the length of a given string, as reported by `String.length/1`
- `string_trim/1` | Trims unicode whitespace from the beginning and the end of a string
- `at/2` | Get an element from a list, i.e `at(list, 1)`
- `round/1` | Round a float, decimal or int to 0 precision, i.e `round(num)`
- `round/2` | Round a float, decimal or int to the provided precision or less, i.e `round(1.1234, 3) == 1.1234` and `round(1.12, 3) == 1.12`
- String interpolation | `"#{first_name} #{last_name}"`, is remapped to the equivalent usage of `<>`
- `fragment/*` | Creates a fragment of a data layer expression. See the section on fragments below.
## Fragments
Fragments come in two forms.
## String Fragments
For SQL/query-backed data layers, they will be a string with question marks for interpolation. For example: `fragment("(? + ?)", foo, bar)`.
## Function Fragments
For elixir-backed data layers, they will be a function or an MFA that will be called with the provided arguments. For example: `fragment(&Module.add/2, foo, bar)` or `fragment({Module, :add, []}, foo, bar)`. When using anonymous functions, you can _only_ use the format `&Module.function/arity`. `&Module.add/2` is okay, but `fn a, b -> Module.add(a, b) end` is not.
## Sub-expressions
- `exists/2` | `exists(foo.bar, name == "fred")` takes an expression scoped to the destination resource, and checks if any related entry matches. See the section on `exists` below.
- `path.exists/2` | Same as `exists` but the source of the relationship is itself a nested relationship. See the section on `exists` below.
- `parent/1` | Allows an expression scoped to a resource to refer to the "outer" context. Used in relationship filters and `exists`
## DateTime Functions
- `now/0` | Evaluates to the current time when the expression is evaluated
- `today/0` | Evaluates to the current date when the expression is evaluated
- `ago/2` | i.e `deleted_at > ago(7, :day)`. The available time intervals are documented in `Ash.Type.DurationName`
- `from_now/2` | Same as `ago` but adds instead of subtracting
- `datetime_add/3` | add an interval to a datetime, i.e `datetime_add(^datetime, 10, :hour)`
- `date/3` | add an interval to a date, i.e `datetime_add(^date, 3, :day)`
## Primitives
- `cond` - `cond` is transformed to a series of `if` expressions under the hood
- `item[:key] or item["key"]` - accesses keys in a map. In both cases, it prefers a matching atom key, falling back to a matching string key. This is to aid with data stores that store embeds as JSON with string keys (like AshPostgres), so that this expression behaves the same in the data layer as it does in Elixir.
## Escape Hatches
- `lazy/1` - Takes an MFA and evaluates it just before running the query. This is important for calculations, because the `expression/2` callback should be _stable_ (returns the same value given the same input). For example `lazy({ULID, :generate, [timestamp_input]})`
## Inline Aggregates
Aggregates can be referenced in-line, with their relationship path specified and any options provided that match the options given to `Ash.Query.Aggregate.new/4`. For example:
```elixir
calculate :grade, :decimal, expr(
count(answers, query: [filter: expr(correct == true)]) /
count(answers, query: [filter: expr(correct == false)])
)
```
The available aggregate kinds can also be seen in the `Ash.Query.Aggregate` module documentation.
## Templates
Most of the time, when you are using an expression, you will actually be creating a `template`. In this template, you have a few references that can be used, which will be replaced when before the expression is evaluated. The following references are available.
```elixir
^actor(:key) # equivalent to `get_in(actor || %{}, [:key])`
^actor([:key1, :key2]) # equivalent to `get_in(actor || %{}, [:key, :key2])`
^arg(:arg_name) # equivalent to `Map.get(arguments, :arg_name)`
^context(:key) # equivalent to `get_in(context, :key)`
^context([:key1, :key2]) # equivalent to `get_in(context, [:key1, :key2])`
^ref(:key) # equivalent to referring to `key`. Allows for dynamic references
^ref(:key, [:path]) # equivalent to referring to `path.key`. Allows for dynamic references with dynamic (or static) paths.
```
## Custom Expressions
Custom expressions allow you to extend Ash's expression language with your own expressions. To see more, see `Ash.CustomExpression`. To add a custom expression, configure it and recompile ash. For example:
```elixir
config :ash, :custom_expressions, [
MyApp.CustomExpression
]
```
```
mix deps.compile ash --force
```
These expressions will be available across all usages of Ash expressions within your application.
## Use cases for expressions
### Filters
The most obvious place we use expressions is when filtering data. For example:
```elixir
Ash.Query.filter(Ticket, status == :open and opened_at >= ago(10, :day))
```
These filters will be run in the data layer, i.e in the SQL query.
#### Filter semantics & joins
The semantics of Ash filters are probably slightly different than what you are used to, and they are important to understand. Every filter expression is always talking about a single row, potentially "joined" to single related rows. By referencing relationships, you are implicitly doing a join. For those familiar with SQL terminology, it is equivalent to a left join, although AshPostgres can detect when it is safe to do an inner join (for performance reason). Lets use an example of `posts` and `comments`.
Given a filter like the following:
```elixir
Ash.Query.filter(Post, comments.points > 10 and comments.tag.name == "elixir")
```
The filter refers to a _single post/comment/tag combination_. So in english, this is "posts where they have a comment with more than 10 points and _that same comment_ has a tag with the name `elixir`". What this also means is that filters like the above do not compose nicely when new filters are added. For example:
```elixir
def has_comment_with_more_points_than(query, score) do
Ash.Query.filter(Post, comments.points > 10)
end
def has_comment_tagged(query, tag) do
Ash.Query.filter(Post, comments.tag.name == ^tag)
end
Post
|> has_comment_with_more_points_than(query, 10)
|> has_comment_tagged("elixir")
```
That code _seems_ like it ought to produce a filter over `Post` that would give us any post with a comment having more than 10 points, _and_ with a comment tagged `elixir`. That is not the same thing as having a _single_ comment that meets both those criteria. So how do we make this better?
##### Exists
Lets rewrite the above using exists:
```elixir
def has_comment_with_more_points_than(query, score) do
Ash.Query.filter(Post, exists(comments, points > ^score))
end
def has_comment_tagged(query, tag) do
Ash.Query.filter(Post, exists(comments.tag.name == ^tag)
end
Post
|> has_comment_with_more_points_than(query, ^score)
|> has_comment_tagged("elixir")
```
Now, they will compose properly! Generally speaking, you should use exists when you are filtering across any relationships that are `to_many` relationships \*even if you don't expect your filter to be composed. Currently, the filter syntax does not minimize(combine) these `exists/2` statements, but doing so is not complex and can be added. While unlikely, please lodge an issue if you see any performance issues with `exists`.
##### Exists at path
Sometimes, you want the ability to say that some given row must have an existing related entry matching a filter. For example:
```elixir
Ash.Query.filter(Post, author.exists(roles, name == :admin) and author.active)
```
While the above is not common, it can be useful in some specific circumstances, and is used under the hood by the policy authorizer when combining the filters of various resources to create a single filter.
## Relationship Filters
When filtering relationships, you can use the `parent/1` function to scope a part of the expression to "source" of the join. This allows for very expressive relationships! Keep in mind, however, that if you want to update and/or manage these relationships, you'll have to make sure that any attributes that make these things actually related are properly set.
```elixir
has_many :descendents, __MODULE__ do
description "All descendents in the same tree"
no_attributes? true # this says that there is no matching source_attribute and destination_attribute on this relationship
# This is an example using postgres' ltree extension.
filter expr(tree_id == parent(tree_id) and fragment("? @> ?", parent(path), path))
end
```
## Portability
Ash expressions being portable is more important than it sounds. For example, if you were using AshPostgres and had the following calculation, which is an expression capable of being run in elixir or translated to SQL:
```elixir
calculate :full_name, :string, expr(first_name <> " " <> last_name)
```
And you did something like the following:
```elixir
User
|> Ash.Query.load(:full_name)
|> Ash.Query.sort(:full_name)
|> Accounts.read!()
```
You would see that it ran a SQL query with the `full_name` calculation as SQL. This allows for sorting on that value. However, if you had something like this:
```elixir
# data can be loaded in the query like above, or on demand later
Accounts.load!(user, :full_name)
```
you would see that no SQL queries are run. The calculation is run directly in Elixir and the value is set.
## Parent
`Parent` is a way to "jump out" of a scoped expression. Here are some examples:
```elixir
Ash.Query.filter(exists(open_tickets, severity >= parent(severity_threshold)))
```
```elixir
has_many :relevant_tickets, Ticket do
filter expr(status == :open and severity >= parent(severity_threshold))
end
```
```elixir
count :count_of_relevant_tickets, :open_tickets do
filter expr(status == :open and severity >= parent(severity_threshold))
end
```
### Referencing related values
Related values can be references using dot delimiters, i.e `Ash.Query.filter(user.first_name == "fred")`.
When referencing related values in filters, 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, 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.
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")
```