chore: better handling of atomic validations

This commit is contained in:
Zach Daniel 2024-01-29 17:55:56 -05:00
parent f4339be426
commit 8e82d9588f
4 changed files with 89 additions and 72 deletions

View file

@ -11,16 +11,14 @@ update :increment_score do
end end
``` ```
## Current State
Atomics are new, and we will be progressively enhancing various features to support/be aware of atomics. Unless listed below, no other features are aware of atomics. There are many places that can be enriched to either be aware of or leverage atomics. For example, changes could have an atomic and a non-atomic version, policies could be made to support atomics by altering atomic expressions to raise errors, allowing for authorization of atomic changes that doesn't have to wait until after the query.
## What is supported ## What is supported
- Atomics are only supported in update actions *upserts are not supported yet* - Atomics are only supported in update actions and upserts. In the case of upserts, the atomic changes are only applied in the case of a conflicting record.
- Attaching atomics to an action using `set/2` in the action, as shown in the example below. - Attaching atomics to an action using the `atomic_update/2` change in the action, as shown in the example below.
- Attaching atomics to a changeset by hand - Attaching atomics to a changeset manually with `Ash.Changeset.atomic_update/3`
- Using calculations that don't refer to aggregates in expressions - Using calculations that don't refer to aggregates or related values in expressions
### Manually attached
```elixir ```elixir
changeset changeset
@ -28,26 +26,12 @@ changeset
|> Api.update!() |> Api.update!()
``` ```
## What is not supported/may come in the future ### Upsert example
- atomic support in upserts, with a special reference to the row being overwritten:
```elixir ```elixir
create :upsert do create :upsert do
upsert? true upsert? true
change set_attribute(:points, 1) # set to 1 change set_attribute(:points, 1) # set to 1
set_on_upsert :points, expr(base.points + 1) # or increment existing set_on_upsert :points, expr(points + 1) # or increment existing
end end
``` ```
- using calculations that refer to aggregates/would need to join to other resources in atomics
- lowering validations, policies, and changes into atomics when data layers support it
- bulk updates using atomics, i.e
```elixir
Resource
|> Ash.Query.for_read(:some_read_action)
|> Api.update(set: [points: expr(points + 1)])
```

View file

@ -563,57 +563,67 @@ defmodule Ash.Changeset do
{:cont, changeset} {:cont, changeset}
end end
%{validation: {module, validation_opts}, where: where}, changeset -> %{validation: _} = validation, changeset ->
case List.wrap(module.atomic(changeset, validation_opts)) do case run_atomic_validation(changeset, validation) do
[{:atomic, _, _, _} | _] = atomics -> {:not_atomic, reason} ->
Enum.reduce_while(atomics, changeset, fn {:halt, {:not_atomic, reason}}
{:atomic, fields, condition_expr, error_expr}, changeset ->
condition_expr = rewrite_atomics(changeset, condition_expr)
Ash.Filter.walk_filter_template(condition_expr, fn changeset ->
{:_atomic_ref, field} -> {:cont, changeset}
atomic_ref(changeset, field)
other ->
other
end)
with {:changing?, true} <-
{:changing?,
fields == :* || Enum.any?(fields, &changing_attribute?(changeset, &1))},
{:atomic, condition} <- atomic_condition(where, changeset) do
case condition do
true ->
{:cont, validate_atomically(changeset, condition_expr, error_expr)}
false ->
{:cont, changeset}
condition ->
condition_expr =
Ash.Expr.expr(^condition and condition_expr)
{:cont, validate_atomically(changeset, condition_expr, error_expr)}
end
else
{:changing?, false} ->
{:cont, changeset}
{:not_atomic, reason} ->
{:halt, {:not_atomic, reason}}
end
end)
|> case do
{:not_atomic, reason} -> {:halt, {:not_atomic, reason}}
changeset -> {:cont, changeset}
end
[value] ->
{:halt, value}
end end
end) end)
end end
defp run_atomic_validation(changeset, %{validation: {module, validation_opts}, where: where}) do
case List.wrap(module.atomic(changeset, validation_opts)) do
[{:atomic, _, _, _} | _] = atomics ->
Enum.reduce_while(atomics, changeset, fn
{:atomic, fields, condition_expr, error_expr}, changeset ->
condition_expr = rewrite_atomics(changeset, condition_expr)
Ash.Filter.walk_filter_template(condition_expr, fn
{:_atomic_ref, field} ->
atomic_ref(changeset, field)
other ->
other
end)
with {:changing?, true} <-
{:changing?,
fields == :* || Enum.any?(fields, &changing_attribute?(changeset, &1))},
{:atomic, condition} <- atomic_condition(where, changeset) do
case condition do
true ->
{:cont, validate_atomically(changeset, condition_expr, error_expr)}
false ->
{:cont, changeset}
condition ->
condition_expr =
Ash.Expr.expr(^condition and condition_expr)
{:cont, validate_atomically(changeset, condition_expr, error_expr)}
end
else
{:changing?, false} ->
{:cont, changeset}
{:not_atomic, reason} ->
{:halt, {:not_atomic, reason}}
end
end)
|> case do
{:not_atomic, reason} -> {:not_atomic, reason}
changeset -> changeset
end
[value] ->
value
end
end
defp rewrite_atomics(changeset, expr) do defp rewrite_atomics(changeset, expr) do
Ash.Filter.walk_filter_template(expr, fn Ash.Filter.walk_filter_template(expr, fn
{:_atomic_ref, ref} -> {:_atomic_ref, ref} ->
@ -1685,9 +1695,12 @@ defmodule Ash.Changeset do
%{only_when_valid?: true}, %{valid?: false} = changeset -> %{only_when_valid?: true}, %{valid?: false} = changeset ->
changeset changeset
%{always_atomic?: true} = change, changeset -> %{always_atomic?: true, change: _} = change, changeset ->
run_atomic_change(changeset, change, context) run_atomic_change(changeset, change, context)
%{always_atomic?: true, validation: _} = change, changeset ->
run_atomic_validation(changeset, change)
%{change: {module, opts}, where: where}, changeset -> %{change: {module, opts}, where: where}, changeset ->
if Enum.all?(where || [], fn {module, opts} -> if Enum.all?(where || [], fn {module, opts} ->
Ash.Tracer.span :validation, "change condition: #{inspect(module)}", tracer do Ash.Tracer.span :validation, "change condition: #{inspect(module)}", tracer do

View file

@ -1,6 +1,9 @@
defmodule Ash.Resource.Actions.Update do defmodule Ash.Resource.Actions.Update do
@moduledoc "Represents a update action on a resource." @moduledoc "Represents a update action on a resource."
require Ash.Flags
@require_atomic_default Ash.Flags.ash_three?()
defstruct [ defstruct [
:name, :name,
:primary?, :primary?,
@ -9,6 +12,7 @@ defmodule Ash.Resource.Actions.Update do
accept: nil, accept: nil,
manual: nil, manual: nil,
manual?: false, manual?: false,
require_atomic?: @require_atomic_default,
atomics: [], atomics: [],
require_attributes: [], require_attributes: [],
delay_global_validations?: false, delay_global_validations?: false,
@ -27,6 +31,7 @@ defmodule Ash.Resource.Actions.Update do
name: atom, name: atom,
manual: module | nil, manual: module | nil,
accept: list(atom), accept: list(atom),
require_atomic?: boolean,
arguments: list(Ash.Resource.Actions.Argument.t()), arguments: list(Ash.Resource.Actions.Argument.t()),
delay_global_validations?: boolean, delay_global_validations?: boolean,
skip_global_validations?: boolean, skip_global_validations?: boolean,
@ -48,6 +53,13 @@ defmodule Ash.Resource.Actions.Update do
doc: """ doc: """
Override the update behavior. Accepts a module or module and opts, or a function that takes the changeset and context. See the [manual actions guide](/documentation/topics/manual-actions.md) for more. Override the update behavior. Accepts a module or module and opts, or a function that takes the changeset and context. See the [manual actions guide](/documentation/topics/manual-actions.md) for more.
""" """
],
require_atomic?: [
type: :boolean,
doc: """
Require that the update be atomic. This means that all changes and validations implement the `atomic` callback. See the guide on atomic updates for more.
""",
default: @require_atomic_default
] ]
] ]
|> Spark.OptionsHelpers.merge_schemas( |> Spark.OptionsHelpers.merge_schemas(

View file

@ -138,6 +138,8 @@ defmodule Ash.Resource.Change do
| :ok | :ok
| {:error, term()} | {:error, term()}
@callback atomic?() :: boolean
@callback after_atomic(Ash.Changeset.t(), Keyword.t(), Ash.Resource.record(), context()) :: @callback after_atomic(Ash.Changeset.t(), Keyword.t(), Ash.Resource.record(), context()) ::
{:ok, Ash.Resource.record()} | {:error, term()} {:ok, Ash.Resource.record()} | {:error, term()}
@ -155,11 +157,17 @@ defmodule Ash.Resource.Change do
def init(opts), do: {:ok, opts} def init(opts), do: {:ok, opts}
if Module.defines?(__MODULE__, {:atomic, 3}, :def) do
def atomic?, do: true
else
def atomic?, do: false
end
def atomic(_changeset, _opts, _context) do def atomic(_changeset, _opts, _context) do
{:not_atomic, "#{inspect(__MODULE__)} does not implement `atomic/2`"} {:not_atomic, "#{inspect(__MODULE__)} does not implement `atomic/2`"}
end end
defoverridable init: 1, atomic: 3 defoverridable init: 1, atomic: 3, atomic?: 0
end end
end end
end end