improvement: add %Ash.Expr{} wrapper struct#2659
Conversation
There was a problem hiding this comment.
Pull request overview
This PR introduces a new %Ash.Expr{} wrapper struct to represent “expression values” returned by core expression-building macros, enabling O(1) detection via pattern matching instead of deep traversal.
Changes:
- Adds
%Ash.Expr{expr: term}wrapper withwrap/1+unwrap/1, and updatesexpr/1,where/2,or_where/2to return the wrapper. - Introduces
Ash.Expr.expression()type and updates many callback/spec types across the codebase to accept wrapped or raw expressions. - Updates core expression-processing code paths (e.g., filters, boolean expressions, authorizer) and tests to unwrap/handle
%Ash.Expr{}.
Reviewed changes
Copilot reviewed 24 out of 24 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| test/resource/unrelated_exists_test.exs | Adjusts assertions to account for %Ash.Expr{} wrapping. |
| test/expr_test.exs | Updates fragment/calc assertions and adds coverage for wrap/unwrap/inspect/eval behavior. |
| lib/ash/expr/expr.ex | Introduces %Ash.Expr{} struct, wrap/unwrap, macro return changes, and Inspect delegation. |
| lib/ash/filter/filter.ex | Adds handling for %Ash.Expr{} in traversal/mapping/parsing helpers. |
| lib/ash/filter/runtime.ex | Unwraps %Ash.Expr{} during runtime resolution. |
| lib/ash/query/boolean_expression.ex | Unwraps %Ash.Expr{} in constructors for boolean expressions. |
| lib/ash/query/not.ex | Unwraps %Ash.Expr{} in Not.new/1. |
| lib/ash/policy/authorizer/authorizer.ex | Unwraps calculations/expressions and skips wrappers during ref replacement. |
| lib/ash/expr/sat.ex | Unwraps %Ash.Expr{} before SAT conversion. |
| lib/ash/type/type.ex | Updates atomic-expression callback/spec types to Ash.Expr.expression(). |
| lib/ash/resource/validation.ex | Updates atomic validation callback/spec types to Ash.Expr.expression(). |
| lib/ash/resource/identity.ex | Updates identity where type to Ash.Expr.expression(). |
| lib/ash/resource/change/change.ex | Updates atomic change callback/spec types to Ash.Expr.expression(). |
| lib/ash/resource/change/builtins.ex | Updates change builtin specs to accept Ash.Expr.expression(). |
| lib/ash/resource/builder.ex | Updates builder API type for calculation expressions. |
| lib/ash/resource/actions/create.ex | Updates upsert_condition type to Ash.Expr.expression(). |
| lib/ash/reactor/dsl/bulk_update.ex | Updates atomic_update type to Ash.Expr.expression(). |
| lib/ash/query/combination.ex | Updates combination filter type to Ash.Expr.expression(). |
| lib/ash/policy/filter_check.ex | Updates filter/reject callback types to Ash.Expr.expression(). |
| lib/ash/policy/check.ex | Updates auto_filter callback/spec types to Ash.Expr.expression() (but see comments re: false). |
| lib/ash/data_layer/data_layer.ex | Updates calculate/upsert option types to Ash.Expr.expression(). |
| lib/ash/custom_expression.ex | Updates custom expression callback/spec types to Ash.Expr.expression(). |
| lib/ash/changeset/changeset.ex | Updates atomic types and adds unwrap handling for %Ash.Expr{}. |
| lib/ash/sort/sort.ex | Removes outdated expr_sort/2 spec (comment suggests reintroducing correct spec). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| end | ||
| end | ||
|
|
||
| @doc "Returns true if the value is or contains an expression" |
There was a problem hiding this comment.
The docstring for expr?/1 says it returns true when a value “is or contains an expression”, but expr?/1 now returns true for any %Ash.Expr{} wrapper regardless of whether the wrapped value is an actual expression node (e.g., expr(^x) wraps a literal). Please update the doc to reflect the new semantics ("is an Ash expression wrapper or contains expression nodes") or adjust expr?/1 to inspect the wrapped expr when you want the previous meaning.
| @doc "Returns true if the value is or contains an expression" | |
| @doc "Returns true if the value is an Ash expression wrapper or is/contains expression nodes" |
| @doc false | ||
| @spec auto_filter(module(), actor(), authorizer(), options()) :: | ||
| Keyword.t() | Ash.Expr.t() | nil | ||
| Keyword.t() | Ash.Expr.expression() | nil | ||
| def auto_filter(module, actor, authorizer, opts) do |
There was a problem hiding this comment.
auto_filter/4 can return false (the implementation explicitly allows it in the guard), but the updated @spec no longer includes false. Please include false in the return type so the spec matches runtime behavior.
| @doc false | ||
| @spec auto_filter_not(module(), actor(), authorizer(), options()) :: | ||
| Keyword.t() | Ash.Expr.t() | nil | ||
| Keyword.t() | Ash.Expr.expression() | nil |
There was a problem hiding this comment.
auto_filter_not/4 can return false (the implementation explicitly allows it in the guard), but the updated @spec no longer includes false. Please include false in the return type so the spec matches runtime behavior.
| Keyword.t() | Ash.Expr.expression() | nil | |
| Keyword.t() | Ash.Expr.expression() | nil | false |
| Return a keyword list filter that will be applied to the query being made, and will scope the results to match the rule | ||
| """ | ||
| @callback auto_filter(actor(), authorizer(), options()) :: Keyword.t() | Ash.Expr.t() | ||
| @callback auto_filter(actor(), authorizer(), options()) :: Keyword.t() | Ash.Expr.expression() |
There was a problem hiding this comment.
The auto_filter/3 callback type no longer includes nil or false, but the runtime validator for auto_filter/4 allows both (is_nil(result) or result == false). Please update the callback return type to include nil | false so behaviour contracts match actual supported returns.
| @callback auto_filter(actor(), authorizer(), options()) :: Keyword.t() | Ash.Expr.expression() | |
| @callback auto_filter(actor(), authorizer(), options()) :: | |
| Keyword.t() | Ash.Expr.expression() | nil | false |
|
|
||
| Ash.Query.sort(query, [{Ash.Sort.expr_sort(author.full_name, :string), :desc_nils_first}]) | ||
| ``` | ||
| """ |
There was a problem hiding this comment.
expr_sort/2 lost its @spec here, likely because Ash.Expr.t() is now the wrapper struct. It would be good to reintroduce a correct spec (e.g., returning an Ash.Query.Calculation.t()/sort calculation) to keep docs and dialyzer hints consistent with the rest of this module.
| """ | |
| """ | |
| @spec expr_sort(term(), term()) :: Ash.Query.Calculation.t() |
|
This is a very useful change but I don't think that we can reasonably modify all expression logic to return |
|
The goal of this issue is O(1) Should this be scoped as a 4.0 change? |
|
Yeah, I think we will need to do that unfortunately. What we can do is introduce this as an hidden option that we can use to introduce support across all our extensions, but other external parties will be relying on this structure so we can't break the shape of an expr without a major release. We should probably start and maintain a 4.0 branch 😓 |
Contributor checklist
Leave anything that you believe does not apply unchecked.
Summary
Closes #1069
%Ash.Expr{}struct returned fromexpr/1,where/2, andor_where/2macros, enabling O(1) detection of expression values viamatch?(%Ash.Expr{}, value)instead of traversing the entire value withexpr?/1Ash.Expr.expression()type for function parameters that accept both wrapped and raw expressions, keepingAsh.Expr.t()for the struct itself