Skip to content

Commit a372956

Browse files
committed
fix: don't allow modifying changeset with atomic conditions
this fixes a bug where by some conditions on changes were not being honored. This may cause a problem for certiain specific formulations of changes plus conditions right now, but the current behavior cannot be kept. We will likely need a way to return after_action hooks that will be called conditionally if the expression returns true or false when placed as a calcualtion into the update. Not an easy change unfortunately. improvement: add `touching?` option to changing validation
1 parent 01c6bf2 commit a372956

File tree

5 files changed

+147
-90
lines changed

5 files changed

+147
-90
lines changed

lib/ash/changeset/changeset.ex

+62-27
Original file line numberDiff line numberDiff line change
@@ -1022,21 +1022,25 @@ defmodule Ash.Changeset do
10221022
)
10231023

10241024
with {:ok, change_opts} <- module.init(change_opts),
1025-
{:atomic, changeset, atomic_changes, validations} <-
1026-
atomic_with_changeset(
1027-
module.atomic(changeset, change_opts, struct(Ash.Resource.Change.Context, context)),
1028-
changeset
1029-
),
1030-
{:atomic, condition} <- atomic_condition(where, changeset, context) do
1031-
changeset =
1032-
case condition do
1033-
true ->
1034-
apply_atomic_update(changeset, atomic_changes)
1025+
{:atomic, condition} <-
1026+
atomic_condition(where, changeset, context),
1027+
{{:atomic, modified_changeset?, new_changeset, atomic_changes, validations}, condition} <-
1028+
{atomic_with_changeset(
1029+
module.atomic(changeset, change_opts, struct(Ash.Resource.Change.Context, context)),
1030+
changeset
1031+
), condition} do
1032+
case condition do
1033+
true ->
1034+
{:atomic, apply_atomic_update(new_changeset, atomic_changes)}
10351035

1036-
false ->
1037-
changeset
1036+
false ->
1037+
{:atomic, changeset}
10381038

1039-
condition ->
1039+
condition ->
1040+
if modified_changeset? do
1041+
{:not_atomic,
1042+
"change `#{inspect(module)}` modified the changeset, but had a condition that could not be checked without running the action"}
1043+
else
10401044
atomic_changes =
10411045
Map.new(atomic_changes, fn {key, value} ->
10421046
new_value =
@@ -1051,24 +1055,33 @@ defmodule Ash.Changeset do
10511055
{key, new_value}
10521056
end)
10531057

1054-
apply_atomic_update(changeset, atomic_changes)
1055-
end
1058+
{:atomic, apply_atomic_update(new_changeset, atomic_changes)}
1059+
end
1060+
end
1061+
|> case do
1062+
{:not_atomic, reason} ->
1063+
{:not_atomic, reason}
10561064

1057-
Enum.reduce(
1058-
List.wrap(validations),
1059-
changeset,
1060-
fn {:atomic, _, condition_expr, error_expr}, changeset ->
1061-
validate_atomically(changeset, condition_expr, error_expr)
1062-
end
1063-
)
1065+
{:atomic, changeset} ->
1066+
Enum.reduce(
1067+
List.wrap(validations),
1068+
changeset,
1069+
fn {:atomic, _, condition_expr, error_expr}, changeset ->
1070+
validate_atomically(changeset, condition_expr, error_expr)
1071+
end
1072+
)
1073+
end
10641074
else
1065-
{:ok, changeset} ->
1066-
changeset
1075+
{{:ok, new_changeset}, _condition} ->
1076+
new_changeset
1077+
1078+
{{:not_atomic, reason}, _} ->
1079+
{:not_atomic, reason}
10671080

10681081
{:not_atomic, reason} ->
10691082
{:not_atomic, reason}
10701083

1071-
:ok ->
1084+
{:ok, _} ->
10721085
changeset
10731086
end
10741087
end
@@ -1133,9 +1146,15 @@ defmodule Ash.Changeset do
11331146
end
11341147

11351148
defp atomic_with_changeset({:atomic, changeset, atomics}, _changeset),
1136-
do: {:atomic, changeset, atomics, []}
1149+
do: {:atomic, true, changeset, atomics, []}
1150+
1151+
defp atomic_with_changeset({:atomic, atomics}, changeset),
1152+
do: {:atomic, false, changeset, atomics, []}
1153+
1154+
defp atomic_with_changeset({:ok, changeset}, _) do
1155+
{:atomic, true, changeset, %{}, []}
1156+
end
11371157

1138-
defp atomic_with_changeset({:atomic, atomics}, changeset), do: {:atomic, changeset, atomics, []}
11391158
defp atomic_with_changeset(other, _), do: other
11401159

11411160
defp validate_atomically(changeset, condition_expr, error_expr) do
@@ -1207,6 +1226,22 @@ defmodule Ash.Changeset do
12071226
|> then(&{:cont, {:atomic, &1}})
12081227
end
12091228
end)
1229+
|> case do
1230+
{:atomic, expr} ->
1231+
case Ash.Expr.eval(expr,
1232+
resource: changeset.resource,
1233+
unknown_on_unknown_refs?: true
1234+
) do
1235+
{:ok, value} ->
1236+
{:atomic, value}
1237+
1238+
:unknown ->
1239+
{:atomic, expr}
1240+
1241+
{:error, _} ->
1242+
{:atomic, false}
1243+
end
1244+
end
12101245
end
12111246

12121247
# This is not expressly necessary as `expr(true and not ^new_expr)` would also

lib/ash/resource/change/after_action.ex

-7
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,4 @@ defmodule Ash.Resource.Change.AfterAction do
1313
prepend?: opts[:prepend?]
1414
)
1515
end
16-
17-
# we had to remove this. We can't be sure that the change function won't access
18-
# `changeset.data`, so cannot do this automatically
19-
# @impl true
20-
# def atomic(changeset, opts, context) do
21-
# {:ok, change(changeset, opts, context)}
22-
# end
2316
end

lib/ash/resource/change/set_attribute.ex

+29-33
Original file line numberDiff line numberDiff line change
@@ -80,49 +80,45 @@ defmodule Ash.Resource.Change.SetAttribute do
8080
end
8181

8282
@impl true
83-
def atomic(changeset, opts, context) do
83+
def atomic(changeset, opts, _context) do
8484
value =
8585
case opts[:value] do
8686
value when is_function(value) -> value.()
8787
value -> value
8888
end
8989

90-
if Ash.Expr.expr?(value) do
91-
if opts[:new?] do
92-
{:atomic,
93-
%{
94-
opts[:attribute] =>
95-
expr(
96-
if is_nil(^atomic_ref(opts[:attribute])) do
97-
^value
98-
else
99-
^atomic_ref(opts[:attribute])
100-
end
101-
)
102-
}}
90+
if opts[:new?] do
91+
{:atomic,
92+
%{
93+
opts[:attribute] =>
94+
expr(
95+
if is_nil(^atomic_ref(opts[:attribute])) do
96+
^value
97+
else
98+
^atomic_ref(opts[:attribute])
99+
end
100+
)
101+
}}
102+
else
103+
if opts[:set_when_nil?] do
104+
{:atomic, %{opts[:attribute] => value}}
103105
else
104-
if opts[:set_when_nil?] do
105-
{:atomic, %{opts[:attribute] => value}}
106+
if is_nil(value) do
107+
{:ok, changeset}
106108
else
107-
if is_nil(value) do
108-
{:ok, changeset}
109-
else
110-
{:atomic,
111-
%{
112-
opts[:attribute] =>
113-
expr(
114-
if is_nil(^value) do
115-
^atomic_ref(opts[:attribute])
116-
else
117-
^value
118-
end
119-
)
120-
}}
121-
end
109+
{:atomic,
110+
%{
111+
opts[:attribute] =>
112+
expr(
113+
if is_nil(^value) do
114+
^atomic_ref(opts[:attribute])
115+
else
116+
^value
117+
end
118+
)
119+
}}
122120
end
123121
end
124-
else
125-
{:ok, change(changeset, opts, context)}
126122
end
127123
end
128124
end

lib/ash/resource/validation/builtins.ex

+8-3
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,15 @@ defmodule Ash.Resource.Validation.Builtins do
2626
2727
validate changing(:first_name)
2828
validate changing(:comments)
29+
validate changing(:comments, touching?: true)
30+
31+
## Options
32+
33+
#{Ash.Resource.Validation.Changing.Opts.docs()}
2934
"""
30-
@spec changing(attribute_or_relationship :: atom) :: Validation.ref()
31-
def changing(field) do
32-
{Validation.Changing, field: field}
35+
@spec changing(attribute_or_relationship :: atom, opts :: Keyword.t()) :: Validation.ref()
36+
def changing(field, opts \\ []) do
37+
{Validation.Changing, Keyword.merge(opts, field: field)}
3338
end
3439

3540
@doc """

lib/ash/resource/validation/changing.ex

+48-20
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ defmodule Ash.Resource.Validation.Changing do
1515
field: [
1616
type: :atom,
1717
required: true,
18-
doc: "The attribute or relationship to check for changes."
18+
doc:
19+
"The attribute or relationship to check for changes. Using a relationship does not compare old and new value, returning `true` if the value is being touched)"
20+
],
21+
touching?: [
22+
type: :atom,
23+
required: false,
24+
default: false,
25+
doc:
26+
"Whether to consider a field as changing if it is just being touched (i.e consider it changed when it is being changed to its current value)"
1927
]
2028
]
2129
end
@@ -35,12 +43,28 @@ defmodule Ash.Resource.Validation.Changing do
3543

3644
@impl Ash.Resource.Validation
3745
def validate(changeset, opts, context) do
38-
if field_is_changing?(changeset, Keyword.fetch!(opts, :field)),
46+
if field_is_changing?(changeset, Keyword.fetch!(opts, :field), opts[:touching?]),
3947
do: :ok,
4048
else: {:error, exception(opts, context)}
4149
end
4250

43-
defp field_is_changing?(changeset, field) do
51+
defp field_is_changing?(changeset, field, true) do
52+
case Ash.Resource.Info.relationship(changeset.resource, field) do
53+
nil ->
54+
Map.has_key?(changeset.casted_attributes, field) ||
55+
Map.has_key?(changeset.attributes, field) || Keyword.has_key?(changeset.atomcis, field)
56+
57+
%{type: :belongs_to} = relationship ->
58+
Map.has_key?(changeset.casted_attributes, field) ||
59+
Map.has_key?(changeset.attributes, field) || Keyword.has_key?(changeset.atomcis, field) ||
60+
changing_relationship?(changeset, relationship.name)
61+
62+
relationship ->
63+
changing_relationship?(changeset, relationship.name)
64+
end
65+
end
66+
67+
defp field_is_changing?(changeset, field, _) do
4468
case Ash.Resource.Info.relationship(changeset.resource, field) do
4569
nil ->
4670
changing_attribute?(changeset, field)
@@ -56,23 +80,27 @@ defmodule Ash.Resource.Validation.Changing do
5680

5781
@impl Ash.Resource.Validation
5882
def atomic(changeset, opts, context) do
59-
case atomic_field(changeset, Keyword.fetch!(opts, :field)) do
60-
{:changing, field} ->
61-
{:atomic, [field], expr(^atomic_ref(field) == ^ref(field)),
62-
expr(
63-
error(^InvalidAttribute, %{
64-
field: ^field,
65-
value: ^atomic_ref(field),
66-
message: ^(context.message || @default_error_message),
67-
vars: %{field: ^field}
68-
})
69-
)}
70-
71-
{:not_changing, _field} ->
72-
{:error, exception(opts, context)}
73-
74-
{:not_atomic, field} ->
75-
{:not_atomic, "can't atomically check if #{field} relationship is changing"}
83+
if opts[:touching?] do
84+
validate(changeset, opts, context)
85+
else
86+
case atomic_field(changeset, Keyword.fetch!(opts, :field)) do
87+
{:changing, field} ->
88+
{:atomic, [field], expr(^atomic_ref(field) == ^ref(field)),
89+
expr(
90+
error(^InvalidAttribute, %{
91+
field: ^field,
92+
value: ^atomic_ref(field),
93+
message: ^(context.message || @default_error_message),
94+
vars: %{field: ^field}
95+
})
96+
)}
97+
98+
{:not_changing, _field} ->
99+
{:error, exception(opts, context)}
100+
101+
{:not_atomic, field} ->
102+
{:not_atomic, "can't atomically check if #{field} relationship is changing"}
103+
end
76104
end
77105
end
78106

0 commit comments

Comments
 (0)