Packages
ash
3.27.0
3.29.3
3.29.2
3.29.1
3.29.0
3.28.0
3.27.8
3.27.7
3.27.6
3.27.5
3.27.4
3.27.3
3.27.2
3.27.1
3.27.0
3.26.0
3.25.2
3.25.1
3.25.0
retired
3.24.7
3.24.6
3.24.5
3.24.4
3.24.3
3.24.2
3.24.1
3.24.0
3.23.1
3.23.0
3.22.2
3.22.1
3.22.0
3.21.3
3.21.2
3.21.1
3.21.0
3.20.0
3.19.3
3.19.2
3.19.1
3.19.0
3.18.0
3.17.1
3.17.0
3.16.0
3.15.0
3.14.1
3.14.0
retired
3.13.2
3.13.1
3.13.0
3.12.0
3.11.3
3.11.2
3.11.1
3.11.0
3.10.1
3.10.0
3.9.0
3.8.0
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
retired
3.6.3
retired
3.6.2
3.6.1
3.6.0
3.5.43
3.5.42
3.5.41
3.5.40
3.5.39
3.5.38
3.5.37
3.5.36
3.5.35
3.5.34
3.5.33
3.5.32
3.5.31
3.5.30
3.5.29
3.5.28
3.5.27
3.5.26
3.5.25
3.5.24
3.5.23
3.5.22
3.5.21
3.5.20
3.5.19
3.5.18
3.5.17
3.5.16
3.5.15
3.5.14
3.5.13
3.5.12
3.5.11
3.5.10
3.5.9
3.5.8
3.5.7
3.5.6
3.5.5
3.5.4
3.5.3
3.5.2
3.5.1
3.5.0
3.4.74
retired
3.4.73
3.4.72
3.4.71
3.4.70
3.4.69
3.4.68
3.4.67
3.4.66
3.4.65
3.4.64
3.4.63
3.4.62
3.4.61
3.4.60
3.4.59
3.4.58
3.4.57
3.4.56
3.4.55
3.4.54
3.4.53
3.4.52
3.4.51
3.4.50
3.4.49
3.4.48
3.4.47
3.4.46
3.4.45
3.4.44
3.4.43
3.4.42
3.4.41
3.4.40
3.4.39
3.4.38
3.4.37
3.4.36
3.4.35
3.4.34
3.4.33
3.4.32
3.4.31
3.4.30
3.4.29
3.4.28
3.4.27
3.4.26
3.4.25
3.4.24
3.4.23
3.4.22
3.4.21
3.4.20
3.4.19
3.4.18
3.4.17
3.4.16
3.4.15
3.4.14
3.4.13
3.4.12
3.4.11
3.4.10
3.4.9
3.4.8
3.4.7
3.4.6
3.4.5
3.4.4
3.4.3
3.4.2
3.4.1
3.4.0
3.3.3
3.3.2
3.3.1
3.3.0
3.2.6
3.2.5
3.2.4
3.2.3
3.2.2
3.2.1
3.2.0
3.1.8
3.1.7
3.1.6
3.1.5
3.1.4
3.1.3
3.1.2
3.1.1
3.1.0
3.0.16
3.0.15
3.0.14
3.0.13
3.0.12
3.0.11
3.0.10
3.0.9
3.0.8
3.0.7
3.0.6
3.0.5
3.0.4
3.0.3
3.0.2
3.0.1
3.0.0
3.0.0-rc.46
3.0.0-rc.45
3.0.0-rc.44
3.0.0-rc.43
3.0.0-rc.42
3.0.0-rc.41
3.0.0-rc.40
3.0.0-rc.39
3.0.0-rc.38
3.0.0-rc.37
3.0.0-rc.36
3.0.0-rc.35
3.0.0-rc.34
3.0.0-rc.33
3.0.0-rc.32
3.0.0-rc.31
3.0.0-rc.29
3.0.0-rc.28
3.0.0-rc.27
3.0.0-rc.26
3.0.0-rc.25
3.0.0-rc.24
3.0.0-rc.23
3.0.0-rc.22
3.0.0-rc.21
3.0.0-rc.20
3.0.0-rc.19
3.0.0-rc.18
3.0.0-rc.17
3.0.0-rc.16
3.0.0-rc.15
3.0.0-rc.14
3.0.0-rc.13
3.0.0-rc.12
3.0.0-rc.11
3.0.0-rc.10
3.0.0-rc.9
3.0.0-rc.8
3.0.0-rc.7
3.0.0-rc.6
3.0.0-rc.5
3.0.0-rc.4
3.0.0-rc.3
3.0.0-rc.1
3.0.0-rc.0
2.21.15
2.21.14
2.21.13
2.21.12
2.21.11
2.21.10
2.21.9
2.21.8
2.21.7
2.21.6
2.21.5
2.21.4
2.21.3
2.21.2
2.21.1
2.21.0
2.20.3
2.20.2
2.20.1
2.20.0
2.19.14
2.19.13
2.19.12
2.19.11
2.19.10
2.19.9
2.19.8
2.19.7
2.19.6
2.19.5
2.19.4
2.19.3
retired
2.19.2
retired
2.19.1
retired
2.19.0
retired
2.18.2
2.18.1
2.18.0
2.17.24
2.17.23
2.17.22
2.17.21
2.17.20
2.17.19
2.17.18
2.17.17
2.17.16
2.17.15
2.17.14
2.17.13
2.17.12
2.17.11
2.17.10
2.17.9
2.17.8
2.17.7
2.17.6
2.17.5
2.17.4
2.17.3
2.17.2
2.17.1
2.17.0
2.16.1
2.16.0
2.15.20
2.15.19
2.15.18
2.15.17
2.15.16
2.15.15
2.15.14
2.15.13
2.15.12
2.15.11
2.15.10
2.15.9
2.15.8
2.15.7
2.15.6
2.15.5
2.15.4
2.15.2
2.15.1
2.15.0
2.14.21
2.14.20
2.14.19
2.14.18
2.14.17
2.14.16
2.14.15
2.14.14
2.14.13
2.14.12
2.14.11
2.14.10
2.14.9
2.14.8
2.14.7
2.14.6
2.14.5
2.14.4
2.14.3
2.14.2
2.14.1
2.14.0
2.13.4
retired
2.13.3
2.13.2
2.13.1
2.13.0
2.12.1
2.12.0
2.11.11
2.11.10
2.11.9
2.11.8
2.11.7
2.11.6
2.11.5
2.11.4
2.11.3
2.11.2
2.11.1
2.11.0
2.11.0-rc.3
2.11.0-rc.2
2.11.0-rc.1
2.11.0-rc.0
2.10.2
2.10.1
2.10.0
2.9.29
2.9.28
2.9.27
2.9.26
2.9.25
2.9.24
2.9.23
2.9.22
2.9.21
2.9.20
2.9.19
2.9.18
2.9.17
2.9.16
2.9.15
2.9.14
2.9.13
2.9.12
2.9.11
2.9.10
2.9.9
2.9.8
2.9.7
2.9.6
2.9.5
2.9.4
2.9.3
2.9.2
2.9.1
2.9.0
2.8.1
2.8.0
2.7.1
2.7.0
2.6.31
2.6.30
2.6.29
2.6.28
2.6.27
2.6.26
2.6.25
2.6.24
2.6.23
2.6.22
2.6.21
2.6.20
2.6.19
2.6.18
2.6.17
2.6.16
2.6.15
2.6.14
2.6.13
2.6.11
2.6.10
2.6.9
2.6.8
2.6.7
2.6.6
2.6.5
2.6.4
2.6.3
2.6.2
2.6.1
2.6.0
2.5.16
2.5.15
2.5.14
2.5.13
2.5.12
2.5.11
2.5.10
2.5.9
2.5.8
2.5.7
2.5.6
2.5.5
2.5.4
2.5.3
2.5.2
2.5.1
2.5.0
2.5.0-rc.6
2.5.0-rc.5
2.5.0-rc.4
2.5.0-rc.3
2.5.0-rc.2
2.5.0-rc.1
2.5.0-rc.0
2.4.30
2.4.29
2.4.28
2.4.27
2.4.26
2.4.25
2.4.24
2.4.23
2.4.22
2.4.21
2.4.20
2.4.19
2.4.18
2.4.17
2.4.16
2.4.15
2.4.14
2.4.13
2.4.12
2.4.11
2.4.10
2.4.9
2.4.8
2.4.7
2.4.6
2.4.5
2.4.4
2.4.3
2.4.2
2.4.1
2.4.0
2.3.0
2.2.0
2.1.0
2.0.0
2.0.0-rc.15
2.0.0-rc.14
2.0.0-rc.13
2.0.0-rc.12
2.0.0-rc.11
2.0.0-rc.10
2.0.0-rc.9
2.0.0-rc.8
2.0.0-rc.7
2.0.0-rc.6
2.0.0-rc.5
2.0.0-rc.4
2.0.0-rc.3
2.0.0-rc.2
2.0.0-rc.1
2.0.0-rc.0
2.0.0-pre.8
2.0.0-pre.7
2.0.0-pre.6
2.0.0-pre.5
2.0.0-pre.4
2.0.0-pre.3
2.0.0-pre.2
2.0.0-pre.1
2.0.0-pre.0
1.53.3
1.53.2
1.53.0
1.52.0-rc.22
1.52.0-rc.21
1.52.0-rc.20
1.52.0-rc.19
1.52.0-rc.18
1.52.0-rc.17
1.52.0-rc.16
1.52.0-rc.15
1.52.0-rc.14
1.52.0-rc.13
1.52.0-rc.12
1.52.0-rc.11
1.52.0-rc.10
1.52.0-rc.9
1.52.0-rc.8
1.52.0-rc.7
1.52.0-rc.6
1.52.0-rc.5
1.52.0-rc.4
1.52.0-rc.3
1.52.0-rc.2
1.52.0-rc.1
1.52.0-rc.0
1.51.2
1.51.1
retired
1.51.0
1.50.21
1.50.20
1.50.19
1.50.18
1.50.17
1.50.16
1.50.15
1.50.14
1.50.13
1.50.12
1.50.11
1.50.10
1.50.9
1.50.8
1.50.7
1.50.6
1.50.5
1.50.4
1.50.3
1.50.2
1.50.1
1.50.0
1.49.0
1.48.0-rc.30
1.48.0-rc.29
1.48.0-rc.28
1.48.0-rc.27
1.48.0-rc.26
1.48.0-rc.25
1.48.0-rc.24
1.48.0-rc.23
1.48.0-rc.22
1.48.0-rc.21
1.48.0-rc.20
1.48.0-rc.19
1.48.0-rc.18
1.48.0-rc.17
1.48.0-rc.16
1.48.0-rc.15
1.48.0-rc.14
1.48.0-rc.13
1.48.0-rc.12
1.48.0-rc.11
1.48.0-rc.10
1.48.0-rc.9
1.48.0-rc.8
1.48.0-rc.7
1.48.0-rc.6
1.48.0-rc.5
1.48.0-rc.4
1.48.0-rc.3
1.48.0-rc.2
1.48.0-rc.1
1.48.0-rc.0
1.47.12
1.47.11
1.47.10
1.47.9
1.47.8
1.47.7
1.47.6
1.47.5
1.47.4
1.47.3
1.47.2
1.47.1
1.47.0
1.46.13
1.46.12
1.46.11
1.46.10
1.46.9
1.46.8
1.46.7
1.46.6
1.46.5
1.46.4
1.46.3
1.46.2
1.46.1
1.46.0
1.45.0-rc9
1.45.0-rc8
1.45.0-rc7
1.45.0-rc6
1.45.0-rc5
1.45.0-rc4
1.45.0-rc3
1.45.0-rc20
1.45.0-rc2
1.45.0-rc19
1.45.0-rc18
1.45.0-rc17
1.45.0-rc16
1.45.0-rc15
1.45.0-rc14
1.45.0-rc13
1.45.0-rc12
1.45.0-rc11
1.45.0-rc10
1.45.0-rc1
1.45.0-rc0
1.44.13
1.44.12
1.44.11
1.44.10
1.44.9
1.44.8
1.44.7
1.44.6
1.44.5
1.44.4
1.44.3
1.44.2
1.44.1
1.44.0
1.43.12
1.43.11
1.43.10
1.43.9
1.43.8
1.43.7
1.43.6
1.43.5
1.43.4
1.43.3
1.43.2
1.43.1
1.43.0
1.42.0
1.41.12
1.41.11
1.41.10
1.41.9
1.41.8
1.41.7
1.41.6
1.41.5
1.41.4
1.41.3
1.41.2
1.41.1
1.41.0
1.40.0
1.39.7
1.39.6
1.39.5
1.39.4
1.39.3
1.39.2
1.39.1
1.39.0
1.38.0
1.37.2
1.37.1
1.37.0
1.36.22
1.36.21
1.36.19
1.36.18
1.36.17
1.36.16
1.36.15
1.36.14
1.36.13
1.36.12
1.36.11
1.36.10
1.36.9
1.36.8
1.36.7
1.36.6
1.36.5
1.36.4
1.36.3
1.36.2
1.36.0
1.35.1
1.35.0
1.34.9
1.34.8
1.34.7
1.34.6
1.34.5
1.34.4
1.34.3
1.34.2
1.34.1
1.34.0
1.33.0
1.32.2
1.32.1
1.32.0
1.31.1
1.31.0
1.30.2
1.30.1
1.29.0-rc1
1.29.0-rc0
1.28.1
1.28.0
1.27.1
1.27.0
1.26.13
1.26.12
1.26.11
1.26.10
1.26.9
1.26.8
1.26.7
1.26.6
1.26.5
1.26.4
1.26.2
1.26.1
1.26.0
1.25.8
1.25.7
1.25.6
1.25.5
1.25.4
1.25.3
1.25.2
1.25.1
1.25.0
1.24.2
1.24.1
1.24.0
1.23.3
1.23.2
1.23.1
1.23.0
1.22.1
1.22.0
1.20.1
1.20.0
1.19.1
1.19.0
1.18.1
1.18.0
1.17.1
1.17.0
1.16.2
1.15.1
1.15.0
1.14.0
1.13.4
1.13.3
1.13.2
1.13.1
1.13.0
1.12.0
1.11.1
1.11.0
1.10.0
1.9.0
1.8.0
1.7.0
1.6.8
1.6.7
1.6.6
1.6.5
1.6.4
1.6.3
1.6.2
1.6.1
1.6.0
1.5.1
1.5.0
1.4.1
1.4.0
1.3.1
1.3.0
1.2.1
1.2.0
1.1.3
1.1.2
1.1.0
1.0.3
1.0.2
1.0.1
1.0.0
0.13.1
0.13.0
0.12.0
0.10.0
0.9.1
0.9.0
0.8.0
0.7.0
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.0
0.3.0
0.2.0
0.1.9
0.1.8
0.1.3
0.1.1
0.1.0
A declarative, extensible framework for building Elixir applications.
Security advisory:
This version has known vulnerabilities.
View advisories
Current section
Files
Jump to
Current section
Files
lib/ash/actions/update/bulk.ex
# SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors>## SPDX-License-Identifier: MITdefmodule Ash.Actions.Update.Bulk do @moduledoc false require Ash.Query require Ash.Tracer @spec run(Ash.Domain.t(), Enumerable.t() | Ash.Query.t(), atom(), input :: map, Keyword.t()) :: Ash.BulkResult.t() def run(domain, resource, action, input, opts, not_atomic_reason \\ nil) def run(domain, resource, action, input, opts, not_atomic_reason) when is_atom(resource) do run(domain, Ash.Query.new(resource), action, input, opts, not_atomic_reason) end def run(domain, %Ash.Query{} = query, action, input, opts, not_atomic_reason) do action_name = if is_atom(action), do: action, else: action.name span_type = cond do opts[:atomic_changeset] -> :action is_map(action) and Map.get(action, :type) == :destroy -> :bulk_destroy true -> :bulk_update end Ash.Tracer.span span_type, fn -> Ash.Domain.Info.span_name( domain, query.resource, action_name ) end, opts[:tracer] do Ash.Tracer.set_metadata(opts[:tracer], span_type, fn -> %{ domain: domain, resource: query.resource, resource_short_name: Ash.Resource.Info.short_name(query.resource), actor: opts[:actor], tenant: opts[:tenant], action: action_name, authorize?: opts[:authorize?] } end) Ash.Tracer.telemetry_span [:ash, Ash.Domain.Info.short_name(domain), span_type], fn -> %{ resource_short_name: Ash.Resource.Info.short_name(query.resource), action: action_name } end do opts = set_strategy(opts, query.resource) opts = if opts[:return_notifications?] do Keyword.put(opts, :notify?, true) else opts end {query, opts} = if query.__validated_for_action__ do {query, opts} else {query, opts} = Ash.Actions.Helpers.set_context_and_get_opts(domain, query, opts) query = Ash.Query.for_read( query, get_read_action(query.resource, action, opts).name, %{}, actor: opts[:actor], tenant: opts[:tenant], context: %{query_for: :bulk_update} ) {query, opts} end query = %{query | domain: domain} fully_atomic_changeset = cond do not_atomic_reason -> {:not_atomic, not_atomic_reason} :atomic not in opts[:strategy] -> {:not_atomic, "Not in requested strategies"} query.action.manual -> {:not_atomic, "Manual read actions cannot be updated atomically"} !Enum.empty?(query.before_action) -> {:not_atomic, "cannot atomically update a query if it has `before_action` hooks"} !Enum.empty?(query.after_action) -> {:not_atomic, "cannot atomically update a query if it has `after_action` hooks"} changeset = opts[:atomic_changeset] -> changeset Ash.DataLayer.data_layer_can?(query.resource, :update_query) -> private_context = Map.new(Keyword.take(opts, [:actor, :tenant, :authorize])) opts = Keyword.update( opts, :context, %{private: private_context}, fn context -> Map.update( context, :private, private_context, &Map.merge(&1, private_context) ) end ) Ash.Changeset.fully_atomic_changeset(query.resource, action, input, opts) true -> {:not_atomic, "data layer does not support updating a query"} end case fully_atomic_changeset do {:not_atomic, reason} -> case Ash.Actions.Read.Stream.stream_strategy( query, nil, opts[:allow_stream_with] || :keyset ) do {:error, %Ash.Error.Invalid.NonStreamableAction{} = exception} -> %Ash.BulkResult{ status: :error, error_count: 1, errors: [ Ash.Error.to_error_class( Ash.Error.Invalid.NoMatchingBulkStrategy.exception( resource: query.resource, action: action, requested_strategies: opts[:strategy], not_atomic_reason: reason, not_stream_reason: "could not stream the query", footer: "Non stream reason:\n\n" <> Exception.message(exception) ) ) ] } _ -> query = Ash.Query.do_filter(query, opts[:filter]) read_opts = opts |> then(fn read_opts -> if opts[:stream_batch_size] do Keyword.put(read_opts, :batch_size, opts[:stream_batch_size]) else Keyword.delete(read_opts, :batch_size) end end) |> Keyword.put(:authorize?, opts[:authorize?] && opts[:authorize_query?]) |> Keyword.update( :context, %{query_for: :bulk_update}, &Map.put(&1, :query_for, :bulk_update) ) |> Keyword.put(:domain, domain) |> Keyword.delete(:load) if query.limit && query.limit < (opts[:batch_size] || 100) do read_opts = Keyword.take(read_opts, Keyword.keys(Ash.read_opts())) case Ash.Actions.Read.unpaginated_read(query, query.action, read_opts) do {:ok, results} -> run( domain, results, action, input, Keyword.merge( opts, [ resource: query.resource, input_was_stream?: false, context: opts[:atomic_changeset] && Map.delete(opts[:atomic_changeset].context, :private) ], fn :context, v1, v2 -> Ash.Helpers.deep_merge_maps(v1, v2) _k, _v1, v2 -> v2 end ), reason ) {:error, error} -> %Ash.BulkResult{ status: :error, error_count: 1, errors: [Ash.Error.to_error_class(error)] } end else read_opts = Keyword.put( Keyword.take(read_opts, Ash.stream_opt_keys()), :batch_size, opts[:batch_size] || 100 ) # We need to figure out a way to capture errors raised by the stream when picking items off somehow # for now, we only go this route if there are potentially more records in the result set than # in the batch size, to solve this problem for atomic upgrades. # we can likely make the stream throw something instead of raising something # like `{:stream_error, ...}` if a specific option is passed in. # once we figure this out, we may be able to remove the branch above run( domain, Ash.stream!( query, read_opts ), action, input, Keyword.merge( opts, [ resource: query.resource, input_was_stream?: false, query_sort: query.sort, context: opts[:atomic_changeset] && Map.delete(opts[:atomic_changeset].context, :private) ], fn :context, v1, v2 -> Ash.Helpers.deep_merge_maps(v1, v2) _k, _v1, v2 -> v2 end ), reason ) end end %Ash.Changeset{valid?: false, errors: errors} = changeset -> # Run after_transaction hooks for failed changesets case Ash.Changeset.run_after_transactions( {:error, Ash.Error.to_error_class(errors, changeset: changeset)}, changeset ) do {:ok, result} -> %Ash.BulkResult{ status: :success, records: [result] } {:error, error} -> %Ash.BulkResult{ status: :error, error_count: 1, errors: [error] } end atomic_changeset -> atomic_changeset = Ash.Changeset.filter(atomic_changeset, opts[:filter]) {atomic_changeset, opts} = Ash.Actions.Helpers.set_context_and_get_opts(domain, atomic_changeset, opts) atomic_changeset = Ash.Actions.Helpers.apply_opts_load(atomic_changeset, opts) atomic_changeset = if opts[:select] do Ash.Changeset.select(atomic_changeset, opts[:select]) else atomic_changeset end atomic_changeset = %{atomic_changeset | domain: domain} notify? = !Process.put(:ash_started_transaction?, true) try do context = struct( Ash.Resource.Change.Context, %{ bulk?: true, source_context: atomic_changeset.context, actor: opts[:actor], tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] } ) has_after_batch_hooks? = Enum.any?( atomic_changeset.action.changes ++ Ash.Resource.Info.changes( atomic_changeset.resource, atomic_changeset.action_type ), fn %{change: {module, change_opts}} -> module.has_after_batch?() && Ash.Resource.Change.batch_callbacks?(module, query, change_opts, context) _ -> false end ) {context_key, metadata_key} = case atomic_changeset.action.type do :update -> {:bulk_update, :bulk_update_index} :destroy -> {:bulk_destroy, :bulk_destroy_index} end prefer_transaction? = has_after_batch_hooks? || !Enum.empty?(atomic_changeset.after_action) || Ash.DataLayer.prefer_transaction_for_atomic_updates?(atomic_changeset.resource) result = if prefer_transaction? && Keyword.get(opts, :transaction, true) do Ash.DataLayer.in_transaction?(atomic_changeset.resource) Ash.DataLayer.transaction( atomic_changeset.resource, fn -> do_atomic_update( query, atomic_changeset, has_after_batch_hooks?, input, opts ) end, opts[:timeout], %{ type: context_key, metadata: %{ resource: query.resource, action: atomic_changeset.action.name, actor: opts[:actor] }, data_layer_context: opts[:data_layer_context] || %{} }, rollback_on_error?: false ) else {:ok, do_atomic_update(query, atomic_changeset, has_after_batch_hooks?, input, opts)} end # Normalize result - either BulkResult (from fallback) or tagged tuples result |> case do {:ok, %Ash.BulkResult{} = bulk_result} -> bulk_result {:ok, {:ok, tagged_results, notifications}} -> {tagged_results, notifications} {:ok, {:error, error}} -> {[{:error, error, atomic_changeset}], []} {:error, error} -> {[{:error, error, atomic_changeset}], []} end |> case do %Ash.BulkResult{} = bulk_result -> bulk_result {tagged_results, notifications} -> ref = make_ref() try do processed = tagged_results |> process_results( opts, ref, context_key, metadata_key, atomic_changeset.resource, atomic_changeset.domain, atomic_changeset ) |> Enum.to_list() any_success? = Process.get({:any_success?, ref}, false) # Merge notifications from do_atomic_update with those from process_results all_notifications = notifications ++ (Process.delete({:bulk_notifications, ref}) || []) Ash.Actions.BulkManualActionHelpers.build_bulk_result( processed, any_success?, all_notifications, opts ) catch {:error, error} -> status = if Process.get({:any_success?, ref}) do :partial_success else :error end all_notifications = notifications ++ (Process.delete({:bulk_notifications, ref}) || []) result = %Ash.BulkResult{ status: status, records: [], notifications: all_notifications } {error_count, errors} = Ash.Actions.Helpers.Bulk.errors(result, error, opts) %{result | errors: errors, error_count: error_count} end end |> handle_atomic_notifications(atomic_changeset.resource, action, notify?, opts) after if notify? do Process.delete(:ash_started_transaction?) end end end end end rescue e -> action_name = case action do %{name: name} -> name name -> name end reraise Ash.Error.to_error_class(e, stacktrace: __STACKTRACE__, bread_crumbs: [ "Exception raised in bulk update: #{inspect(opts[:resource])}.#{action_name}" ] ), __STACKTRACE__ end def run(domain, stream, action, input, opts, not_atomic_reason) do resource = opts[:resource] opts = set_strategy(opts, resource, Keyword.get(opts, :input_was_stream?, true)) opts = if opts[:return_notifications?] do Keyword.put(opts, :notify?, true) else opts end not_atomic_reason = not_atomic_reason || if :atomic_batches not in opts[:strategy], do: "Cannot perform atomic destroys on an enumerable of inputs" action = case action do nil -> Ash.Resource.Info.primary_action!(resource, :update) name when is_atom(name) -> action = Ash.Resource.Info.action(resource, action) if !action do raise Ash.Error.Invalid.NoSuchAction, resource: resource, action: name, type: :update end action action -> action end if opts[:transaction] == :all && opts[:return_stream?] do raise ArgumentError, "Cannot specify `transaction: :all` and `return_stream?: true` together" end if opts[:return_stream?] && opts[:sorted?] do raise ArgumentError, "Cannot specify `sorted?: true` and `return_stream?: true` together" end span_type = case action.type do :destroy -> :bulk_destroy _ -> :bulk_update end Ash.Tracer.span span_type, fn -> Ash.Domain.Info.span_name( domain, resource, action.name ) end, opts[:tracer] do Ash.Tracer.set_metadata(opts[:tracer], span_type, fn -> %{ domain: domain, resource: resource, resource_short_name: Ash.Resource.Info.short_name(resource), actor: opts[:actor], tenant: opts[:tenant], action: action.name, authorize?: opts[:authorize?] } end) Ash.Tracer.telemetry_span [:ash, Ash.Domain.Info.short_name(domain), span_type], fn -> %{ resource_short_name: Ash.Resource.Info.short_name(resource), action: action.name } end do {context_key, metadata_key, ref_metadata_key} = case action.type do :destroy -> {:bulk_destroy, :bulk_destroy_index, :bulk_action_ref} _ -> {:bulk_update, :bulk_update_index, :bulk_action_ref} end if opts[:transaction] == :all && Ash.DataLayer.data_layer_can?(resource, :transact) do notify? = !Process.put(:ash_started_transaction?, true) try do Ash.DataLayer.transaction( List.wrap(resource) ++ action.touches_resources, fn -> do_run( domain, stream, action, input, Keyword.merge(opts, notify?: opts[:notify?], notification_metadata: opts[:notification_metadata], return_notifications?: opts[:notify?] ), metadata_key, ref_metadata_key, context_key, not_atomic_reason ) end, opts[:timeout], %{ type: context_key, metadata: %{ resource: resource, action: action.name, actor: opts[:actor] }, data_layer_context: opts[:data_layer_context] || %{} }, rollback_on_error?: false ) |> case do {:ok, bulk_result} -> bulk_result = if notify? do notifications = if opts[:return_notifications?] do bulk_result.notifications ++ List.wrap(Process.delete(:ash_notifications)) else if opts[:notify?] do remaining_notifications = Ash.Notifier.notify( bulk_result.notifications ++ List.wrap(Process.delete(:ash_notifications)) ) Ash.Actions.Helpers.warn_missed!(resource, action, %{ resource_notifications: remaining_notifications }) else [] end end %{ bulk_result | notifications: notifications } else Process.put( :ash_notifications, List.wrap(Process.get(:ash_notifications)) ++ List.wrap(bulk_result.notifications) ) bulk_result end handle_bulk_result(bulk_result, metadata_key, opts) {:error, error} -> handle_bulk_result( %Ash.BulkResult{errors: [error], status: :error}, metadata_key, opts ) end after if notify? do Process.delete(:ash_started_transaction?) end end else domain |> do_run( stream, action, input, opts, metadata_key, ref_metadata_key, context_key, not_atomic_reason ) |> handle_bulk_result(metadata_key, opts) end end end rescue e -> action_name = case action do %{name: name} -> name name -> name end reraise Ash.Error.to_error_class(e, stacktrace: __STACKTRACE__, bread_crumbs: [ "Exception raised in bulk update: #{inspect(opts[:resource])}.#{action_name}" ] ), __STACKTRACE__ end defp do_atomic_update(query, atomic_changeset, has_after_batch_hooks?, input, opts) do context = struct( Ash.Resource.Change.Context, %{ bulk?: true, source_context: atomic_changeset.context, actor: opts[:actor], tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] } ) atomic_changeset = if atomic_changeset.context[:data_layer][:use_atomic_update_data?] do atomic_changeset else %{ atomic_changeset | data: %Ash.Changeset.OriginalDataNotAvailable{reason: :atomic_query_update} } end {all_changes, conditional_after_batch_hooks, calculations} = hooks_and_calcs_for_update_query(atomic_changeset, context, query, opts) action_select = if Ash.DataLayer.data_layer_can?(atomic_changeset.resource, :action_select) do Enum.uniq( Enum.concat( Ash.Resource.Info.action_select(atomic_changeset.resource, atomic_changeset.action), List.wrap( opts[:select] || MapSet.to_list( Ash.Resource.Info.selected_by_default_attribute_names(atomic_changeset.resource) ) ) ) ) else MapSet.to_list(Ash.Resource.Info.attribute_names(atomic_changeset.resource)) end return_records? = has_after_batch_hooks? || opts[:notify?] || opts[:return_records?] || !Enum.empty?(atomic_changeset.after_action) || !Enum.empty?(atomic_changeset.after_transaction) # Update opts with calculated return_records? so handle_bulk_result uses it opts = Keyword.put(opts, :return_records?, return_records?) update_query_opts = opts |> Keyword.take([:tenant]) |> Map.new() |> Map.put(:return_records?, return_records?) |> Map.put(:calculations, calculations) |> Map.put( :action_select, action_select ) with :ok <- Ash.Actions.Helpers.Bulk.validate_multitenancy( atomic_changeset.resource, atomic_changeset.action, opts ), {:ok, query} <- authorize_bulk_query(query, atomic_changeset, opts), {:ok, atomic_changeset, query} <- authorize_atomic_changeset(query, atomic_changeset, opts), {query, atomic_changeset} <- add_changeset_filters(query, atomic_changeset), query = Ash.Actions.Read.add_calc_context_to_query( query, opts[:actor], opts[:authorize?], query.tenant, opts[:tracer], query.domain, expand?: true, source_context: query.context ), %Ash.Changeset{valid?: true} = atomic_changeset <- Ash.Changeset.handle_allow_nil_atomics(atomic_changeset, opts[:actor]), atomic_changeset <- sort_atomic_changes(atomic_changeset), {:ok, query} <- Ash.Actions.Read.handle_multitenancy(query), {:ok, data_layer_query} <- Ash.Query.data_layer_query(query) do update_query_result = if atomic_changeset.context.changed? do Ash.DataLayer.update_query( data_layer_query, atomic_changeset, update_query_opts ) else atomic_validation_expr = with [field | _] <- Ash.Resource.Info.primary_key(atomic_changeset.resource), {:ok, v} <- Keyword.fetch(atomic_changeset.atomics, field) do v else _ -> nil end if is_nil(atomic_validation_expr) && is_nil(query.filter) && !update_query_opts[:return_records?] do :ok else Ash.DataLayer.update_query( data_layer_query, atomic_changeset, update_query_opts ) end end case update_query_result do :ok -> {:ok, [], []} {:ok, results} -> results = Ash.Actions.Helpers.select(results, %{ resource: query.resource, select: action_select }) results = case results do [result] -> if atomic_changeset.context[:data_layer][:use_atomic_update_data?] do Map.put( result, :__metadata__, Map.merge(atomic_changeset.data.__metadata__, result.__metadata__) ) else [result] end other -> other end results = List.wrap(results) {results, notifications} = if has_after_batch_hooks? do run_atomic_after_batch_hooks( results, atomic_changeset, all_changes, conditional_after_batch_hooks, context ) else {results, []} end {results, errors, notifications} = if Enum.empty?(atomic_changeset.after_action) do {results, [], notifications} else Enum.reduce( results, {[], [], notifications}, fn result, {results, errors, notifications} -> # we can't actually know if the changeset changed or not when doing atomics # so we just have to set it to statically true here. case Ash.Changeset.run_after_actions(result, atomic_changeset, []) do {:error, error} -> if opts[:transaction] && opts[:rollback_on_error?] do if Ash.DataLayer.in_transaction?(atomic_changeset.resource) do Ash.DataLayer.rollback( atomic_changeset.resource, error ) end end {results, errors ++ List.wrap(error), notifications} {:ok, result, _changeset, %{notifications: more_new_notifications}} -> {[result | results], errors, notifications ++ more_new_notifications} end end ) |> then(fn {results, errors, notifications} -> {Enum.reverse(results), errors, notifications} end) end # Return tagged tuples for process_results to handle after_transaction hooks. # This ensures errors are counted exactly once in build_bulk_result. tagged_results = Enum.map(results, &{:ok, &1, atomic_changeset}) ++ Enum.map(errors, &{:error, &1, atomic_changeset}) {:ok, tagged_results, notifications} {:error, :no_rollback, %Ash.Error.Forbidden.Placeholder{ authorizer: authorizer }} -> error = Ash.Authorizer.exception( authorizer, :forbidden, query.context[:private][:authorizer_state][authorizer] ) {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} {:error, %Ash.Error.Forbidden.Placeholder{ authorizer: authorizer }} -> error = Ash.Authorizer.exception( authorizer, :forbidden, query.context[:private][:authorizer_state][authorizer] ) if Ash.DataLayer.in_transaction?(atomic_changeset.resource) do Ash.DataLayer.rollback( atomic_changeset.resource, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] ) ) else {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} end {:error, :no_rollback, error} -> {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} {:error, error} -> if Ash.DataLayer.in_transaction?(atomic_changeset.resource) do Ash.DataLayer.rollback(atomic_changeset.resource, Ash.Error.to_error_class(error)) else {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} end end else %Ash.Changeset{valid?: false, errors: error} = _changeset -> if Ash.DataLayer.in_transaction?(atomic_changeset.resource) do Ash.DataLayer.rollback(atomic_changeset.resource, Ash.Error.to_error_class(error)) else {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} end {:error, %Ash.Error.Forbidden.InitialDataRequired{} = e} -> case Ash.Actions.Read.Stream.stream_strategy( query, nil, opts[:allow_stream_with] || :keyset ) do {:error, %Ash.Error.Invalid.NonStreamableAction{} = exception} -> {:error, Ash.Error.to_error_class( Ash.Error.Invalid.NoMatchingBulkStrategy.exception( resource: atomic_changeset.resource, action: atomic_changeset.action.name, requested_strategies: opts[:strategy], not_stream_reason: "could not stream the query, see message below for more", not_atomic_batches_reason: "authorization requires initial data", not_atomic_reason: "authorization requires initial data", footer: "Non stream reason:\n\n" <> Exception.message(exception) ) )} _strategy -> input_stream = if atomic_changeset.context[:data_layer][:use_atomic_update_data?] do [atomic_changeset.data] else query end run( atomic_changeset.domain, input_stream, atomic_changeset.action, input, Keyword.merge(opts, authorize_query?: false, input_was_stream?: false ), e.source ) end {:error, error} -> {:error, Ash.Error.to_error_class(error, bread_crumbs: [ "Returned from bulk query update: #{inspect(atomic_changeset.resource)}.#{atomic_changeset.action.name}" ] )} end end defp handle_atomic_notifications(bulk_result, resource, action, notify?, opts) do if opts[:return_notifications?] do bulk_result else if notify? do notifications = List.wrap(Process.delete(:ash_notifications)) ++ List.wrap(bulk_result.notifications) if opts[:notify?] do remaining = Ash.Notifier.notify(notifications) Ash.Actions.Helpers.warn_missed!(resource, action, %{ resource_notifications: remaining }) %{bulk_result | notifications: remaining} else %{bulk_result | notifications: []} end else process_notifications = List.wrap(Process.get(:ash_notifications, [])) Process.put( :ash_notifications, process_notifications ++ List.wrap(bulk_result.notifications) ) %{bulk_result | notifications: []} end end end defp sort_atomic_changes(atomic_changeset) do primary_key = Ash.Resource.Info.primary_key(atomic_changeset.resource) Map.update!(atomic_changeset, :atomics, fn atomics -> Enum.sort_by(atomics, fn {key, _} -> key not in primary_key end) end) end @doc false def run_atomic_after_batch_hooks( results, atomic_changeset, all_changes, conditional_after_batch_hooks, context ) do results = results |> Stream.with_index() |> Enum.map(fn {result, index} -> {atomic_changeset, Ash.Resource.set_metadata(result, %{atomic_index: index})} end) {records_with_changesets, notifications} = conditional_after_batch_hooks |> Enum.reduce( {results, []}, fn {where, indices}, {records, additional_notifications} -> {to_apply, to_not_apply} = case where do true -> {records, []} false -> {[], records} where -> Enum.split_with(records, fn {_, record} -> record.calculations[{:run_after_batch, where}] end) end if Enum.empty?(to_apply) do {records, additional_notifications} else {applied, notifications} = Enum.reduce( indices, {to_apply, additional_notifications}, fn index, {records, additional_notifications} -> change = Enum.at(all_changes, index) {module, opts} = change.change case Ash.Resource.Change.after_batch(module, records, opts, context) do :ok -> {to_apply, additional_notifications} instructions -> Enum.reduce( instructions, {[], additional_notifications}, fn {:ok, record}, {records, additional_notifications} -> {[{atomic_changeset, record} | records], additional_notifications} {:error, error}, _ -> raise Ash.Error.to_ash_error(error) %Ash.Notifier.Notification{} = notification, {records, additional_notifications} -> {records, [notification | additional_notifications]} end ) end end ) {applied ++ to_not_apply, notifications ++ additional_notifications} end end ) final_results = records_with_changesets # would be nice to have `Enum.map_sort_by` |> Stream.map(&elem(&1, 1)) |> Enum.sort_by(& &1.__metadata__.atomic_index) {final_results, notifications} end @doc false def hooks_and_calcs_for_update_query(atomic_changeset, context, query, opts) do all_changes = atomic_changeset.action.changes |> Enum.concat( Ash.Resource.Info.changes(atomic_changeset.resource, atomic_changeset.action_type) ) conditional_after_batch_hooks = all_changes |> Stream.with_index() |> Enum.filter(fn {%{change: {module, change_opts}}, _index} -> module.has_after_batch?() && Ash.Resource.Change.batch_callbacks?(module, query, change_opts, context) _ -> false end) |> Enum.reduce(%{}, fn {%{where: where}, index}, acc -> {:atomic, condition} = Ash.Changeset.atomic_condition(where, atomic_changeset, context) Map.update(acc, condition, [index], &[index | &1]) end) calculations = Enum.flat_map(conditional_after_batch_hooks, fn {static, _indices} when static in [true, false] -> [] {where, _indices} -> {:ok, calculation} = Ash.Query.Calculation.new( {:run_after_batch, where}, Ash.Resource.Calculation.Expression, [expression: where], :boolean, [], arguments: atomic_changeset.arguments, source_context: atomic_changeset.context ) calculation = Ash.Actions.Read.add_calc_context( calculation, opts[:actor], opts[:authorize?], opts[:tenant], opts[:tracer], atomic_changeset.domain, atomic_changeset.resource, source_context: atomic_changeset.context ) [{calculation, where}] end) {all_changes, conditional_after_batch_hooks, calculations} end defp set_strategy(opts, resource, inputs_is_enumerable? \\ false) do opts = if Ash.DataLayer.data_layer_can?(resource, :update_query) do if Ash.DataLayer.data_layer_can?(resource, :expr_error) do opts else Keyword.put_new(opts, :strategy, [:stream, :atomic_batches, :atomic]) end else Keyword.put(opts, :strategy, [:stream]) end if inputs_is_enumerable? && :atomic in List.wrap(opts[:strategy]) do Keyword.put(opts, :strategy, Enum.uniq([:atomic_batches | List.wrap(opts[:strategy])])) else opts end end defp do_run( domain, stream, action, input, opts, metadata_key, ref_metadata_key, context_key, not_atomic_reason ) do resource = opts[:resource] opts = Ash.Actions.Helpers.set_opts(opts, domain) read_action = get_read_action(resource, action, opts) {context_cs, opts} = Ash.Actions.Helpers.set_context_and_get_opts(domain, Ash.Changeset.new(resource), opts) case Ash.Actions.Helpers.Bulk.validate_multitenancy(resource, action, opts) do {:error, error} -> %Ash.BulkResult{ status: :error, error_count: 1, errors: [ Ash.Error.to_error_class(error) ] } :ok -> fully_atomic_changeset = cond do :atomic_batches not in opts[:strategy] -> {:not_atomic, "Not in requested strategies"} Enum.empty?(Ash.Resource.Info.primary_key(resource)) -> {:not_atomic, "cannot atomically update a stream without a primary key"} !read_action -> {:not_atomic, "cannot atomically update a stream without a primary read action"} read_action.manual -> {:not_atomic, "Manual read actions cannot be updated atomically"} Ash.DataLayer.data_layer_can?(resource, :update_query) -> opts = Keyword.update( opts, :context, %{private: context_cs.context.private}, &Map.put(&1 || %{}, :private, context_cs.context.private) ) Ash.Changeset.fully_atomic_changeset(resource, action, input, opts) true -> {:not_atomic, "data layer does not support updating a query"} end case fully_atomic_changeset do %Ash.Changeset{} = atomic_changeset -> query = resource |> Ash.Query.new() |> Map.put(:action, read_action) case Ash.Actions.Read.Stream.stream_strategy( query, nil, opts[:allow_stream_with] || :keyset ) do {:error, exception} -> if :stream in opts[:strategy] do do_stream_batches( domain, stream, action, input, opts, metadata_key, ref_metadata_key, context_key ) else %Ash.BulkResult{ status: :error, error_count: 1, errors: [ Ash.Error.to_error_class( Ash.Error.Invalid.NoMatchingBulkStrategy.exception( resource: resource, action: action.name, requested_strategies: opts[:strategy], not_atomic_batches_reason: "could not stream the query", not_atomic_reason: not_atomic_reason, not_stream_reason: "could not stream the query", footer: "Non stream reason:\n\n" <> Exception.message(exception) ) ) ] } end _ -> do_atomic_batches( atomic_changeset, domain, stream, action, input, opts ) end {:not_atomic, not_atomic_batches_reason} -> if :stream in opts[:strategy] do do_stream_batches( domain, stream, action, input, opts, metadata_key, ref_metadata_key, context_key ) else %Ash.BulkResult{ status: :error, error_count: 1, errors: [ Ash.Error.to_error_class( Ash.Error.Invalid.NoMatchingBulkStrategy.exception( resource: resource, action: action.name, requested_strategies: opts[:strategy], not_atomic_batches_reason: not_atomic_batches_reason, not_atomic_reason: not_atomic_reason ) ) ] } end end end end defp do_atomic_batches( atomic_changeset, domain, stream, action, input, opts ) do batch_size = opts[:batch_size] || 100 resource = opts[:resource] ref = make_ref() pkey = Ash.Resource.Info.primary_key(resource) stream |> Stream.chunk_every(batch_size) |> map_batches( resource, opts, ref, fn batch -> pkeys = Ash.pkey_filter(batch, pkey) read_action = get_read_action(resource, action, opts).name resource |> Ash.Query.for_read(read_action, %{}, actor: opts[:actor], authorize?: false, context: Map.put(atomic_changeset.context, :query_for, :bulk_update), tenant: atomic_changeset.tenant, tracer: opts[:tracer] ) |> Ash.Query.set_context(%{private: %{internal?: true}}) |> Ash.Query.filter(^pkeys) |> Ash.Query.filter(^atomic_changeset.filter) |> Ash.Query.select([]) |> Ash.Query.sort(opts[:query_sort] || []) |> then(fn query -> run(domain, query, action.name, input, actor: opts[:actor], authorize_query?: false, authorize?: opts[:authorize?], tenant: atomic_changeset.tenant, context: opts[:context] || %{}, tracer: opts[:tracer], atomic_changeset: atomic_changeset, load: opts[:load], resource: opts[:resource], input_was_stream?: false, return_errors?: opts[:return_errors?], filter: opts[:filter], return_notifications?: opts[:return_notifications?], notify?: opts[:notify?], notification_metadata: opts[:notification_metadata], return_records?: opts[:return_records?], allow_stream_with: opts[:allow_stream_with], read_action: read_action, strategy: [:atomic], transaction: opts[:transaction] || true, rollback_on_error?: opts[:rollback_on_error?], stop_on_error?: opts[:stop_on_error?] ) |> case do %Ash.BulkResult{ error_count: 0, records: records, notifications: notifications, status: status } -> Ash.Actions.Helpers.Bulk.set_success(ref, status) Ash.Actions.Helpers.Bulk.store_notification(ref, notifications, opts) List.wrap(records) %Ash.BulkResult{ errors: errors, notifications: notifications, error_count: error_count, status: status } -> Ash.Actions.Helpers.Bulk.set_success(ref, status) Ash.Actions.Helpers.Bulk.store_notification(ref, notifications, opts) if errors && errors != [] do Ash.Actions.Helpers.Bulk.maybe_stop_on_error(hd(errors), opts) Enum.map(errors, &{:error, &1}) else Ash.Actions.Helpers.Bulk.maybe_stop_on_error(:transaction_error, opts) # No error details but we have a count - create placeholder tuples for _ <- 1..error_count, do: {:error, :transaction_error} end end end) end ) |> run_batches(ref, atomic_changeset.resource, atomic_changeset.action.name, opts) end defp do_stream_batches( domain, stream, action, input, opts, metadata_key, ref_metadata_key, context_key ) do resource = opts[:resource] action_select = Enum.uniq( Enum.concat( Ash.Resource.Info.action_select(resource, action.name), List.wrap( opts[:select] || MapSet.to_list(Ash.Resource.Info.selected_by_default_attribute_names(resource)) ) ) ) manual_action_can_bulk? = case action.manual do {mod, _opts} -> function_exported?(mod, :bulk_update, 3) _ -> false end batch_size = if action.manual == nil || manual_action_can_bulk? do opts[:batch_size] || 100 else 1 end ref = make_ref() base_changeset = base_changeset(resource, domain, opts, action, input) all_changes = pre_template_all_changes( action, resource, action.type, base_changeset, opts[:actor], base_changeset.to_tenant ) argument_names = Enum.map(action.arguments, & &1.name) stream |> Stream.with_index() |> Stream.chunk_every(batch_size) |> map_batches( resource, opts, ref, fn batch -> Ash.Tracer.span :bulk_batch, fn -> Ash.Domain.Info.span_name( domain, resource, action.name ) end, opts[:tracer] do Ash.Tracer.set_metadata(opts[:tracer], :bulk_batch, fn -> %{ domain: domain, resource: resource, resource_short_name: Ash.Resource.Info.short_name(resource), actor: opts[:actor], tenant: opts[:tenant], action: action.name, authorize?: opts[:authorize?] } end) try do batch |> Enum.map( &setup_changeset( &1, action, opts, input, argument_names, domain, context_key ) ) |> handle_batch( domain, resource, action, all_changes, opts, ref, context_key, metadata_key, ref_metadata_key, base_changeset, action_select ) after if opts[:return_stream?] && opts[:notify?] && !opts[:return_notifications?] do Ash.Notifier.notify(Process.delete({:bulk_notifications, ref}) || []) end end end end ) |> run_batches(ref, resource, action.name, opts) end defp run_batches(changeset_stream, ref, resource, action_name, opts) do if opts[:return_stream?] do Stream.concat(changeset_stream) else try do {records, error_tuples} = changeset_stream |> Stream.concat() |> Enum.split_with(fn {:error, _} -> false _ -> true end) records = if opts[:return_records?], do: records, else: nil notifications = Ash.Actions.Helpers.Bulk.return_notifications_or_notify(ref, opts) error_count = length(error_tuples) errors = if opts[:return_errors?] do Enum.map(error_tuples, fn {:error, error} -> Ash.Error.to_ash_error(error, [], bread_crumbs: [ "Returned from bulk update: #{inspect(resource)}.#{action_name}" ] ) end) else nil end bulk_result = %Ash.BulkResult{ records: records, errors: errors, notifications: notifications, error_count: error_count } case bulk_result do %{records: _, error_count: 0} -> %{bulk_result | status: :success} %{records: records, error_count: _} when records in [nil, []] -> %{bulk_result | status: :error} _ -> %{bulk_result | status: :partial_success} end catch {:error, error} -> status = if Process.get({:any_success?, ref}) do :partial_success else :error end accumulated_notifications = Process.delete({:bulk_notifications, ref}) notifications = if opts[:notify?] do if opts[:return_notifications?] do accumulated_notifications else Ash.Notifier.notify(accumulated_notifications || []) end end result = %Ash.BulkResult{ status: status, notifications: notifications } {error_count, errors} = Ash.Actions.Helpers.Bulk.errors(result, error, opts) %{result | errors: errors, error_count: error_count} after Process.delete({:bulk_notifications, ref}) Process.delete({:any_success?, ref}) end end end defp authorize_bulk_query(query, atomic_changeset, opts) do if opts[:authorize?] && opts[:authorize_query?] do case Ash.can(query, opts[:actor], return_forbidden_error?: true, maybe_is: false, pre_flight?: false, atomic_changeset: atomic_changeset, filter_with: opts[:authorize_query_with] || opts[:authorize_with] || :filter, run_queries?: false, alter_source?: true, no_check?: true ) do {:ok, true} -> {:ok, query} {:ok, true, query} -> {:ok, query} {:ok, false, error} -> {:error, error} {:error, error} -> {:error, error} end else {:ok, query} end end defp authorize_atomic_changeset(query, changeset, opts) do if opts[:authorize?] do case Ash.can( changeset, opts[:actor], return_forbidden_error?: true, maybe_is: false, atomic_changeset: changeset, pre_flight?: false, no_check?: true, on_must_pass_strict_check: {:error, %Ash.Error.Forbidden.InitialDataRequired{source: "must pass strict check"}}, filter_with: opts[:authorize_changeset_with] || opts[:authorize_with] || :filter, alter_source?: true, run_queries?: false, base_query: query ) do {:ok, true} -> {:ok, changeset, query} {:ok, true, %Ash.Query{} = query} -> {:ok, changeset, query} {:ok, true, %Ash.Changeset{} = changeset} -> {:ok, changeset, query} {:ok, true, %Ash.Changeset{} = changeset, %Ash.Query{} = query} -> {:ok, changeset, query} {:ok, false, error} -> {:error, error} {:error, error} -> {:error, error} end else {:ok, changeset, query} end end defp base_changeset(resource, domain, opts, action, input) do arguments = Enum.reduce(input, %{}, fn {key, value}, acc -> argument = if is_binary(key) do Enum.find(action.arguments, fn arg -> to_string(arg.name) == key end) else Enum.find(action.arguments, fn arg -> arg.name == key end) end if argument do Map.put(acc, argument.name, value) else acc end end) resource |> Ash.Changeset.new() |> Map.put(:domain, domain) |> Ash.Changeset.filter(opts[:filter]) |> Ash.Actions.Helpers.add_context(opts) |> Ash.Changeset.set_context(opts[:context] || %{}) |> Ash.Changeset.prepare_changeset_for_action(action, opts) |> Ash.Changeset.set_private_arguments_for_action(opts[:private_arguments] || %{}) |> Ash.Changeset.set_arguments(arguments) |> then(fn changeset -> changeset = if select = opts[:select] do Ash.Changeset.select(changeset, select) else changeset end if load = opts[:load] do Ash.Changeset.load(changeset, load) else changeset end end) end defp pre_template_all_changes(action, resource, _type, base, actor, tenant) do action.changes |> Enum.concat(Ash.Resource.Info.changes(resource, action.type)) |> then(fn changes -> if action.skip_global_validations? do changes else Enum.concat(changes, Ash.Resource.Info.validations(resource, action.type)) end end) |> Enum.map(fn %{change: {module, opts}} = change -> %{change | change: {module, pre_template(opts, base, actor, tenant)}} %{validation: {module, opts}} = validation -> %{validation | validation: {module, pre_template(opts, base, actor, tenant)}} end) |> Enum.map(fn %{where: where} = change -> new_where = if where do where |> List.wrap() |> Enum.map(fn {module, opts} -> {module, pre_template(opts, base, actor, tenant)} end) end %{change | where: new_where} other -> other end) |> Enum.with_index() end defp add_changeset_filters(query, changeset) do {Ash.Query.do_filter(query, changeset.filter), %{changeset | filter: nil}} end defp pre_template(opts, changeset, actor, tenant) do if Ash.Expr.template_references_context?(opts) do opts else {:templated, Ash.Expr.fill_template( opts, actor: actor, tenant: tenant, args: changeset.arguments, context: changeset.context, changeset: changeset )} end end defp notification_stream(ref) do the_notifications = Process.delete({:bulk_notifications, ref}) Stream.resource( fn -> the_notifications end, fn [] -> {:halt, []} notifications -> {Stream.map(notifications || [], &{:notification, &1}), []} end, fn _ -> :ok end ) end defp handle_batch( batch, domain, resource, action, all_changes, opts, ref, context_key, metadata_key, ref_metadata_key, base_changeset, action_select ) do %{ must_return_records?: must_return_records_for_changes?, batch: batch, re_sort?: re_sort?, changes: changes } = run_action_changes( batch, all_changes, action, resource, opts[:actor], opts[:authorize?], opts[:tracer], opts[:tenant], context_key ) batch = authorize(batch, opts) batch = if re_sort? do Enum.sort_by(batch, & &1.context[context_key].index) else batch end {batch, must_be_simple_results} = Ash.Actions.Helpers.split_and_run_simple( batch, action, opts, changes, all_changes, context_key, fn changeset -> case Ash.Actions.Update.run( domain, changeset, action, Keyword.merge(opts, atomic_upgrade?: false, return_destroyed?: opts[:return_records?] ) ) do :ok -> [] {:ok, result} -> [ Ash.Resource.set_metadata(result, %{ metadata_key => changeset.context |> Map.get(context_key) |> Map.get(:index), ref_metadata_key => changeset.context |> Map.get(context_key) |> Map.get(:ref) }) ] {:ok, result, notifications} -> Ash.Actions.Helpers.Bulk.store_notification(ref, notifications, opts) [ Ash.Resource.set_metadata(result, %{ metadata_key => changeset.context |> Map.get(context_key) |> Map.get(:index), ref_metadata_key => changeset.context |> Map.get(context_key) |> Map.get(:ref) }) ] {:error, error} -> error |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() end end ) # Separate valid and invalid changesets # Run after_transaction hooks immediately for invalid changesets to determine # if they convert errors to success. This allows stop_on_error? to work correctly # while still honoring hooks that convert errors. # Results are tagged with :ok_hooks_done or :error_hooks_done so process_results # knows not to run after_transaction hooks again. {batch, invalid_changeset_results} = Enum.reduce(batch, {[], []}, fn %{valid?: false} = changeset, {batch_acc, results_acc} -> error = Ash.Error.to_error_class(changeset.errors, changeset: changeset) case Ash.Changeset.run_after_transactions({:error, error}, changeset) do {:ok, result} -> {batch_acc, [{:ok_hooks_done, result, changeset} | results_acc]} {:error, error} -> error |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) {batch_acc, [{:error_hooks_done, error, changeset} | results_acc]} end changeset, {batch_acc, results_acc} -> {[changeset | batch_acc], results_acc} end) batch = Enum.reverse(batch) invalid_changeset_results = Enum.reverse(invalid_changeset_results) if opts[:transaction] == :batch && Ash.DataLayer.data_layer_can?(resource, :transact) do context = batch |> Enum.at(0) |> Kernel.||(%{}) |> Map.get(:context) notify? = opts[:notify?] && !Process.put(:ash_started_transaction?, true) try do Ash.DataLayer.transaction( List.wrap(resource) ++ action.touches_resources, fn -> do_handle_batch( batch, domain, resource, action, opts, all_changes, ref, metadata_key, ref_metadata_key, context_key, must_return_records_for_changes?, changes, action_select ) end, opts[:timeout], %{ type: context_key, metadata: %{ resource: resource, action: action.name, actor: opts[:actor] }, data_layer_context: opts[:data_layer_context] || context }, rollback_on_error?: false ) |> case do {:ok, tagged_results} -> # process_results runs OUTSIDE transaction - after_transaction hooks run here # Include invalid_changeset_results so they go through the after_transactions all_tagged_results = invalid_changeset_results ++ tagged_results process_results( all_tagged_results, opts, ref, context_key, metadata_key, resource, domain, base_changeset ) |> Stream.concat(must_be_simple_results) |> then(fn stream -> if opts[:return_stream?] do stream |> Stream.map(fn {:error, _} = error_tuple -> error_tuple record -> {:ok, clear_ref_metadata(record)} end) |> Stream.concat(notification_stream(ref)) else stream end end) {:error, error} -> # Convert batch changesets to error tuples for after_transaction processing error_tagged_results = Enum.map(batch, fn changeset -> {:error, error, changeset} end) all_tagged_results = invalid_changeset_results ++ error_tagged_results process_results( all_tagged_results, opts, ref, context_key, metadata_key, resource, domain, base_changeset ) |> Stream.concat(must_be_simple_results) |> then(fn stream -> if opts[:return_stream?] do stream |> Stream.map(fn {:error, _} = error_tuple -> error_tuple record -> {:ok, clear_ref_metadata(record)} end) |> Stream.concat(notification_stream(ref)) else stream end end) end after if notify? do Process.delete(:ash_started_transaction?) notifications = Process.get(:ash_notifications, []) remaining_notifications = Ash.Notifier.notify(notifications) Process.delete(:ash_notifications) || [] Ash.Actions.Helpers.warn_missed!(resource, action, %{ resource_notifications: remaining_notifications }) end end else do_handle_batch( batch, domain, resource, action, opts, all_changes, ref, metadata_key, ref_metadata_key, context_key, must_return_records_for_changes?, changes, action_select ) |> then(fn tagged_results -> # Include invalid_changeset_results so they go through the after_transactions invalid_changeset_results ++ tagged_results end) |> process_results( opts, ref, context_key, metadata_key, resource, domain, base_changeset ) |> Stream.concat(must_be_simple_results) |> then(fn stream -> if opts[:return_stream?] do stream |> Stream.map(fn {:error, _} = error_tuple -> error_tuple record -> {:ok, clear_ref_metadata(record)} end) |> Stream.concat(notification_stream(ref)) else stream end end) end end defp do_handle_batch( batch, domain, resource, action, opts, all_changes, ref, metadata_key, ref_metadata_key, context_key, must_return_records_for_changes?, changes, action_select ) do must_return_records? = opts[:notify?] || Enum.any?(batch, fn item -> item.after_action != [] || item.after_transaction != [] end) # Can return both valid and invalid changesets batch = run_bulk_before_batches( batch, changes, all_changes, opts, ref, context_key ) run_batch( resource, batch, action, opts, must_return_records?, must_return_records_for_changes?, domain, ref, metadata_key, ref_metadata_key, context_key, action_select ) |> run_after_action_hooks(opts, domain, ref) |> then( &run_bulk_after_changes( changes, all_changes, &1, batch, opts, ref, resource, metadata_key ) ) end defp setup_changeset( {record, index}, action, opts, input, argument_names, domain, context_key ) do # Generate unique ref per changeset (not per batch) ref = make_ref() changeset = record |> Ash.Changeset.new() |> Map.put(:domain, domain) |> Ash.Changeset.prepare_changeset_for_action(action, opts) |> Ash.Changeset.set_private_arguments_for_action(opts[:private_arguments] || %{}) |> Ash.Changeset.put_context(context_key, %{index: index, ref: ref}) |> Ash.Changeset.set_context(opts[:context] || %{}) changeset = changeset |> Ash.Changeset.atomic_update(opts[:atomic_update] || []) |> Ash.Changeset.hydrate_atomic_refs(opts[:actor], opts) |> handle_params( Keyword.get(opts, :assume_casted?, false), action, opts, input, argument_names ) |> Ash.Changeset.apply_atomic_constraints(opts[:actor]) case opts[:transform_changeset] do nil -> changeset transform -> transform.(changeset) end end defp handle_params(changeset, false, action, opts, input, _argument_names) do Ash.Changeset.handle_params(changeset, action, input, opts) end defp handle_params(changeset, true, action, opts, input, argument_names) do {args, attrs} = Map.split(input, argument_names) %{changeset | arguments: args, attributes: attrs} |> Ash.Changeset.handle_params(action, input, Keyword.put(opts, :cast_params?, false)) end defp map_batches(stream, resource, opts, ref, callback) do max_concurrency = opts[:max_concurrency] max_concurrency = if max_concurrency && max_concurrency > 1 && Ash.DataLayer.can?(:async_engine, resource) do max_concurrency else 0 end if max_concurrency && max_concurrency > 1 do ash_context = Ash.ProcessHelpers.get_context_for_transfer(opts) Task.async_stream( stream, fn batch -> Ash.ProcessHelpers.transfer_context(ash_context, opts) Process.put(:ash_started_transaction?, true) batch_result = callback.(batch) notifications = if opts[:notify?] do process_notifications = Process.get(:ash_notifications, []) bulk_notifications = Process.get({:bulk_notifications, ref}) || [] if opts[:return_notifications?] do process_notifications ++ bulk_notifications else if opts[:transaction] && opts[:transaction] != :all do Ash.Notifier.notify( Enum.concat(List.wrap(bulk_notifications), List.wrap(process_notifications)) ) end [] end end {batch_result, notifications, Process.get({:any_success?, ref})} end, timeout: :infinity, max_concurrency: max_concurrency ) |> Stream.flat_map(fn {:ok, {:throw, value}} -> throw(value) {:ok, {%Ash.BulkResult{ records: records, notifications: notifications, errors: errors, error_count: error_count }, _, any_success?}} -> Process.put({:any_success?, ref}, any_success?) Ash.Actions.Helpers.Bulk.store_notification(ref, notifications, opts) error_tuples = cond do errors && errors != [] -> if opts[:stop_on_error?] && !opts[:return_stream?] do throw({:error, Ash.Error.to_error_class(hd(errors))}) end Enum.map(errors, &{:error, &1}) error_count > 0 -> if opts[:stop_on_error?] && !opts[:return_stream?] do throw({:error, Ash.Error.to_error_class(:transaction_error)}) end for _ <- 1..error_count, do: {:error, :transaction_error} true -> [] end List.wrap(records) ++ error_tuples {:ok, {result, notifications, any_success?}} -> Process.put({:any_success?, ref}, any_success?) Ash.Actions.Helpers.Bulk.store_notification(ref, notifications, opts) # result already contains records and {:error, _} tuples inline List.wrap(result) {:exit, error} -> error |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() end) else Stream.map(stream, callback) end end defp index_changesets(batch, context_key) do Enum.reduce(batch, {%{}, %{}}, fn changeset, {by_ref, by_index} -> ref = changeset.context[context_key].ref index = changeset.context[context_key].index { Map.put(by_ref, ref, changeset), Map.put(by_index, index, ref) } end) end @doc false def run_bulk_before_batches( [], _changes, _all_changes, _opts, _ref, _context_key ) do [] end def run_bulk_before_batches( batch, changes, all_changes, opts, ref, context_key ) do all_changes |> Enum.reduce(batch, fn {%{validation: _}, _index}, batch -> batch {%{change: {module, change_opts}}, index}, batch -> # change may return a stream but before_batch/3 expects a list batch = Enum.to_list(batch) context = struct(Ash.Resource.Change.Context, %{ bulk?: true, source_context: Enum.at(batch, 0).context, actor: opts[:actor], tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] }) if changes[index] == :all do case change_opts do {:templated, change_opts} -> if module.has_before_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, batch, change_opts, context) do Ash.Resource.Change.before_batch( module, batch, change_opts, context ) else batch end change_opts -> Enum.flat_map(batch, fn changeset -> if module.has_before_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, batch, change_opts, context) do change_opts = templated_opts( change_opts, opts[:actor], changeset.to_tenant, changeset.arguments, changeset.context, changeset ) Ash.Resource.Change.before_batch( module, [changeset], change_opts, context ) else [changeset] end end) end else {matches, non_matches} = batch |> Enum.split_with(fn %{valid?: false} -> false changeset -> changeset.context[context_key].index in List.wrap(changes[index]) end) before_batch_results = case change_opts do {:templated, change_opts} -> if module.has_before_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, matches, change_opts, context) do Ash.Resource.Change.before_batch( module, matches, change_opts, context ) else matches end change_opts -> Enum.flat_map(matches, fn changeset -> change_opts = templated_opts( change_opts, opts[:actor], changeset.to_tenant, changeset.arguments, changeset.context, changeset ) if module.has_before_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?( module, [changeset], change_opts, context ) do Ash.Resource.Change.before_batch( module, [changeset], change_opts, context ) else [changeset] end end) end Enum.concat([before_batch_results, non_matches]) end end) |> Enum.reject(fn %Ash.Notifier.Notification{} = notification -> Ash.Actions.Helpers.Bulk.store_notification(ref, notification, opts) true _changeset -> false end) end defp authorize(batch, opts) do if opts[:authorize?] do batch |> Enum.map(fn changeset -> if changeset.valid? do case Ash.can(changeset, opts[:actor], return_forbidden_error?: true, maybe_is: false, pre_flight?: false ) do {:ok, true} -> changeset {:ok, false, error} -> Ash.Changeset.add_error(changeset, error) {:error, error} -> Ash.Changeset.add_error(changeset, error) end else changeset end end) else batch end end defp handle_bulk_result(%Ash.BulkResult{} = bulk_result, metadata_key, opts) do bulk_result |> sort(metadata_key, opts) |> clear_ref_metadata_from_records() |> ensure_records_return_type(opts) |> ensure_errors_return_type(opts) end # for when we return a stream defp handle_bulk_result(stream, _, _), do: stream defp ensure_records_return_type(result, opts) do if opts[:return_records?] do %{result | records: result.records || []} else %{result | records: nil} end end defp ensure_errors_return_type(result, opts) do if opts[:return_errors?] do %{result | errors: result.errors || []} else %{result | errors: nil} end end defp sort(%{records: records} = result, metadata_key, opts) when is_list(records) and not is_nil(metadata_key) do if opts[:sorted?] do %{result | records: Enum.sort_by(records, & &1.__metadata__[metadata_key])} else result end end defp sort(result, _metadata_key, _opts), do: result @spec run_batch( resource :: Ash.Resource.t(), batch :: [Ash.Changeset.t()], action :: Ash.Resource.Actions.action(), opts :: keyword(), must_return_records? :: boolean(), must_return_records_for_changes? :: boolean(), domain :: Ash.Domain.t(), ref :: reference(), metadata_key :: atom(), ref_metadata_key :: atom(), context_key :: atom(), action_select :: [atom()] | nil ) :: [Ash.Actions.Helpers.Bulk.tagged_result()] defp run_batch( resource, batch, action, opts, must_return_records?, must_return_records_for_changes?, domain, ref, metadata_key, ref_metadata_key, context_key, action_select ) do batch = Enum.map(batch, fn changeset -> changeset = changeset |> Ash.Changeset.hydrate_atomic_refs(opts[:actor]) |> Ash.Changeset.apply_atomic_constraints(opts[:actor]) if changeset.valid? do {changeset, %{notifications: new_notifications}} = Ash.Changeset.run_before_actions(changeset) if !changeset.valid? && opts[:rollback_on_error?] do Ash.Actions.Helpers.rollback_if_in_transaction( {:error, changeset.errors}, resource, nil ) end changeset = changeset |> Ash.Changeset.handle_allow_nil_atomics(opts[:actor]) |> Ash.Changeset.require_values( :update, true ) |> Map.put(:action_select, action_select) changed? = Ash.Changeset.changing_attributes?(changeset) or not Enum.empty?(changeset.atomics) changeset = Ash.Changeset.put_context( changeset, :changed?, changeset.context[:changed?] || changed? ) new_notifications = Ash.Actions.Helpers.Bulk.store_notification(ref, new_notifications, opts) {changeset, manage_notifications} = if changeset.valid? do case Ash.Actions.ManagedRelationships.setup_managed_belongs_to_relationships( changeset, opts[:actor], authorize?: opts[:authorize?], actor: opts[:actor], tenant: opts[:tenant] ) do {:error, error} -> {Ash.Changeset.add_error(changeset, error), new_notifications} {changeset, manage_instructions} -> {changeset, manage_instructions.notifications} end else {changeset, []} end Ash.Actions.Helpers.Bulk.store_notification(ref, manage_notifications, opts) changeset else changeset end end) # Split valid/invalid changesets after before_actions processing # Invalid changesets become {:error, error, changeset} tuples # after_transaction hooks for these will run later in process_results {batch, invalid_changeset_errors} = Ash.Actions.Helpers.Bulk.split_valid_invalid_changesets(batch, opts) # Build index maps from valid changesets only {changesets_by_ref, changesets_by_index} = index_changesets(batch, context_key) case batch do [] -> invalid_changeset_errors batch -> batch |> Enum.group_by(&{&1.atomics, &1.filter}) |> Enum.flat_map(fn {_atomics, batch} -> result = case action.manual do {mod, manual_opts} -> source_context = case batch do [cs | _] -> cs.context [] -> %{} end if function_exported?(mod, :bulk_update, 3) do Ash.Resource.ManualUpdate.bulk_update( mod, batch, manual_opts, %Ash.Resource.ManualUpdate.BulkContext{ actor: opts[:actor], select: opts[:select], source_context: source_context, batch_size: opts[:batch_size], authorize?: opts[:authorize?], tracer: opts[:tracer], domain: domain, return_records?: opts[:return_records?] || must_return_records? || must_return_records_for_changes?, return_notifications?: opts[:return_notifications?] || false, return_errors?: opts[:return_errors?] || false, tenant: Ash.ToTenant.to_tenant(opts[:tenant], resource) } ) else ctx = %Ash.Resource.ManualUpdate.Context{ actor: opts[:actor], source_context: source_context, select: opts[:select], authorize?: opts[:authorize?], tracer: opts[:tracer], domain: domain, return_notifications?: opts[:return_notifications?] || false, tenant: opts[:tenant] } [changeset] = batch [ Ash.Resource.ManualUpdate.update(mod, changeset, manual_opts, ctx) |> Ash.Actions.BulkManualActionHelpers.process_non_bulk_result( changeset, :bulk_update, &Ash.Actions.Helpers.Bulk.store_notification/3, ref, opts ) ] end |> Ash.Actions.Helpers.rollback_if_in_transaction(resource, nil) |> Ash.Actions.BulkManualActionHelpers.process_bulk_results( mod, :bulk_update, &Ash.Actions.Helpers.Bulk.store_notification/3, ref, opts, batch, changesets_by_ref, changesets_by_index ) _ -> Enum.reduce_while( batch, {:ok, []}, fn changeset, {:ok, results} -> changed? = Ash.Changeset.changing_attributes?(changeset) or not Enum.empty?(changeset.atomics) if changed? do changeset = if Enum.empty?(changeset.attributes) && Ash.DataLayer.data_layer_can?(changeset.resource, :atomic_update) do Ash.Changeset.atomic_defaults(changeset) else Ash.Changeset.set_defaults(changeset, :update, true) end |> Ash.Changeset.set_action_select() resource |> Ash.DataLayer.update(changeset) |> Ash.Actions.Helpers.rollback_if_in_transaction(resource, nil) |> case do {:ok, result} -> result = result |> Ash.Resource.put_metadata( metadata_key, changeset.context[context_key].index ) |> Ash.Resource.put_metadata( ref_metadata_key, changeset.context[context_key].ref ) {:cont, {:ok, [result | results]}} {:error, %Ash.Error.Changes.StaleRecord{}} -> {:cont, {:ok, results}} {:error, error} -> {:halt, {:error, error}} end else result = changeset.data |> Ash.Resource.put_metadata( metadata_key, changeset.context[context_key].index ) |> Ash.Resource.put_metadata( ref_metadata_key, changeset.context[context_key].ref ) {:cont, {:ok, [result | results]}} end end ) |> case do {:ok, results} -> {:ok, Enum.reverse(results)} {:error, error} -> {:error, error} end end case result do # Manual action path: already tagged with changesets # Don't throw on error here - let errors flow through to process_results # so that after_transaction hooks can run and potentially convert errors to success {:manual_tagged, tagged_results} -> tagged_results {:ok, result} -> result {:error, error} -> error |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() end end) # Wrap results in tuples with embedded changesets |> Enum.flat_map(fn # Already tagged (from manual path) - pass through {:ok, result, changeset} when is_struct(changeset, Ash.Changeset) -> [{:ok, result, changeset}] {:error, error, changeset} when is_struct(changeset, Ash.Changeset) -> [{:error, error, changeset}] # Data layer path - needs changeset lookup result when is_struct(result) -> changeset = Ash.Actions.Helpers.Bulk.lookup_changeset( result, changesets_by_ref, changesets_by_index, index_key: metadata_key, ref_key: ref_metadata_key ) [{:ok, result, changeset}] {:error, error} -> # Attach error to all changesets in the batch batch |> Enum.map(&{:error, error, &1}) end) |> Enum.concat(invalid_changeset_errors) end end defp manage_relationships(updated, domain, changeset, engine_opts) do with {:ok, loaded} <- Ash.Actions.ManagedRelationships.load(domain, updated, changeset, engine_opts), {:ok, with_relationships, new_notifications} <- Ash.Actions.ManagedRelationships.manage_relationships( loaded, changeset, engine_opts[:actor], engine_opts ) do {:ok, with_relationships, %{notifications: new_notifications, new_changeset: changeset}} end end @spec run_after_action_hooks( batch_results :: [Ash.Actions.Helpers.Bulk.tagged_result()], opts :: keyword(), domain :: Ash.Domain.t(), ref :: reference() ) :: [Ash.Actions.Helpers.Bulk.tagged_result()] defp run_after_action_hooks(batch_results, opts, domain, ref) do Enum.flat_map(batch_results, fn {:ok, result, changeset} -> case manage_relationships(result, domain, changeset, actor: opts[:actor], tenant: opts[:tenant], authorize?: opts[:authorize?] ) do {:ok, result, %{notifications: new_notifications, new_changeset: changeset}} -> Ash.Actions.Helpers.Bulk.store_notification(ref, new_notifications, opts) case Ash.Changeset.run_after_actions(result, changeset, []) do {:error, error} -> if opts[:transaction] && opts[:rollback_on_error?] do if Ash.DataLayer.in_transaction?(changeset.resource) do Ash.DataLayer.rollback( changeset.resource, error ) end end [{:error, error, changeset}] {:ok, result_after_action, changeset, %{notifications: more_new_notifications}} -> Ash.Actions.Helpers.Bulk.store_notification(ref, more_new_notifications, opts) result = Map.put( result_after_action, :__metadata__, Map.merge(result_after_action.__metadata__, result.__metadata__) ) [{:ok, result, changeset}] end {:error, error} -> if opts[:transaction] && opts[:rollback_on_error?] do if Ash.DataLayer.in_transaction?(changeset.resource) do Ash.DataLayer.rollback( changeset.resource, error ) end end [{:error, error, changeset}] end # Pass through error tuples unchanged other -> [other] end) end @spec process_results( tagged_results :: [Ash.Actions.Helpers.Bulk.tagged_result_with_hooks()], opts :: keyword(), ref :: reference(), context_key :: atom(), metadata_key :: atom(), resource :: Ash.Resource.t(), domain :: Ash.Domain.t(), base_changeset :: Ash.Changeset.t() ) :: [Ash.Resource.Record.t() | {:error, term()}] defp process_results( tagged_results, opts, ref, context_key, metadata_key, resource, domain, base_changeset ) do need_notifications? = Ash.Actions.Helpers.Bulk.need_notifications?(opts) {results, changeset_by_id} = Enum.flat_map_reduce(tagged_results, %{}, fn {:ok, result, changeset}, changeset_map -> try do case Ash.Changeset.run_after_transactions({:ok, result}, changeset) do {:ok, result} -> Process.put({:any_success?, ref}, true) if opts[:return_records?] || need_notifications? do changeset_id = make_ref() metadata = if index = changeset.context[context_key][:index] do %{metadata_key => index, :bulk_changeset_id => changeset_id} else %{bulk_changeset_id: changeset_id} end metadata = if changeset.to_tenant, do: Map.put(metadata, :tenant, changeset.to_tenant), else: metadata {[Ash.Resource.set_metadata(result, metadata)], Map.put(changeset_map, changeset_id, changeset)} else {[], changeset_map} end {:error, error} -> error_result = error |> Ash.Actions.Helpers.Bulk.set_index_path(changeset) |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() {error_result, changeset_map} end rescue e -> error_result = e |> Ash.Actions.Helpers.Bulk.set_index_path(changeset) |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() {error_result, changeset_map} end {:ok_hooks_done, result, changeset}, changeset_map -> Process.put({:any_success?, ref}, true) if opts[:return_records?] || need_notifications? do changeset_id = make_ref() metadata = if index = changeset.context[context_key][:index] do %{metadata_key => index, :bulk_changeset_id => changeset_id} else %{bulk_changeset_id: changeset_id} end metadata = if changeset.to_tenant, do: Map.put(metadata, :tenant, changeset.to_tenant), else: metadata {[Ash.Resource.set_metadata(result, metadata)], Map.put(changeset_map, changeset_id, changeset)} else {[], changeset_map} end {:error, error, changeset}, changeset_map -> try do case Ash.Changeset.run_after_transactions({:error, error}, changeset) do {:ok, result} -> Process.put({:any_success?, ref}, true) if opts[:return_records?] || need_notifications? do changeset_id = make_ref() metadata = if index = changeset.context[context_key][:index] do %{metadata_key => index, :bulk_changeset_id => changeset_id} else %{bulk_changeset_id: changeset_id} end metadata = if changeset.to_tenant, do: Map.put(metadata, :tenant, changeset.to_tenant), else: metadata {[Ash.Resource.set_metadata(result, metadata)], Map.put(changeset_map, changeset_id, changeset)} else {[], changeset_map} end {:error, error} -> error_result = error |> Ash.Actions.Helpers.Bulk.set_index_path(changeset) |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() {error_result, changeset_map} end rescue e -> error_result = e |> Ash.Actions.Helpers.Bulk.set_index_path(changeset) |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() {error_result, changeset_map} end {:error_hooks_done, error, changeset}, changeset_map -> error = Ash.Actions.Helpers.Bulk.set_index_path(error, changeset) {[{:error, error}], changeset_map} end) # Separate records from error tuples before loading {records, errors} = Enum.split_with(results, fn {:error, _} -> false _ -> true end) loaded_records = records |> load_data(domain, resource, base_changeset, opts) |> case do {:ok, records} -> Enum.reject(records, & &1.__metadata__[:private][:missing_from_data_layer]) {:error, error} -> error |> Ash.Actions.Helpers.Bulk.maybe_rollback(resource, opts) |> Ash.Actions.Helpers.Bulk.maybe_stop_on_error(opts) |> Ash.Helpers.error() |> List.wrap() end Ash.Actions.Helpers.Bulk.store_notifications_for_loaded_records( loaded_records, changeset_by_id, ref, ¬ification/3, opts ) cleaned_records = loaded_records |> Ash.Actions.Helpers.Bulk.clean_changeset_id_metadata() if opts[:return_records?] do Ash.Actions.Helpers.select(cleaned_records, base_changeset) ++ errors else errors end end defp load_data(records, domain, resource, changeset, opts) do select = if changeset.select do List.wrap(changeset.select) else resource |> Ash.Resource.Info.public_attributes() |> Enum.map(& &1.name) end case Ash.load(records, select, reuse_values?: true, domain: domain, tenant: opts[:tenant], action: Ash.Resource.Info.primary_action(changeset.resource, :read) || changeset.action, actor: opts[:actor], authorize?: opts[:authorize?], tracer: opts[:tracer] ) do {:ok, records} -> load_query = changeset.resource |> Ash.Query.load(List.wrap(changeset.load)) |> Ash.Actions.Helpers.merge_notifier_calculations( Ash.Notifier.notifier_calculation_query( changeset.resource, changeset.action, changeset.context ) ) Ash.load( records, load_query, reuse_values?: true, tenant: opts[:tenant], action: Ash.Resource.Info.primary_action(changeset.resource, :read) || changeset.action, domain: domain, actor: opts[:actor], authorize?: opts[:authorize?], tracer: opts[:tracer] ) other -> other end end @doc """ Runs bulk after_batch changes on a list of tagged results. Called within transaction to execute batch change callbacks. """ @spec run_bulk_after_changes( changes :: map(), all_changes :: [{map(), non_neg_integer()}], results :: [Ash.Actions.Helpers.Bulk.tagged_result()], changesets :: [Ash.Changeset.t()], opts :: keyword(), ref :: reference(), resource :: Ash.Resource.t(), metadata_key :: atom() ) :: [Ash.Actions.Helpers.Bulk.tagged_result()] def run_bulk_after_changes( changes, all_changes, results, changesets, opts, ref, resource, metadata_key ) do source_context = case changesets do [cs | _] -> cs.context _ -> %{} end {results, errors} = results |> Enum.split_with(fn {:ok, _, _} -> true _other -> false end) context = struct( Ash.Resource.Change.Context, %{ bulk?: true, actor: opts[:actor], source_context: source_context, tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] } ) all_changes |> Enum.reduce(results, fn {%{validation: _}, _index}, results -> results {%{change: {module, change_opts}}, index}, results -> {results, errors} = results |> Enum.split_with(fn {:ok, _, _} -> true _other -> false end) if changes[index] == :all do # Transform {:ok, result, changeset} to {changeset, result} for after_batch callbacks results_for_callback = Enum.map(results, fn {:ok, result, changeset} -> {changeset, result} end) case change_opts do {:templated, change_opts} -> if module.has_after_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, changesets, change_opts, context) do Ash.Resource.Change.after_batch( module, results_for_callback, change_opts, context ) |> case do :ok -> results callback_results -> callback_results |> Enum.zip(results) |> Enum.map(fn {{:ok, result}, {:ok, _old_result, changeset}} -> {:ok, result, changeset} {{:error, error}, {:ok, _old_result, changeset}} -> {:error, error, changeset} {notification, _} -> notification end) end else results end change_opts -> Enum.flat_map(results_for_callback, fn {changeset, record} -> change_opts = templated_opts( change_opts, opts[:actor], changeset.to_tenant, changeset.arguments, changeset.context, changeset ) if module.has_after_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?( module, changesets, change_opts, context ) do Ash.Resource.Change.after_batch( module, [{changeset, record}], change_opts, context ) |> case do :ok -> [{:ok, record, changeset}] results -> results |> Enum.zip(changesets) |> Enum.map(fn {{:ok, result}, changeset} -> {:ok, result, changeset} {{:error, error}, changeset} -> {:error, error, changeset} {notification, _changeset} -> notification end) end else [{:ok, record, changeset}] end end) end |> handle_after_batch_results(results, ref, resource, opts) else {matches, non_matches} = results |> Enum.split_with(fn {:ok, result, _changeset} -> result.__metadata__[metadata_key] in List.wrap(changes[index]) end) # Transform {:ok, result, changeset} to {changeset, result} for after_batch callbacks matches_for_callback = Enum.map(matches, fn {:ok, result, changeset} -> {changeset, result} end) after_batch_results = case change_opts do {:templated, change_opts} -> if module.has_after_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?( module, changesets, change_opts, context ) do Ash.Resource.Change.after_batch( module, matches_for_callback, change_opts, struct( Ash.Resource.Change.Context, %{ bulk?: true, source_context: source_context, actor: opts[:actor], tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] } ) ) |> case do :ok -> matches callback_results -> callback_results |> Enum.zip(matches) |> Enum.map(fn {{:ok, result}, {:ok, _old_result, changeset}} -> {:ok, result, changeset} {{:error, error}, {:ok, _old_result, changeset}} -> {:error, error, changeset} {notification, _} -> notification end) end else matches end change_opts -> Enum.flat_map(matches_for_callback, fn {changeset, record} = result -> if module.has_after_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?( module, changesets, change_opts, context ) do change_opts = templated_opts( change_opts, opts[:actor], changeset.to_tenant, changeset.arguments, changeset.context, changeset ) Ash.Resource.Change.after_batch( module, [result], change_opts, struct( Ash.Resource.Change.Context, %{ bulk?: true, source_context: source_context, actor: opts[:actor], tenant: opts[:tenant], tracer: opts[:tracer], authorize?: opts[:authorize?] } ) ) |> case do :ok -> [{:ok, record, changeset}] results -> results |> Enum.zip(changesets) |> Enum.map(fn {{:ok, result}, changeset} -> {:ok, result, changeset} {{:error, error}, changeset} -> {:error, error, changeset} {notification, _changeset} -> notification end) end else [{:ok, record, changeset}] end end) end |> handle_after_batch_results(matches, ref, resource, opts) Enum.concat([after_batch_results, non_matches]) end |> Enum.concat(errors) end) |> Enum.concat(errors) end defp handle_after_batch_results(results, _matches, ref, resource, opts) do results |> Enum.flat_map(fn {%Ash.Notifier.Notification{} = notification, _} -> Ash.Actions.Helpers.Bulk.store_notification(ref, notification, opts) {:ok, result, changeset} -> [{:ok, result, changeset}] {:error, error, changeset} -> if opts[:transaction] && opts[:rollback_on_error?] do if Ash.DataLayer.in_transaction?(resource) do Ash.DataLayer.rollback( resource, error ) end end if opts[:stop_on_error?] && !opts[:return_stream?] do throw({:error, Ash.Error.to_error_class(error)}) end [{:error, error, changeset}] end) end defp notification(changeset, result, opts) do Ash.Actions.Helpers.resource_notification(changeset, result, opts) end def run_action_changes( batch, all_changes, action, resource, actor, authorize?, tracer, tenant, context_key ) do source_context = case batch do [cs | _] -> cs.context _ -> %{} end context = %{ actor: actor, source_context: source_context, authorize?: authorize? || false, tracer: tracer, tenant: tenant } Enum.reduce( all_changes, %{must_return_records?: false, re_sort?: false, batch: batch, changes: %{}}, fn {%{validation: {module, validation_opts}} = validation, _change_index}, %{batch: batch} = state -> validation_context = struct( Ash.Resource.Validation.Context, Map.put(context, :message, validation.message) ) batch = if module.has_batch_validate?() && module.batch_callbacks?(batch, validation_opts, validation_context) do Ash.Actions.Helpers.Bulk.run_batch_validation( validation, batch, validation_context, actor ) else if module.has_validate?() && Enum.all?(validation.where, fn {module, _opts} -> module.has_validate?() end) do if validation.before_action? do Enum.map(batch, fn changeset -> Ash.Changeset.before_action(changeset, fn changeset -> [changeset] = validate_batch_non_atomically( validation, [changeset], validation_context, actor ) changeset end) end) else validate_batch_non_atomically(validation, batch, validation_context, actor) end else if module.atomic?() do validate_batch_atomically(validation, batch, validation_context, context, actor) else raise """ Cannot use a non-atomic validation with an atomic condition. Attempting to run action: `#{inspect(resource)}.#{action.name}` """ end end end %{ state | must_return_records?: state.must_return_records?, batch: batch, changes: state.changes } {%{change: {module, change_opts}} = change, change_index}, %{batch: batch} = state -> {matches, non_matches} = if Enum.empty?(change.where) && !change.only_when_valid? do {batch, []} else Enum.split_with(batch, fn changeset -> Enum.all?(change.where || [], fn {module, opts} -> if module.has_validate?() do opts = Ash.Actions.Helpers.templated_opts( opts, actor, changeset.tenant, changeset.arguments, changeset.context, changeset ) {:ok, opts} = Ash.Resource.Validation.init(module, opts) if change.only_when_valid? do changeset.valid? && Ash.Resource.Validation.validate( module, changeset, opts, struct(Ash.Resource.Validation.Context, context) ) == :ok else Ash.Resource.Validation.validate( module, changeset, opts, struct(Ash.Resource.Validation.Context, context) ) == :ok end else true end end) end) end if Enum.empty?(matches) do %{ state | must_return_records?: state.must_return_records?, batch: Enum.concat(matches, non_matches), changes: state.changes } else matches = batch_change(change, matches, context, actor) must_return_records? = state.must_return_records? || Enum.any?(batch, fn item -> item.relationships not in [nil, %{}] || !Enum.empty?(item.after_action) end) || (module.has_after_batch?() && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, batch, change_opts, context)) match_indices = if Enum.empty?(non_matches) do :all else Enum.map(matches, & &1.context[context_key].index) end %{ state | must_return_records?: must_return_records?, batch: Enum.concat(matches, non_matches), re_sort?: true, changes: Map.put( state.changes, change_index, match_indices ) } end end ) end defp validate_batch_non_atomically( %{validation: {module, opts}} = validation, batch, validation_context, actor ) do Enum.map(batch, fn changeset -> if Enum.all?(validation.where || [], fn {module, opts} -> opts = Ash.Actions.Helpers.templated_opts( opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset ) {:ok, opts} = Ash.Resource.Validation.init(module, opts) Ash.Resource.Validation.validate( module, changeset, opts, validation_context ) == :ok end) do opts = Ash.Actions.Helpers.templated_opts( opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset ) {:ok, opts} = Ash.Resource.Validation.init(module, opts) case Ash.Resource.Validation.validate( module, changeset, opts, validation_context ) do :ok -> changeset {:error, error} -> error = if validation.message do Ash.Error.override_validation_message(error, validation.message) else error end Ash.Changeset.add_error(changeset, error) end else changeset end end) end defp validate_batch_atomically(validation, batch, validation_context, context, actor) do Enum.map(batch, fn changeset -> validation = %{ validation | opts: templated_opts( validation.opts, actor, changeset.tenant, changeset.arguments, changeset.context, changeset ) } case Ash.Changeset.split_atomic_conditions( validation, changeset, actor, validation_context ) do {:ok, validation_with_remaining_conditions} -> Ash.Changeset.run_atomic_validation( changeset, validation_with_remaining_conditions, context ) :skip -> changeset end end) end def batch_change(%{change: {module, change_opts}, where: where} = change, batch, context, actor) do must_be_atomic? = Enum.any?(change.where, fn {mod, _} -> !mod.has_validate?() end) case change_opts do {:templated, change_opts} -> cond do !must_be_atomic? && module.has_batch_change?() && Ash.Resource.Change.batch_callbacks?(module, batch, change_opts, context) -> {:ok, change_opts} = Ash.Resource.Change.init(module, change_opts) Ash.Resource.Change.batch_change(module, batch, change_opts, context) !must_be_atomic? && module.has_change?() -> Enum.map(batch, fn changeset -> {:ok, change_opts} = Ash.Resource.Change.init(module, change_opts) Ash.Resource.Change.change( module, changeset, change_opts, struct(struct(Ash.Resource.Change.Context, context), bulk?: true) ) end) module.atomic?() -> Enum.map(batch, fn changeset -> change = %{ change | where: Enum.filter(where, fn {module, _opts} -> module.atomic?() end) |> Enum.map(fn {module, change_opts} -> {module, templated_opts( change_opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset )} end), change: {module, templated_opts( change_opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset )} } case Ash.Changeset.run_atomic_change(changeset, change, context) do {:not_atomic, reason} -> Ash.Changeset.add_error( changeset, "Could not be made atomic: #{inspect(reason)}" ) changeset -> changeset end end) true -> if must_be_atomic? do raise "#{inspect(module)} cannot be paired with atomic conditions, as it does not implement `atomic/3`" else raise "#{inspect(module)} must define at least one of `atomic/3`, `change/3` or `batch_change/3`" end end change_opts -> cond do !must_be_atomic? && module.has_batch_change?() -> Enum.flat_map(batch, fn changeset -> if Ash.Resource.Change.batch_callbacks?(module, batch, change_opts, context) do change_opts = templated_opts( change_opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset ) {:ok, change_opts} = Ash.Resource.Change.init(module, change_opts) Ash.Resource.Change.batch_change(module, batch, change_opts, context) else [ Ash.Resource.Change.change( module, changeset, change_opts, struct(struct(Ash.Resource.Change.Context, context), bulk?: true) ) ] end end) !must_be_atomic? && module.has_change?() -> Enum.map(batch, fn changeset -> change_opts = templated_opts( change_opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset ) {:ok, change_opts} = Ash.Resource.Change.init(module, change_opts) Ash.Resource.Change.change( module, changeset, change_opts, struct(struct(Ash.Resource.Change.Context, context), bulk?: true) ) end) module.atomic?() -> Enum.map(batch, fn changeset -> change = %{ change | where: Enum.filter(where, fn {module, _opts} -> module.atomic?() end), change: {module, templated_opts( change_opts, actor, changeset.to_tenant, changeset.arguments, changeset.context, changeset )} } case Ash.Changeset.run_atomic_change(changeset, change, context) do {:not_atomic, reason} -> Ash.Changeset.add_error( changeset, "Could not be made atomic: #{inspect(reason)}" ) changeset -> changeset end end) true -> if must_be_atomic? do raise "#{inspect(module)} cannot be paired with atomic conditions, as it does not implement `atomic/3`" else raise "#{inspect(module)} must define at least one of `atomic/3`, `change/3` or `batch_change/3`" end end end end defp templated_opts({:templated, opts}, _actor, _tenant, _arguments, _context, _changeset), do: opts defp templated_opts(opts, actor, tenant, arguments, context, changeset) do Ash.Expr.fill_template( opts, actor: actor, tenant: tenant, args: arguments, context: context, changeset: changeset ) end @doc false def get_read_action(resource, action, opts) do case opts[:read_action] do nil -> case action do %{atomic_upgrade_with: read_action} when not is_nil(read_action) -> Ash.Resource.Info.action(resource, read_action) || raise "No such read action in #{inspect(resource)}.#{action.name}.atomic_upgrade_with: #{read_action}" action when is_atom(action) and not is_nil(action) -> action = Ash.Resource.Info.action(resource, action) || raise "No such update action #{inspect(resource)}.#{action}" if read_action = action.atomic_upgrade_with do Ash.Resource.Info.action(resource, read_action) || raise "No such read action in #{inspect(resource)}.#{action.name}.atomic_upgrade_with: #{read_action}" else Ash.Resource.Info.primary_action!(resource, :read) end _ -> Ash.Resource.Info.primary_action!(resource, :read) end action -> Ash.Resource.Info.action(resource, action) end end defp clear_ref_metadata(record) do Ash.Resource.set_metadata(record, Map.delete(record.__metadata__, :bulk_action_ref)) end defp clear_ref_metadata_from_records(%{records: records} = result) when is_list(records) do %{result | records: Enum.map(records, &clear_ref_metadata/1)} end defp clear_ref_metadata_from_records(result), do: resultend