Skip to content

feat: add pipelines DSL for reusable action logic composition#2652

Merged
zachdaniel merged 12 commits intoash-project:mainfrom
nallwhy:feat/pipeline
Mar 31, 2026
Merged

feat: add pipelines DSL for reusable action logic composition#2652
zachdaniel merged 12 commits intoash-project:mainfrom
nallwhy:feat/pipeline

Conversation

@nallwhy
Copy link
Copy Markdown
Contributor

@nallwhy nallwhy commented Mar 28, 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

  • Adds a pipelines DSL section for declaring reusable groups of changes, validations, preparations, arguments, and accepted attributes that can be composed into multiple actions via pipe_through
  • Resolves #1625

Usage

pipelines do
  pipeline :change_state do
    accept [:state]
    argument :reason, :string
    validate present(:reason)
    change set_attribute(:score, expr(score + 1))
  end
end

actions do
  update :open do
    pipe_through [:change_state]
    change set_attribute(:state, :open)
  end

  update :close do
    pipe_through [:change_state], where: present(:admin_role)
    change set_attribute(:state, :closed)
  end
end

How it works

A compile-time transformer (ResolvePipelines) runs before other action transformers and:

  • Entities: Injects changes + validations into CUD actions, preparations + validations into read/generic actions. Pipeline entities are prepended before the action's own.
  • Arguments: Merged with deduplication by name. If both pipeline and action declare the same argument name with the same type, the action's version wins. Different types raise a compile-time error.
  • Accept: Union of pipeline and action accept lists, deduplicated. Action's :* is preserved, nil accept gets the pipeline's value.
  • Where: pipe_through [:name], where: present(:field) applies the condition to all entities from that pipeline. Multiple pipe_through declarations are supported with independent where clauses.

Copilot AI review requested due to automatic review settings March 28, 2026 15:41
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

Adds a new pipelines DSL to Ash resources, letting actions reuse shared groups of changes/validations/preparations/arguments/accept via pipe_through, resolved at compile-time by a new transformer.

Changes:

  • Introduces pipelines + pipeline DSL sections and pipe_through action declarations (with optional where: gating).
  • Adds Ash.Resource.Transformers.ResolvePipelines to inject/merge pipeline entities, arguments, and accept into actions.
  • Adds introspection (Ash.Resource.Info.pipelines/1, pipeline/2) plus tests and DSL documentation updates.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
test/resource/actions/pipelines_test.exs Exercises pipeline injection/ordering/where behavior and merge edge cases.
lib/ash/resource/validation.ex Sets where default to [] for safer list operations during pipeline resolution.
lib/ash/resource/transformers/resolve_pipelines.ex New transformer implementing pipeline resolution and merging into actions.
lib/ash/resource/pipeline.ex New pipeline entity struct + schema.
lib/ash/resource/info.ex Adds pipeline introspection helpers.
lib/ash/resource/dsl.ex Adds pipelines section + pipe_through entity and wires transformer ordering.
lib/ash/resource/actions/create.ex Adds pipe_through field/type to create actions.
lib/ash/resource/actions/update.ex Adds pipe_through field/type to update actions.
lib/ash/resource/actions/destroy.ex Adds pipe_through field/type to destroy actions.
lib/ash/resource/actions/read.ex Adds pipe_through field/type to read actions.
lib/ash/resource/actions/action/action.ex Adds pipe_through field/type to generic actions.
lib/ash/resource/actions/pipe_through.ex New struct + schema for pipe_through declarations (names + where).
documentation/dsls/DSL-Ash.Resource.md Documents new DSL sections/entities.
.formatter.exs Adds pipeline/pipe_through to locals-without-parens.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@nallwhy nallwhy marked this pull request as draft March 28, 2026 22:18
@nallwhy nallwhy force-pushed the feat/pipeline branch 2 times, most recently from 27f42a7 to 904cf9b Compare March 29, 2026 14:20
@nallwhy nallwhy marked this pull request as ready for review March 29, 2026 14:34
@zachdaniel
Copy link
Copy Markdown
Contributor

This is very cool! A few thoughts:

I think it would be best not to involve accept or argument in the pipeline logic for now. We can always add that back in later, but for now I think that should stay solely in the action.

For this:

injects changes + validations into CUD actions, preparations + validations into read/generic actions. Pipeline entities are prepended before the action's own.

A few things here:

  1. All action types actually now support validations. The validation module has a supports callback to say what types of subjects it supports. We'll have to consider how we handle this, for example we could validate at compile time that they support the action that uses that pipeline, or we could skip things that aren't supported (I'd vote for the first to start).
  2. Same for preparations.
  3. I think the action should be in charge of where the pipe_through placement is. For example:
update :close do
  pipe_through [:change_state], where: present(:admin_role) # <- the pipeline happens first
  change set_attribute(:state, :closed) # <- this happens second
end

# vs

update :close do
  change set_attribute(:state, :closed) # <- this happens first
  pipe_through [:change_state], where: present(:admin_role) # <- the pipeline happens second
end

@nallwhy
Copy link
Copy Markdown
Contributor Author

nallwhy commented Mar 30, 2026

Thanks for the feedback!

On accept/argument in pipelines

I think keeping them would be valuable since a pipeline's changes/validations often depend on specific arguments or accepted attributes. Without them, users would need to repeat those declarations in every action that uses the pipeline, which works against the deduplication goal of pipelines. If they're not needed for a given pipeline, they can simply be omitted. Is there a specific concern you had in mind?

On pipe_through placement ordering

I think this can be achieved by putting the pipe_through entity into the same entity group as changes (for create/update/destroy actions) and preparations (for read/generic actions). That way Spark naturally preserves declaration order.

The transformer would then walk the list, replace each %PipeThrough{} with the pipeline's entities in place, and the final result would only contain %Change{}/%Validation{}/%Preparation{} structs — no %PipeThrough{} visible to other transformers or at runtime.

Does this approach sound right, or is there a better way to handle this in Spark?

@zachdaniel
Copy link
Copy Markdown
Contributor

I think this can be achieved by putting the pipe_through entity into the same entity group as changes (for create/update/destroy actions) and preparations (for read/generic actions). That way Spark naturally preserves declaration order.

Yep! I agree with that approach.

On accept/argument in pipelines
I think keeping them would be valuable since a pipeline's changes/validations often depend on specific arguments or > accepted attributes. Without them, users would need to repeat those declarations in every action that uses the
pipeline, which works against the deduplication goal of pipelines. If they're not needed for a given pipeline, they can > simply be omitted. Is there a specific concern you had in mind?

This part is very complex. I do agree that its useful, but also consider the confusion around things like this:

create :create do
  pipe_through [:pipe]

  change set_attribute(:foo, arg(:foo))
end

pipelines do
  pipeline :pipe do
    argument :foo, :string
    
    ...
  end
end

By disconnecting the accept/arguments from their action we create a situation where its very easy to accidentally break action interfaces by changing pipelines. I see what you are saying on the value here and I'm very willing to continue the conversation, but I think that we should consider that more carefully and separate it into a separate PR. For example, we might want something like this:

  # explicitly inherit these items
  pipe_through [:pipe], accept: [:foo, :bar], arguments: [:foo, :bar]

So the point there is that if a pipe there doesn't include that accept/argument etc. you'd find out at compile time.

But I think the above conversation needs more design considerations.

```

```
pipe_through [:change_state], where: expr(^actor(:role) == :super_user)
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.

This example isn't quite right, you can't currently use an expr this way, only validations. (something I'd like to support in the future).

@zachdaniel
Copy link
Copy Markdown
Contributor

One last comment that I noticed and then we're good to go 😄

@zachdaniel zachdaniel merged commit 45a2467 into ash-project:main Mar 31, 2026
45 checks passed
@zachdaniel
Copy link
Copy Markdown
Contributor

🚀 Thank you for your contribution! 🚀

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.

Support pipelines, to declare subsections of action logic that can be referenced from many other places

3 participants