Skip to content

improvement: add %Ash.Expr{} wrapper struct#2659

Open
nallwhy wants to merge 6 commits into
ash-project:mainfrom
nallwhy:improvement/expr_struct
Open

improvement: add %Ash.Expr{} wrapper struct#2659
nallwhy wants to merge 6 commits into
ash-project:mainfrom
nallwhy:improvement/expr_struct

Conversation

@nallwhy
Copy link
Copy Markdown
Contributor

@nallwhy nallwhy commented Apr 3, 2026

Contributor checklist

Leave anything that you believe does not apply unchecked.

  • I accept the AI Policy, or AI was not used in the creation of this PR.
  • Bug fixes include regression tests
  • Chores
  • Documentation changes
  • Features include unit/acceptance tests
  • Refactoring
  • Update dependencies

Summary

Closes #1069

  • Introduces %Ash.Expr{} struct returned from expr/1, where/2, and or_where/2 macros, enabling O(1) detection of expression values via match?(%Ash.Expr{}, value) instead of traversing the entire value with expr?/1
  • Adds Ash.Expr.expression() type for function parameters that accept both wrapped and raw expressions, keeping Ash.Expr.t() for the struct itself

Copilot AI review requested due to automatic review settings April 3, 2026 09:02
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 with wrap/1 + unwrap/1, and updates expr/1, where/2, or_where/2 to 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.

Comment thread lib/ash/expr/expr.ex
end
end

@doc "Returns true if the value is or contains an expression"
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@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"

Copilot uses AI. Check for mistakes.
Comment thread lib/ash/policy/check.ex
Comment on lines 168 to 171
@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
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment thread lib/ash/policy/check.ex
@doc false
@spec auto_filter_not(module(), actor(), authorizer(), options()) ::
Keyword.t() | Ash.Expr.t() | nil
Keyword.t() | Ash.Expr.expression() | nil
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
Keyword.t() | Ash.Expr.expression() | nil
Keyword.t() | Ash.Expr.expression() | nil | false

Copilot uses AI. Check for mistakes.
Comment thread lib/ash/policy/check.ex
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()
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
@callback auto_filter(actor(), authorizer(), options()) :: Keyword.t() | Ash.Expr.expression()
@callback auto_filter(actor(), authorizer(), options()) ::
Keyword.t() | Ash.Expr.expression() | nil | false

Copilot uses AI. Check for mistakes.
Comment thread lib/ash/sort/sort.ex

Ash.Query.sort(query, [{Ash.Sort.expr_sort(author.full_name, :string), :desc_nils_first}])
```
"""
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
"""
"""
@spec expr_sort(term(), term()) :: Ash.Query.Calculation.t()

Copilot uses AI. Check for mistakes.
@zachdaniel
Copy link
Copy Markdown
Contributor

This is a very useful change but I don't think that we can reasonably modify all expression logic to return %Ash.Expr{} without making a major version bump. There are tons of scenarios that use expressions.

@nallwhy
Copy link
Copy Markdown
Contributor Author

nallwhy commented Apr 5, 2026

The goal of this issue is O(1) expr?/1 detection. For that to work, expr/1 needs to return a distinguishable struct %Ash.Expr{}, which means any code pattern-matching on expr/1 results (e.g. %Ash.Query.Call{} = expr(...)) would break.

Should this be scoped as a 4.0 change?

@zachdaniel
Copy link
Copy Markdown
Contributor

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 😓

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add %Ash.Expr{}, which will be returned from expr/1

3 participants