Skip to content

Add filter option to update/destroy actions - #2774

Open
Ryan6794 wants to merge 1 commit into
ash-project:mainfrom
Ryan6794:main
Open

Add filter option to update/destroy actions#2774
Ryan6794 wants to merge 1 commit into
ash-project:mainfrom
Ryan6794:main

Conversation

@Ryan6794

@Ryan6794 Ryan6794 commented Jul 2, 2026

Copy link
Copy Markdown

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

Implements #1350 - adds a filter option to update and destroy actions, bringing them to parity with read actions which have supported this since early on.

What this enables

update :publish do
  filter expr(status == :draft)
end

destroy :soft_delete do
  filter expr(is_nil(deleted_at))
end

The filter is enforced at the action level and cannot be bypassed by the caller. Multiple filters can be declared and are combined with and. Filters support ^actor/1, ^arg/1 and ^context/1 templates, identical to read action filters.

Changes

lib/ash/resource/actions/update.ex and lib/ash/resource/actions/destroy.ex

Both action structs gain two new fields: filters (plural) which holds the raw list of filter entities as declared in the DSL, and filter (singular) which holds the final merged expression. The transform/1 callback now calls concat_filters/1 which collapses the list into a single expression joined with :and, or leaves filter as nil if none were declared. This is the same pattern the Read action already uses.

lib/ash/resource/dsl.ex

Adds filters: [@filter] to the entities list for both @update and @destroy DSL entities. This is what makes filter expr(...) valid syntax inside update and destroy blocks. The @filter entity already existed and was already used by @read - this change simply registers it for the two new contexts.

lib/ash/changeset/changeset.ex

Adds |> apply_action_filter(action) at the end of prepare_changeset_for_action/3. This is the function that wires a changeset to its action before any user-provided changes are applied - making it the right place to apply an action-level filter that callers cannot override. The private apply_action_filter/2 helper uses Map.get/2 rather than direct field access so it is safe when called for create actions, which do not have a filter field.

lib/ash/actions/update/bulk.ex and lib/ash/actions/destroy/bulk.ex

In bulk operations, Ash runs a read query first to fetch the records it will update or destroy. By applying action.filter to that read query via Ash.Query.do_filter/2, we ensure only eligible records are ever fetched from the data layer. Without this, the filter would still be enforced per-changeset, but Ash would wastefully fetch ineligible records first and then reject them one by one. This satisfies the requirement in the issue that the filter be "lifted to the read action when used in a bulk update/destroy scenario."

lib/ash/resource/change/builtins.ex

Renames filter/1 to filter_where/1. This was required to resolve a compile error introduced by the DSL change above. When Spark processes a filters: [@filter] child entity inside @update or @destroy, it auto-generates a module and injects a filter/1 macro into the DSL scope of that action. The existing Change.Builtins.filter/1 was also imported into that same scope, causing Elixir to raise a conflicting import error. Since Spark's imports: field only accepts plain module names with no except support, the only clean resolution is to rename the builtin. filter_where is also a more descriptive name - it reads as "apply this change only where this condition is true."

test/actions/bulk/bulk_update_test.exs and test/actions/bulk/bulk_destroy_test.exs

Updated two test resources that used change filter(...) to use the renamed change filter_where(...) instead.

@spec filter(expr :: Ash.Expr.t()) :: Ash.Resource.Change.ref()
def filter(filter) do
@spec filter_where(expr :: Ash.Expr.t()) :: Ash.Resource.Change.ref()
def filter_where(filter) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can't change this, as it would be a breaking change. We would either need to call the DSL builder something else, or save this change for 4.0

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.

2 participants