diff --git a/documentation/topics/atomics.md b/documentation/topics/atomics.md index 31199092..fad26449 100644 --- a/documentation/topics/atomics.md +++ b/documentation/topics/atomics.md @@ -11,16 +11,14 @@ update :increment_score do 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 -- Atomics are only supported in update actions *upserts are not supported yet* -- Attaching atomics to an action using `set/2` in the action, as shown in the example below. -- Attaching atomics to a changeset by hand -- Using calculations that don't refer to aggregates in expressions +- 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 the `atomic_update/2` change in the action, as shown in the example below. +- Attaching atomics to a changeset manually with `Ash.Changeset.atomic_update/3` +- Using calculations that don't refer to aggregates or related values in expressions + +### Manually attached ```elixir changeset @@ -28,26 +26,12 @@ changeset |> Api.update!() ``` -## What is not supported/may come in the future - -- atomic support in upserts, with a special reference to the row being overwritten: +### Upsert example ```elixir create :upsert do upsert? true 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 ``` - -- 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)]) -``` diff --git a/lib/ash/changeset/changeset.ex b/lib/ash/changeset/changeset.ex index 9fcbcdeb..7a328bce 100644 --- a/lib/ash/changeset/changeset.ex +++ b/lib/ash/changeset/changeset.ex @@ -563,57 +563,67 @@ defmodule Ash.Changeset do {:cont, changeset} end - %{validation: {module, validation_opts}, where: where}, changeset -> - 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) + %{validation: _} = validation, changeset -> + case run_atomic_validation(changeset, validation) do + {:not_atomic, reason} -> + {:halt, {:not_atomic, reason}} - 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} -> {:halt, {:not_atomic, reason}} - changeset -> {:cont, changeset} - end - - [value] -> - {:halt, value} + changeset -> + {:cont, changeset} 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 Ash.Filter.walk_filter_template(expr, fn {:_atomic_ref, ref} -> @@ -1685,9 +1695,12 @@ defmodule Ash.Changeset do %{only_when_valid?: true}, %{valid?: false} = changeset -> changeset - %{always_atomic?: true} = change, changeset -> + %{always_atomic?: true, change: _} = change, changeset -> run_atomic_change(changeset, change, context) + %{always_atomic?: true, validation: _} = change, changeset -> + run_atomic_validation(changeset, change) + %{change: {module, opts}, where: where}, changeset -> if Enum.all?(where || [], fn {module, opts} -> Ash.Tracer.span :validation, "change condition: #{inspect(module)}", tracer do diff --git a/lib/ash/resource/actions/update.ex b/lib/ash/resource/actions/update.ex index a4525e4e..6baf5495 100644 --- a/lib/ash/resource/actions/update.ex +++ b/lib/ash/resource/actions/update.ex @@ -1,6 +1,9 @@ defmodule Ash.Resource.Actions.Update do @moduledoc "Represents a update action on a resource." + require Ash.Flags + @require_atomic_default Ash.Flags.ash_three?() + defstruct [ :name, :primary?, @@ -9,6 +12,7 @@ defmodule Ash.Resource.Actions.Update do accept: nil, manual: nil, manual?: false, + require_atomic?: @require_atomic_default, atomics: [], require_attributes: [], delay_global_validations?: false, @@ -27,6 +31,7 @@ defmodule Ash.Resource.Actions.Update do name: atom, manual: module | nil, accept: list(atom), + require_atomic?: boolean, arguments: list(Ash.Resource.Actions.Argument.t()), delay_global_validations?: boolean, skip_global_validations?: boolean, @@ -48,6 +53,13 @@ defmodule Ash.Resource.Actions.Update do 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. """ + ], + 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( diff --git a/lib/ash/resource/change/change.ex b/lib/ash/resource/change/change.ex index 5befbfca..eb2a4860 100644 --- a/lib/ash/resource/change/change.ex +++ b/lib/ash/resource/change/change.ex @@ -138,6 +138,8 @@ defmodule Ash.Resource.Change do | :ok | {:error, term()} + @callback atomic?() :: boolean + @callback after_atomic(Ash.Changeset.t(), Keyword.t(), Ash.Resource.record(), context()) :: {:ok, Ash.Resource.record()} | {:error, term()} @@ -155,11 +157,17 @@ defmodule Ash.Resource.Change do 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 {:not_atomic, "#{inspect(__MODULE__)} does not implement `atomic/2`"} end - defoverridable init: 1, atomic: 3 + defoverridable init: 1, atomic: 3, atomic?: 0 end end end