Skip to content

Commit 0808869

Browse files
authored
improvement: Add Ash.Subject to abstract Changeset, Query, ActionInput common functions (ash-project#2212)
1 parent c244822 commit 0808869

2 files changed

Lines changed: 1106 additions & 0 deletions

File tree

lib/ash/subject.ex

Lines changed: 395 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,395 @@
1+
defmodule Ash.Subject do
2+
@moduledoc """
3+
Provides a consistent API for common operations across `Ash.Changeset`,
4+
`Ash.Query`, and `Ash.ActionInput`. It allows you to write generic code that works
5+
with any of these types without needing to pattern match or special-case your logic.
6+
"""
7+
8+
@type t :: Ash.Changeset.t() | Ash.Query.t() | Ash.ActionInput.t()
9+
10+
@doc """
11+
Adds an error or list of errors to the subject.
12+
13+
Supports all subject types (Changeset, Query, ActionInput) and maintains
14+
type consistency.
15+
16+
## Parameters
17+
18+
* `subject` - The subject to add errors to
19+
* `errors` - Error or list of errors to add
20+
"""
21+
@spec add_error(t(), Ash.Error.error_input() | list(Ash.Error.error_input())) :: t()
22+
def add_error(subject, []), do: subject
23+
24+
def add_error(%Ash.Changeset{} = subject, error) do
25+
Ash.Changeset.add_error(subject, error, [])
26+
end
27+
28+
def add_error(%Ash.Query{} = subject, error) do
29+
Ash.Query.add_error(subject, [], error)
30+
end
31+
32+
def add_error(%Ash.ActionInput{} = subject, error) do
33+
Ash.ActionInput.add_error(subject, error, [])
34+
end
35+
36+
@doc """
37+
Puts a key-value pair into the subject's context.
38+
39+
## Parameters
40+
41+
* `subject` - The subject to update context on
42+
* `key` - The context key
43+
* `value` - The value to store
44+
"""
45+
@spec put_context(t(), atom, term()) :: t()
46+
def put_context(subject, key, value) do
47+
set_context(subject, %{key => value})
48+
end
49+
50+
@doc """
51+
Sets the context for the subject.
52+
53+
Merges the provided map into the subject's existing context.
54+
For Changeset and Query, delegates to their specific implementations.
55+
56+
## Parameters
57+
58+
* `subject` - The subject to set context on
59+
* `context` - Map of context data to merge
60+
"""
61+
@spec set_context(t(), map) :: t()
62+
def set_context(%Ash.Changeset{} = subject, map) do
63+
Ash.Changeset.set_context(subject, map)
64+
end
65+
66+
def set_context(%Ash.Query{} = subject, map) do
67+
Ash.Query.set_context(subject, map)
68+
end
69+
70+
def set_context(subject, nil), do: subject
71+
72+
def set_context(subject, map) do
73+
%{
74+
subject
75+
| context:
76+
subject.context
77+
|> Ash.Helpers.deep_merge_maps(map)
78+
|> then(&Ash.Helpers.deep_merge_maps(&1, map[:shared] || %{}))
79+
}
80+
end
81+
82+
@doc """
83+
Gets an argument or attribute value from a Changeset, or just an argument from other subjects.
84+
85+
For Changesets, this can retrieve both arguments and attributes.
86+
For Query and ActionInput, this only retrieves arguments.
87+
88+
## Parameters
89+
90+
* `subject` - The subject to get value from
91+
* `name` - The argument or attribute name (atom or string)
92+
"""
93+
@spec get_argument_or_attribute(t(), atom | binary) :: term()
94+
def get_argument_or_attribute(subject, argument_or_attribute, default \\ nil)
95+
96+
def get_argument_or_attribute(%Ash.Changeset{} = subject, argument_or_attribute, default) do
97+
case Ash.Changeset.get_argument_or_attribute(subject, argument_or_attribute) do
98+
nil -> default
99+
value -> value
100+
end
101+
end
102+
103+
def get_argument_or_attribute(subject, argument_or_attribute, default) do
104+
get_argument(subject, argument_or_attribute, default)
105+
end
106+
107+
@doc """
108+
Gets an argument value from the subject.
109+
110+
Supports both atom and string argument names.
111+
112+
## Parameters
113+
114+
* `subject` - The subject to get argument from
115+
* `argument` - The argument name (atom or string)
116+
"""
117+
@spec get_argument(t(), atom | binary) :: term()
118+
def get_argument(subject, argument, default \\ nil)
119+
120+
def get_argument(subject, argument, default) when is_atom(argument) do
121+
value =
122+
if Map.has_key?(subject.arguments, argument) do
123+
Map.get(subject.arguments, argument)
124+
else
125+
Map.get(subject.arguments, to_string(argument))
126+
end
127+
128+
if is_nil(value) do
129+
default
130+
else
131+
value
132+
end
133+
end
134+
135+
def get_argument(subject, argument, default) when is_binary(argument) do
136+
subject.arguments
137+
|> Enum.find(fn {key, _} ->
138+
to_string(key) == argument
139+
end)
140+
|> case do
141+
{_key, value} ->
142+
value
143+
144+
_ ->
145+
default
146+
end
147+
end
148+
149+
@spec get_attribute(Ash.Changeset.t(), atom) :: term()
150+
def get_attribute(%Ash.Changeset{} = subject, attribute) do
151+
Ash.Changeset.get_attribute(subject, attribute)
152+
end
153+
154+
@doc """
155+
Fetches an argument value from the subject.
156+
157+
Returns `{:ok, value}` if the argument exists, `:error` otherwise.
158+
Supports both atom and string argument names.
159+
160+
## Parameters
161+
162+
* `subject` - The subject to fetch argument from
163+
* `argument` - The argument name (atom or string)
164+
"""
165+
@spec fetch_argument(t(), atom | binary) :: {:ok, term()} | :error
166+
def fetch_argument(subject, argument) when is_atom(argument) do
167+
case Map.fetch(subject.arguments, argument) do
168+
{:ok, value} ->
169+
{:ok, value}
170+
171+
:error ->
172+
case Map.fetch(subject.arguments, to_string(argument)) do
173+
{:ok, value} -> {:ok, value}
174+
:error -> :error
175+
end
176+
end
177+
end
178+
179+
def fetch_argument(subject, argument) when is_binary(argument) do
180+
subject.arguments
181+
|> Enum.find(fn {key, _} ->
182+
to_string(key) == argument
183+
end)
184+
|> case do
185+
{_key, value} ->
186+
{:ok, value}
187+
188+
_ ->
189+
:error
190+
end
191+
end
192+
193+
@doc """
194+
Sets multiple arguments on the subject.
195+
196+
Takes a map of argument names to values and sets them all.
197+
198+
## Parameters
199+
200+
* `subject` - The subject to set arguments on
201+
* `arguments` - Map of argument names to values
202+
"""
203+
@spec set_arguments(t(), map) :: t()
204+
def set_arguments(subject, map) do
205+
Enum.reduce(map, subject, fn {key, value}, subject ->
206+
set_argument(subject, key, value)
207+
end)
208+
end
209+
210+
@doc """
211+
Sets a single argument on the subject.
212+
213+
## Parameters
214+
215+
* `subject` - The subject to set argument on
216+
* `argument` - The argument name (atom or string)
217+
* `value` - The value to set
218+
"""
219+
@spec set_argument(t(), atom | binary, term()) :: t()
220+
def set_argument(%Ash.Changeset{} = subject, argument, value) do
221+
Ash.Changeset.set_argument(subject, argument, value)
222+
end
223+
224+
def set_argument(%Ash.Query{} = subject, argument, value) do
225+
Ash.Query.set_argument(subject, argument, value)
226+
end
227+
228+
def set_argument(%Ash.ActionInput{} = subject, argument, value) do
229+
Ash.ActionInput.set_argument(subject, argument, value)
230+
end
231+
232+
@doc """
233+
Deletes one or more arguments from the subject.
234+
235+
## Parameters
236+
237+
* `subject` - The subject to delete arguments from
238+
* `arguments` - Single argument name or list of argument names to delete
239+
"""
240+
@spec delete_argument(t(), atom | binary | list(atom | binary)) :: t()
241+
def delete_argument(%Ash.Changeset{} = subject, argument_or_arguments) do
242+
Ash.Changeset.delete_argument(subject, argument_or_arguments)
243+
end
244+
245+
def delete_argument(%Ash.Query{} = subject, argument_or_arguments) do
246+
Ash.Query.delete_argument(subject, argument_or_arguments)
247+
end
248+
249+
def delete_argument(subject, argument_or_arguments) do
250+
argument_or_arguments
251+
|> List.wrap()
252+
|> Enum.reduce(subject, fn argument, subject ->
253+
%{subject | arguments: Map.delete(subject.arguments, argument)}
254+
end)
255+
end
256+
257+
@doc """
258+
Sets a private argument on the subject.
259+
260+
Private arguments are not exposed in the public API.
261+
Only supported by Changeset and ActionInput.
262+
263+
## Parameters
264+
265+
* `subject` - The subject to set private argument on (Changeset or ActionInput)
266+
* `argument` - The argument name (atom or string)
267+
* `value` - The value to set
268+
"""
269+
@spec set_private_argument(Ash.Changeset.t() | Ash.ActionInput.t(), atom | binary, term()) ::
270+
Ash.Changeset.t() | Ash.ActionInput.t()
271+
def set_private_argument(%Ash.Changeset{} = subject, argument, value) do
272+
Ash.Changeset.set_private_argument(subject, argument, value)
273+
end
274+
275+
def set_private_argument(%Ash.ActionInput{} = subject, argument, value) do
276+
Ash.ActionInput.set_private_argument(subject, argument, value)
277+
end
278+
279+
@doc """
280+
Sets multiple private arguments on the subject.
281+
282+
Takes a map of argument names to values and sets them all as private arguments.
283+
Only supported by Changeset and ActionInput.
284+
285+
## Parameters
286+
287+
* `subject` - The subject to set private arguments on (Changeset or ActionInput)
288+
* `arguments` - Map of argument names to values
289+
"""
290+
@spec set_private_arguments(Ash.Changeset.t() | Ash.ActionInput.t(), map) ::
291+
Ash.Changeset.t() | Ash.ActionInput.t()
292+
def set_private_arguments(subject, map) do
293+
Enum.reduce(map, subject, fn {key, value}, subject ->
294+
set_private_argument(subject, key, value)
295+
end)
296+
end
297+
298+
@doc """
299+
Adds a callback to be executed before the action.
300+
301+
## Parameters
302+
303+
* `subject` - The subject to add callback to
304+
* `callback` - Function that takes and returns the subject
305+
* `opts` - Options including `:prepend?` to add at beginning
306+
"""
307+
@spec before_action(t(), (t() -> t()), Keyword.t()) :: t()
308+
def before_action(subject, callback, opts \\ [])
309+
310+
def before_action(%Ash.Changeset{} = subject, callback, opts) do
311+
Ash.Changeset.before_action(subject, callback, opts)
312+
end
313+
314+
def before_action(%Ash.Query{} = subject, callback, opts) do
315+
Ash.Query.before_action(subject, callback, opts)
316+
end
317+
318+
def before_action(subject, callback, opts) do
319+
if opts[:prepend?] do
320+
%{subject | before_action: [callback | subject.before_action]}
321+
else
322+
%{subject | before_action: subject.before_action ++ [callback]}
323+
end
324+
end
325+
326+
@doc """
327+
Adds a callback to be executed after the action.
328+
329+
Note: Query only supports 2-arity callbacks and ignores opts.
330+
331+
## Parameters
332+
333+
* `subject` - The subject to add callback to
334+
* `callback` - Function that processes the result
335+
* `opts` - Options including `:prepend?` (ignored for Query)
336+
"""
337+
@spec after_action(t(), (t(), term() -> {:ok, term()} | {:error, term()}), Keyword.t()) :: t()
338+
def after_action(subject, callback, opts \\ [])
339+
340+
def after_action(%Ash.Changeset{} = subject, callback, opts) do
341+
Ash.Changeset.after_action(subject, callback, opts)
342+
end
343+
344+
def after_action(%Ash.Query{} = subject, callback, _opts) do
345+
Ash.Query.after_action(subject, callback)
346+
end
347+
348+
def after_action(subject, callback, opts) do
349+
if opts[:prepend?] do
350+
%{subject | after_action: [callback | subject.after_action]}
351+
else
352+
%{subject | after_action: subject.after_action ++ [callback]}
353+
end
354+
end
355+
356+
@doc """
357+
Executes all before_action callbacks on the subject.
358+
359+
Only supported by Changeset and ActionInput.
360+
361+
## Parameters
362+
363+
* `subject` - The subject to run callbacks on
364+
"""
365+
@spec run_before_actions(Ash.Changeset.t() | Ash.ActionInput.t()) ::
366+
{Ash.Changeset.t() | Ash.ActionInput.t(), map()}
367+
def run_before_actions(%Ash.Changeset{} = subject) do
368+
Ash.Changeset.run_before_actions(subject)
369+
end
370+
371+
def run_before_actions(%Ash.ActionInput{} = subject) do
372+
Ash.ActionInput.run_before_actions(subject)
373+
end
374+
375+
@doc """
376+
Executes all after_action callbacks on the subject.
377+
378+
Only supported by Changeset and ActionInput.
379+
380+
## Parameters
381+
382+
* `result` - The result data from the action
383+
* `subject` - The subject that was executed
384+
* `notifications` - Notifications from before_action hooks
385+
"""
386+
@spec run_after_actions(term(), Ash.Changeset.t() | Ash.ActionInput.t(), term()) ::
387+
{term(), list()}
388+
def run_after_actions(result, %Ash.Changeset{} = subject, before_action_notifications) do
389+
Ash.Changeset.run_after_actions(result, subject, before_action_notifications)
390+
end
391+
392+
def run_after_actions(result, %Ash.ActionInput{} = subject, before_action_notifications) do
393+
Ash.ActionInput.run_after_actions(result, subject, before_action_notifications)
394+
end
395+
end

0 commit comments

Comments
 (0)