Skip to content

Commit 2a8065d

Browse files
committed
Preserve pipe_through declaration order in actions
1 parent 0721219 commit 2a8065d

File tree

9 files changed

+172
-181
lines changed

9 files changed

+172
-181
lines changed

documentation/dsls/DSL-Ash.Resource.md

Lines changed: 76 additions & 76 deletions
Large diffs are not rendered by default.

lib/ash/resource/actions/action/action.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ defmodule Ash.Resource.Actions.Action do
1313
constraints: [],
1414
touches_resources: [],
1515
skip_unknown_inputs: [],
16-
pipe_through: [],
1716
arguments: [],
1817
preparations: [],
1918
allow_nil?: false,
@@ -31,7 +30,6 @@ defmodule Ash.Resource.Actions.Action do
3130
description: String.t() | nil,
3231
arguments: [Ash.Resource.Actions.Argument.t()],
3332
skip_unknown_inputs: list(atom() | String.t()),
34-
pipe_through: list(Ash.Resource.Actions.PipeThrough.t()),
3533
allow_nil?: boolean,
3634
touches_resources: [Ash.Resource.t()],
3735
constraints: Keyword.t(),

lib/ash/resource/actions/create.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ defmodule Ash.Resource.Actions.Create do
2020
delay_global_validations?: false,
2121
skip_global_validations?: false,
2222
skip_unknown_inputs: [],
23-
pipe_through: [],
2423
upsert?: false,
2524
upsert_identity: nil,
2625
upsert_fields: nil,
@@ -47,7 +46,6 @@ defmodule Ash.Resource.Actions.Create do
4746
multitenancy: atom,
4847
upsert?: boolean,
4948
skip_unknown_inputs: list(atom | String.t()),
50-
pipe_through: list(Ash.Resource.Actions.PipeThrough.t()),
5149
return_skipped_upsert?: boolean(),
5250
notifiers: [module()],
5351
delay_global_validations?: boolean,

lib/ash/resource/actions/destroy.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ defmodule Ash.Resource.Actions.Destroy do
1515
manual: nil,
1616
require_atomic?: Application.compile_env(:ash, :require_atomic_by_default?, true),
1717
skip_unknown_inputs: [],
18-
pipe_through: [],
1918
atomic_upgrade?: true,
2019
atomic_upgrade_with: nil,
2120
action_select: nil,
@@ -46,7 +45,6 @@ defmodule Ash.Resource.Actions.Destroy do
4645
arguments: list(Ash.Resource.Actions.Argument.t()),
4746
atomic_upgrade?: boolean(),
4847
skip_unknown_inputs: list(atom() | String.t()),
49-
pipe_through: list(Ash.Resource.Actions.PipeThrough.t()),
5048
atomic_upgrade_with: atom() | nil,
5149
require_atomic?: boolean,
5250
accept: nil | list(atom),

lib/ash/resource/actions/read.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ defmodule Ash.Resource.Actions.Read do
1414
manual: nil,
1515
metadata: [],
1616
skip_unknown_inputs: [],
17-
pipe_through: [],
1817
skip_global_validations?: false,
1918
modify_query: nil,
2019
multitenancy: nil,
@@ -40,7 +39,6 @@ defmodule Ash.Resource.Actions.Read do
4039
manual: atom | {atom, Keyword.t()} | nil,
4140
metadata: [Ash.Resource.Actions.Metadata.t()],
4241
skip_unknown_inputs: list(atom | String.t()),
43-
pipe_through: list(Ash.Resource.Actions.PipeThrough.t()),
4442
skip_global_validations?: boolean,
4543
modify_query: nil | mfa,
4644
multitenancy: atom,

lib/ash/resource/actions/update.ex

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ defmodule Ash.Resource.Actions.Update do
1515
require_attributes: [],
1616
allow_nil_input: [],
1717
skip_unknown_inputs: [],
18-
pipe_through: [],
1918
manual: nil,
2019
manual?: false,
2120
require_atomic?: Application.compile_env(:ash, :require_atomic_by_default?, true),
@@ -43,7 +42,6 @@ defmodule Ash.Resource.Actions.Update do
4342
manual: module | nil,
4443
multitenancy: atom,
4544
skip_unknown_inputs: list(atom | String.t()),
46-
pipe_through: list(Ash.Resource.Actions.PipeThrough.t()),
4745
atomic_upgrade?: boolean(),
4846
action_select: list(atom) | nil,
4947
atomic_upgrade_with: atom() | nil,

lib/ash/resource/dsl.ex

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -560,9 +560,7 @@ defmodule Ash.Resource.Dsl do
560560
entities: [
561561
changes: [
562562
@action_change,
563-
@action_validate
564-
],
565-
pipe_through: [
563+
@action_validate,
566564
@pipe_through
567565
],
568566
arguments: [
@@ -622,9 +620,6 @@ defmodule Ash.Resource.Dsl do
622620
arguments: [
623621
@action_argument
624622
],
625-
pipe_through: [
626-
@pipe_through
627-
],
628623
preparations: [
629624
@prepare,
630625
%{
@@ -633,7 +628,8 @@ defmodule Ash.Resource.Dsl do
633628
@validate.schema
634629
|> Keyword.delete(:always_atomic?)
635630
|> Keyword.delete(:on)
636-
}
631+
},
632+
@pipe_through
637633
]
638634
],
639635
args: [:name, {:optional, :returns}]
@@ -674,9 +670,6 @@ defmodule Ash.Resource.Dsl do
674670
arguments: [
675671
@action_argument
676672
],
677-
pipe_through: [
678-
@pipe_through
679-
],
680673
preparations: [
681674
@prepare,
682675
%{
@@ -685,7 +678,8 @@ defmodule Ash.Resource.Dsl do
685678
@validate.schema
686679
|> Keyword.delete(:always_atomic?)
687680
|> Keyword.delete(:on)
688-
}
681+
},
682+
@pipe_through
689683
],
690684
pagination: [
691685
@pagination
@@ -716,9 +710,7 @@ defmodule Ash.Resource.Dsl do
716710
entities: [
717711
changes: [
718712
@action_change,
719-
@action_validate
720-
],
721-
pipe_through: [
713+
@action_validate,
722714
@pipe_through
723715
],
724716
metadata: [
@@ -764,9 +756,7 @@ defmodule Ash.Resource.Dsl do
764756
entities: [
765757
changes: [
766758
@action_change,
767-
@action_validate
768-
],
769-
pipe_through: [
759+
@action_validate,
770760
@pipe_through
771761
],
772762
metadata: [

lib/ash/resource/transformers/resolve_pipelines.ex

Lines changed: 56 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
# SPDX-License-Identifier: MIT
44

55
defmodule Ash.Resource.Transformers.ResolvePipelines do
6-
@moduledoc "Resolves `pipe_through` on actions by injecting pipeline changes/preparations."
6+
@moduledoc "Resolves `pipe_through` on actions by replacing PipeThrough entities with pipeline contents in-place."
77
use Spark.Dsl.Transformer
88

9+
alias Ash.Resource.Actions.PipeThrough
910
alias Spark.Dsl.Transformer
1011
alias Spark.Error.DslError
1112

@@ -23,90 +24,80 @@ defmodule Ash.Resource.Transformers.ResolvePipelines do
2324

2425
dsl_state
2526
|> Transformer.get_entities([:actions])
26-
|> Enum.filter(&(&1.pipe_through != []))
2727
|> Enum.reduce_while({:ok, dsl_state}, fn action, {:ok, dsl_state} ->
28-
case resolve_pipe_through(action, pipelines, dsl_state) do
29-
{:ok, updated_action} ->
30-
new_state =
31-
Transformer.replace_entity(
32-
dsl_state,
33-
[:actions],
34-
updated_action,
35-
&(&1.name == action.name && &1.type == action.type)
36-
)
37-
38-
{:cont, {:ok, new_state}}
39-
40-
{:error, error} ->
41-
{:halt, {:error, error}}
28+
field = entity_field(action.type)
29+
entities = Map.get(action, field, [])
30+
31+
if Enum.any?(entities, &match?(%PipeThrough{}, &1)) do
32+
updated_action = resolve_action(action, field, entities, pipelines, dsl_state)
33+
34+
new_state =
35+
Transformer.replace_entity(
36+
dsl_state,
37+
[:actions],
38+
updated_action,
39+
&(&1.name == action.name && &1.type == action.type)
40+
)
41+
42+
{:cont, {:ok, new_state}}
43+
else
44+
{:cont, {:ok, dsl_state}}
4245
end
4346
end)
4447
end
4548

46-
defp resolve_pipe_through(action, pipelines, dsl_state) do
47-
# Flatten all pipe_through declarations into {name, where_conditions} pairs
48-
pairs =
49-
Enum.flat_map(action.pipe_through, fn %{names: names, where: where} ->
50-
Enum.map(names, &{&1, where})
49+
defp resolve_action(action, field, entities, pipelines, dsl_state) do
50+
{expanded, arguments, accept} =
51+
Enum.reduce(entities, {[], [], []}, fn
52+
%PipeThrough{names: names, where: where}, {expanded, arguments, accept} ->
53+
{new_entities, new_arguments, new_accept} =
54+
expand_pipe_through(names, where, action, pipelines, dsl_state)
55+
56+
{expanded ++ new_entities, arguments ++ new_arguments, accept ++ new_accept}
57+
58+
entity, {expanded, arguments, accept} ->
59+
{expanded ++ [entity], arguments, accept}
5160
end)
5261

53-
pairs
54-
|> Enum.reduce_while({:ok, {[], [], []}}, fn {name, where},
55-
{:ok, {entities, arguments, accept}} ->
62+
action
63+
|> Map.put(field, expanded)
64+
|> merge_arguments(arguments, dsl_state)
65+
|> merge_accept(merge_pipeline_accepts(accept))
66+
end
67+
68+
defp expand_pipe_through(names, where, action, pipelines, dsl_state) do
69+
Enum.reduce(names, {[], [], []}, fn name, {entities, arguments, accept} ->
5670
case Map.fetch(pipelines, name) do
5771
{:ok, pipeline} ->
58-
new_entities = collect_entities(action.type, pipeline, where, dsl_state)
72+
pipeline_entities = collect_entities(action.type, pipeline, where, dsl_state)
5973

60-
{:cont,
61-
{:ok,
62-
{[new_entities | entities], [pipeline.arguments | arguments],
63-
[pipeline.accept | accept]}}}
74+
{entities ++ pipeline_entities, arguments ++ pipeline.arguments,
75+
accept ++ List.wrap(if pipeline.accept == :*, do: [:*], else: pipeline.accept)}
6476

6577
:error ->
66-
{:halt,
67-
{:error,
68-
DslError.exception(
69-
module: Transformer.get_persisted(dsl_state, :module),
70-
path: [:actions, action.type],
71-
message:
72-
"Action `#{action.name}` references pipeline `#{name}` via `pipe_through`, but no pipeline named `#{name}` exists."
73-
)}}
78+
raise DslError,
79+
module: Transformer.get_persisted(dsl_state, :module),
80+
path: [:actions, action.type],
81+
message:
82+
"Action `#{action.name}` references pipeline `#{name}` via `pipe_through`, but no pipeline named `#{name}` exists."
7483
end
7584
end)
76-
|> case do
77-
{:ok, {entities, arguments, accept}} ->
78-
merged_accept = merge_pipeline_accepts(accept)
79-
80-
{:ok,
81-
apply_to_action(
82-
action,
83-
entities |> Enum.reverse() |> Enum.concat(),
84-
arguments |> Enum.reverse() |> Enum.concat(),
85-
merged_accept,
86-
dsl_state
87-
)}
88-
89-
{:error, error} ->
90-
{:error, error}
91-
end
9285
end
9386

94-
defp apply_to_action(action, entities, arguments, accept, dsl_state) do
95-
action
96-
|> prepend_entities(entities)
97-
|> merge_arguments(arguments, dsl_state)
98-
|> merge_accept(accept)
99-
end
87+
defp collect_entities(type, pipeline, where, dsl_state) do
88+
subject = subject_for_type(type)
89+
entities = source_entities(pipeline, type)
90+
91+
Enum.each(entities, &validate_supports!(&1, subject, pipeline, dsl_state))
10092

101-
defp prepend_entities(action, entities) do
102-
field = entity_field(action.type)
103-
Map.update!(action, field, &(entities ++ &1))
93+
Enum.map(entities, fn entity ->
94+
Map.update!(entity, :where, &(where ++ &1))
95+
end)
10496
end
10597

10698
defp merge_arguments(action, [], _dsl_state), do: action
10799

108100
defp merge_arguments(action, pipeline_arguments, dsl_state) do
109-
# Dedup pipeline arguments (multiple pipelines may define the same arg)
110101
{deduped_pipeline_args, pipeline_conflicts} =
111102
Enum.reduce(pipeline_arguments, {%{}, []}, fn arg, {seen, conflicts} ->
112103
case Map.fetch(seen, arg.name) do
@@ -115,7 +106,6 @@ defmodule Ash.Resource.Transformers.ResolvePipelines do
115106

116107
{:ok, existing} ->
117108
if existing.type == arg.type do
118-
# same type from different pipelines — keep first
119109
{seen, conflicts}
120110
else
121111
{seen, [{arg.name, existing.type, arg.type} | conflicts]}
@@ -135,7 +125,6 @@ defmodule Ash.Resource.Transformers.ResolvePipelines do
135125
message: "Pipelines define argument(s) with conflicting types: #{conflict_details}"
136126
end
137127

138-
# Check pipeline args against action args
139128
action_args_by_name = Map.new(action.arguments, &{&1.name, &1})
140129

141130
{new_args, action_conflicts} =
@@ -172,10 +161,10 @@ defmodule Ash.Resource.Transformers.ResolvePipelines do
172161
end
173162

174163
defp merge_pipeline_accepts(accept_lists) do
175-
if Enum.any?(accept_lists, &(&1 == :*)) do
164+
if :* in accept_lists do
176165
:*
177166
else
178-
accept_lists |> Enum.reverse() |> Enum.concat() |> Enum.uniq()
167+
Enum.uniq(accept_lists)
179168
end
180169
end
181170

@@ -195,17 +184,6 @@ defmodule Ash.Resource.Transformers.ResolvePipelines do
195184
end
196185
end
197186

198-
defp collect_entities(type, pipeline, where, dsl_state) do
199-
subject = subject_for_type(type)
200-
entities = source_entities(pipeline, type)
201-
202-
Enum.each(entities, &validate_supports!(&1, subject, pipeline, dsl_state))
203-
204-
Enum.map(entities, fn entity ->
205-
Map.update!(entity, :where, &(where ++ &1))
206-
end)
207-
end
208-
209187
defp validate_supports!(
210188
%Ash.Resource.Validation{module: module, opts: opts},
211189
subject,

test/resource/actions/pipelines_test.exs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,39 @@ defmodule Ash.Test.Resource.Actions.PipelinesTest do
160160
end
161161
end
162162

163+
describe "pipe_through placement ordering" do
164+
test "preserves declaration order — change, pipe_through, change" do
165+
defresource PlacementBetween do
166+
pipelines do
167+
pipeline :p do
168+
change set_attribute(:score, 1)
169+
end
170+
end
171+
172+
actions do
173+
create :ordered do
174+
accept [:name]
175+
change set_attribute(:state, :before)
176+
pipe_through [:p]
177+
change set_attribute(:name, "after")
178+
end
179+
end
180+
end
181+
182+
action = Info.action(PlacementBetween, :ordered)
183+
[first, second, third] = action.changes
184+
185+
assert first.change ==
186+
{Ash.Resource.Change.SetAttribute, value: :before, attribute: :state}
187+
188+
assert second.change ==
189+
{Ash.Resource.Change.SetAttribute, value: 1, attribute: :score}
190+
191+
assert third.change ==
192+
{Ash.Resource.Change.SetAttribute, value: "after", attribute: :name}
193+
end
194+
end
195+
163196
describe "multiple pipe_through declarations" do
164197
test "each declaration's entities are appended in order with separate where" do
165198
defresource MultiPipeThrough do

0 commit comments

Comments
 (0)