Packages
ash
3.25.1
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/managed_relationships.ex
# SPDX-FileCopyrightText: 2019 ash contributors <https://github.com/ash-project/ash/graphs/contributors>## SPDX-License-Identifier: MITdefmodule Ash.Actions.ManagedRelationships do @moduledoc false # This shouldn't be its own flow, it should use regular lifecycle hooks alias Ash.Error.Changes.InvalidRelationship alias Ash.Error.Query.NotFound require Logger require Ash.Query import Ash.Expr def load(_domain, created, %{relationships: rels}, _) when rels == %{}, do: {:ok, created} def load(_domain, created, %{relationships: nil}, _), do: {:ok, created} def load(domain, created, changeset, engine_opts) do Enum.reduce_while(changeset.relationships, {:ok, created}, fn {key, value}, {:ok, acc} -> relationship = Ash.Resource.Info.relationship(changeset.resource, key) must_load_opts = [ type: relationship.type, action_type: changeset.action.type, could_be_related_at_creation?: engine_opts[:upsert?] || Map.get(relationship, :could_be_related_at_creation?, false) ] case Enum.filter(value, fn {_, opts} -> opts = Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts(relationship, opts) Ash.Changeset.ManagedRelationshipHelpers.must_load?( opts, must_load_opts ) end) do [] -> {:cont, {:ok, acc}} relationships -> authorize? = engine_opts[:authorize?] && Enum.any?(relationships, fn {_, opts} -> opts[:authorize?] end) actor = engine_opts[:actor] tenant = engine_opts[:tenant] || changeset.tenant case Ash.load(acc, key, authorize?: authorize?, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: tenant, domain: domain ) do {:ok, loaded} -> {:cont, {:ok, loaded}} {:error, error} -> {:halt, {:error, add_bread_crumb(error, relationship, :load)}} end end end) end def setup_managed_belongs_to_relationships(changeset, actor, engine_opts) do changeset.relationships |> Enum.map(fn {relationship, val} -> {Ash.Resource.Info.relationship(changeset.resource, relationship), val} end) |> Enum.filter(fn {relationship, _val} -> relationship.type == :belongs_to end) |> Enum.flat_map(fn {relationship, inputs} -> inputs |> Enum.reject(fn {_, opts} -> opts[:ignore?] || opts[:handled?] end) |> Enum.with_index() |> Enum.map(fn {{input, opts}, index} -> {{relationship, {input, opts}}, index} end) end) |> Enum.map(fn {{relationship, {[input], opts}}, index} -> {{relationship, {input, opts}}, index} {{relationship, {other, opts}}, index} -> {{relationship, {other, opts}}, index} end) |> Enum.sort_by(fn {{_rel, {_input, opts}}, _index} -> opts[:meta][:order] end) |> Enum.reduce_while({changeset, %{notifications: []}}, fn {{relationship, {input, opts}}, index}, {changeset, instructions} -> opts = Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts(relationship, opts) opts = Keyword.put(opts, :authorize?, !!(engine_opts[:authorize?] && opts[:authorize?])) pkeys = pkeys(relationship, opts) changeset = if changeset.action.type == :update do case Ash.load(changeset.data, relationship.name, domain: changeset.domain, authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), tenant: engine_opts[:tenant] ) do {:ok, result} -> {:ok, %{changeset | data: result}} {:error, error} -> {:error, error} end else {:ok, changeset} end case changeset do {:ok, changeset} -> changeset = if input in [nil, []] && opts[:on_missing] != :ignore do changeset |> maybe_force_change_attribute(relationship, :source_attribute, nil) |> Ash.Changeset.after_action( fn _changeset, result -> {:ok, Map.put(result, relationship.name, nil)} end, prepend?: true ) else changeset end current_value = case Map.get(changeset.data, relationship.name) do %Ash.NotLoaded{} -> nil other -> other end input = if is_list(input) do Enum.at(input, 0) else input end match = if input do find_match( List.wrap(current_value), input, pkeys, relationship, opts[:on_no_match] == :match ) else nil end case match do nil -> case opts[:on_lookup] do _ when is_nil(input) -> create_belongs_to_record( changeset, instructions, relationship, input, actor, index, opts ) :ignore -> create_belongs_to_record( changeset, instructions, relationship, input, actor, index, opts ) {_key, _create_or_update, read} -> if is_struct(input, relationship.destination) do changeset = changeset |> Ash.Changeset.set_context(%{ private: %{ belongs_to_manage_found: %{relationship.name => %{index => input}} } }) |> maybe_force_change_attribute( relationship, :source_attribute, Map.get(input, relationship.destination_attribute) ) {:cont, {changeset, instructions}} else Enum.find_value(pkeys(relationship, opts), fn fields -> if Enum.all?(fields, fn field -> has_input_value?(input, field) end) do Map.new(fields, &{&1, get_input_value(input, &1)}) end end) |> case do nil -> create_belongs_to_record( changeset, instructions, relationship, input, actor, index, opts ) keys -> relationship.destination |> Ash.Query.for_read(read, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), authorize?: opts[:authorize?], domain: domain(changeset, relationship), tenant: changeset.tenant ) |> Ash.Query.filter(^keys) |> sort_and_filter(relationship) |> Ash.Query.set_context(relationship.context) |> Ash.read_one() |> case do {:ok, nil} -> create_belongs_to_record( changeset, instructions, relationship, input, actor, index, opts ) {:ok, found} -> changeset = changeset |> Ash.Changeset.set_context(%{ private: %{ belongs_to_manage_found: %{ relationship.name => %{index => found} } } }) |> maybe_force_change_attribute( relationship, :source_attribute, Map.get(found, relationship.destination_attribute) ) {:cont, {changeset, instructions}} {:error, error} -> {:halt, {Ash.Changeset.add_error( changeset, add_bread_crumb(error, relationship, :lookup), [opts[:meta][:id] || relationship.name] ), instructions}} end end end end _value -> on_match = if is_tuple(opts[:on_match]) do elem(opts[:on_match], 0) else opts[:on_match] end case on_match do :destroy -> changeset = maybe_force_change_attribute( changeset, relationship, :source_attribute, nil ) {:cont, {changeset, instructions}} :unrelate -> changeset = maybe_force_change_attribute( changeset, relationship, :source_attribute, nil ) |> Ash.Changeset.set_context(%{ private: %{ belongs_to_manage_unrelated: %{relationship.name => %{index => true}} } }) {:cont, {changeset, instructions}} _ -> {:cont, {changeset, instructions}} end end {:error, error} -> {:halt, {:error, add_bread_crumb(error, relationship, :load)}} end end) |> validate_required_belongs_to(false) |> case do {:error, error} -> {:error, error} {changeset, instructions} -> changeset = Map.update!(changeset, :relationships, fn relationships -> Map.new(relationships, fn {rel, inputs} -> {rel, Enum.map(inputs, fn {input, config} -> {input, Keyword.put(config, :handled?, true)} end)} end) end) {changeset, instructions} end end defp has_input_value?(value, key) when is_map(value) do Map.has_key?(value, key) || Map.has_key?(value, to_string(key)) end defp has_input_value?(_, _), do: false defp get_input_value(value, key) when is_map(value) do case Map.fetch(value, key) do {:ok, value} -> value :error -> Map.get(value, to_string(key)) end end defp get_input_value(_, _), do: nil defp domain(changeset, relationship) do Ash.Domain.Info.related_domain(changeset, relationship, changeset.domain) end defp maybe_force_change_attribute(changeset, %{no_attributes?: true}, _, _), do: changeset defp maybe_force_change_attribute(changeset, relationship, key, value) do Ash.Changeset.force_change_attribute( changeset, Map.get(relationship, key), value ) end def validate_required_belongs_to(changeset_instructions_or_error, preflight? \\ true) def validate_required_belongs_to({:error, error}, _), do: {:error, error} def validate_required_belongs_to({%{valid?: false} = changeset, instructions}, _) do {changeset, instructions} end def validate_required_belongs_to({changeset, instructions}, preflight?) do changeset.resource |> Ash.Resource.Info.required_belongs_to_relationships() |> Enum.reduce( {changeset, instructions}, fn required_relationship, {changeset, instructions} -> changeset = if (preflight? || changeset.relationships[required_relationship.name]) && !changeset.context[:private][:error][required_relationship.name] do case Ash.Changeset.get_attribute(changeset, required_relationship.source_attribute) do nil -> Ash.Changeset.add_error( changeset, Ash.Error.Changes.Required.exception( field: required_relationship.name, type: :relationship ) ) _ -> changeset end else changeset end {changeset, instructions} end ) end defp create_belongs_to_record( changeset, instructions, relationship, input, actor, index, opts ) do if input in [nil, []] do {:cont, {changeset, instructions}} else case opts[:on_no_match] do ignore when ignore in [:ignore, :match] -> {:cont, {changeset, instructions}} :error -> if opts[:on_lookup] != :ignore do changeset = changeset |> Ash.Changeset.add_error( add_bread_crumb( NotFound.exception( primary_key: input, resource: relationship.destination ), relationship, :create ), [opts[:meta][:id] || relationship.name] ) |> Ash.Changeset.put_context(:private, %{ error: %{relationship.name => true} }) {:halt, {changeset, instructions}} else changeset = changeset |> Ash.Changeset.add_error( add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "changes would create a new related record" ), relationship, :create ) ) |> Ash.Changeset.put_context(:private, %{ error: %{relationship.name => true} }) {:halt, {changeset, instructions}} end {:create, action_name} -> do_create_belongs_to_record( relationship, action_name, input, changeset, actor, opts, instructions, index ) end end catch {DBConnection, ref, error} -> throw({DBConnection, ref, set_error_path(error, relationship, index, opts)}) end defp do_create_belongs_to_record( relationship, action_name, input, changeset, actor, opts, instructions, index ) do input = Map.take( input, Enum.to_list(Ash.Resource.Info.action_inputs(relationship.destination, action_name)) ) relationship.destination |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_create(action_name, input, require?: false, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], skip_unknown_inputs: Map.keys(input), domain: domain(changeset, relationship) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.create(return_notifications?: true) |> case do {:ok, created, notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) changeset = changeset |> Ash.Changeset.set_context(%{ private: %{ belongs_to_manage_created: %{relationship.name => %{index => created}} } }) |> maybe_force_change_attribute( relationship, :source_attribute, Map.get(created, relationship.destination_attribute) ) {:cont, {changeset, %{instructions | notifications: instructions.notifications ++ notifications}}} {:error, error} -> debug_log( relationship.name, changeset, :create, {:error, error}, opts[:debug?] ) {:halt, {Ash.Changeset.add_error( changeset, add_bread_crumb(error, relationship, :create), [opts[:meta][:id] || relationship.name] ), instructions}} end end def manage_relationships(record, changeset, actor, engine_opts) do changeset.relationships |> Enum.map(fn {relationship, val} -> {Ash.Resource.Info.relationship(changeset.resource, relationship), val} end) |> Enum.flat_map(fn {key, batches} -> batches |> Enum.reject(fn {_, opts} -> opts[:ignore?] end) |> Enum.with_index() |> Enum.map(fn {{batch, opts}, index} -> opts = Keyword.put(opts, :authorize?, !!(engine_opts[:authorize?] && opts[:authorize?])) {key, batch, opts, index} end) end) |> Enum.sort_by(fn {_key, _batch, opts, _index} -> opts[:meta][:order] end) |> Enum.reduce_while({:ok, record, []}, fn {relationship, inputs, opts, index}, {:ok, record, all_notifications} -> inputs = List.wrap(inputs) case manage_relationship(record, relationship, inputs, changeset, actor, index, opts) do {:ok, record, notifications} -> record = if relationship.type == :many_to_many do Map.put( record, relationship.join_relationship, Map.get(record.__struct__.__struct__(), relationship.join_relationship) ) else record end {:cont, {:ok, record, notifications ++ all_notifications}} {:error, error} -> {:halt, {:error, error}} end end) end def pkeys(relationship, opts) do use_identities = case opts[:use_identities] do nil -> [:_primary_key] use_identities -> use_identities end use_identities = if opts[:identity_priority] do Enum.sort_by(use_identities, fn identity -> Enum.find_index(opts[:identity_priority], &(&1 == identity)) end) else use_identities end use_identities |> Enum.map(fn :_primary_key -> Ash.Resource.Info.primary_key(relationship.destination) identity -> identity = Ash.Resource.Info.identity(relationship.destination, identity) if is_nil(identity.where) do identity.keys else raise ArgumentError, "Cannot currently use identities with a `where` statement in managed_relationships. Got #{inspect(relationship.destination)}.#{identity.name}" end end) end defp manage_relationship( record, %{cardinality: :many} = relationship, inputs, changeset, actor, index, opts ) do inputs = List.wrap(inputs) opts = Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts(relationship, opts) pkeys = pkeys(relationship, opts) original_value = case Map.get(record, relationship.name) do %Ash.NotLoaded{} -> [] value -> value end case delete_unused( record, List.wrap(original_value), relationship, pkeys, inputs, changeset, actor, opts ) do {:ok, new_value, notifications} -> case maybe_bulk_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) do {:ok, new_value, notifications} -> new_value = if is_list(new_value) do if key = opts[:order_is_key] do Enum.sort_by(new_value, &Map.get(&1, key)) else sort_by_input_index(new_value) end else new_value end {:ok, Map.put(record, relationship.name, new_value), notifications} other -> other end {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, add_bread_crumb(error, relationship, :manage)} {:error, error} -> {:error, set_error_path(error, relationship, 0, opts)} end catch {DBConnection, ref, error} -> throw({DBConnection, ref, error}) {:rollback, value} -> index = case value do %Ash.Changeset{context: %{bulk_create: %{index: i}}} -> i _ -> 0 end value = Ash.Error.to_ash_error(value) throw({:rollback, set_error_path(value, relationship, index, opts)}) end defp manage_relationship( record, %{cardinality: :one} = relationship, inputs, changeset, actor, index, opts ) do opts = Ash.Changeset.ManagedRelationshipHelpers.sanitize_opts(relationship, opts) identities = relationship.destination |> Ash.Resource.Info.identities() |> Enum.map(& &1.keys) pkeys = [Ash.Resource.Info.primary_key(relationship.destination) | identities] original_value = if relationship.type == :belongs_to do Map.get(changeset.data, relationship.name) else Map.get(record, relationship.name) end original_value = case original_value do %Ash.NotLoaded{} -> [] value -> value end inputs = List.wrap(inputs) case delete_unused( record, List.wrap(original_value), relationship, pkeys, inputs, changeset, actor, opts ) do {:ok, current_value, notifications} -> inputs |> Enum.reduce_while( {:ok, current_value, notifications}, fn input, {:ok, current_value, all_notifications} -> case handle_input( record, current_value, original_value, relationship, input, pkeys, changeset, actor, index, opts ) do {:ok, new_value, notifications} -> {:cont, {:ok, new_value, notifications ++ all_notifications}} {:error, error} -> {:halt, {:error, add_bread_crumb(error, relationship, :manage)}} end end ) |> case do {:ok, new_value, all_notifications} -> {:ok, Map.put(record, relationship.name, Enum.at(List.wrap(new_value), 0)), all_notifications} {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, add_bread_crumb(error, relationship, :manage)} {:error, error} -> {:error, set_error_path(error, relationship, 0, opts)} end {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, add_bread_crumb(error, relationship, :manage)} {:error, error} -> {:error, Ash.Error.set_path( add_bread_crumb(error, relationship, :manage), opts[:error_path] || [opts[:meta][:id] || relationship.name] )} end catch {DBConnection, ref, error} -> throw({DBConnection, ref, set_error_path(error, relationship, 0, opts)}) end # Determines whether we can use bulk operations for the :many cardinality path. # If conditions are met, classifies inputs and batches creates together. # Falls back to individual processing otherwise. defp maybe_bulk_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) do if can_bulk_create?(relationship, opts) do bulk_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) else sequential_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) end end defp can_bulk_create?(relationship, opts) do opts[:bulk?] == true && case opts[:on_no_match] do {:create, _action_name} -> relationship.type != :many_to_many && Ash.DataLayer.data_layer_can?(relationship.destination, :bulk_create) {:create, _action_name, _join_action_name, _params} -> Ash.DataLayer.data_layer_can?(relationship.destination, :bulk_create) _ -> false end end defp sequential_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) do inputs |> Stream.with_index() |> Enum.reduce( {:ok, new_value, notifications, []}, fn {input, input_index}, {status, current_value, all_notifications, errors} -> try do case handle_input( record, current_value, original_value, relationship, input, pkeys, changeset, actor, index, opts ) do {:ok, next_value, notifications} -> {status, next_value, notifications ++ all_notifications, errors} {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, current_value, all_notifications, [add_bread_crumb(error, relationship, :manage) | errors]} {:error, error} -> {:error, current_value, all_notifications, [set_error_path(error, relationship, input_index, opts) | errors]} end catch {DBConnection, ref, error} -> throw({DBConnection, ref, set_error_path(error, relationship, input_index, opts)}) end end ) |> case do {:ok, final_value, final_notifs, []} -> {:ok, final_value, final_notifs} {:error, _value, _notifs, errors} -> {:error, Enum.reverse(errors)} end end defp bulk_manage_relationship( record, new_value, original_value, relationship, inputs, pkeys, changeset, actor, index, opts, notifications ) do # Classify all inputs into creates vs updates/lookups {creates, updates, lookups} = inputs |> Enum.with_index() |> Enum.reduce({[], [], []}, fn {input, input_index}, {creates, updates, lookups} -> match = find_match( List.wrap(original_value), input, pkeys, relationship, opts[:on_no_match] == :match ) if is_nil(match) || opts[:on_match] == :no_match do if opts[:on_lookup] != :ignore do {creates, updates, [{input, input_index} | lookups]} else {[{input, input_index} | creates], updates, lookups} end else {creates, [{input, input_index, match} | updates], lookups} end end) creates = Enum.reverse(creates) updates = Enum.reverse(updates) lookups = Enum.reverse(lookups) # Process lookups first - they may become creates or updates with {:ok, new_value, notifications, additional_creates} <- process_lookups_for_bulk( lookups, record, new_value, original_value, relationship, pkeys, changeset, actor, index, opts, notifications ), # Apply updates before batch-creating new related records. This preserves # the relative ordering of already-present vs newly-created items for # relationship lists backed by order-sensitive data layers (e.g. ETS). {:ok, new_value, notifications} <- process_updates( updates, record, new_value, relationship, changeset, actor, pkeys, opts, notifications ) do # Batch creates batch_creates( additional_creates ++ creates, record, new_value, relationship, changeset, actor, index, opts, notifications ) end end defp process_lookups_for_bulk( [], _record, new_value, _original_value, _relationship, _pkeys, _changeset, _actor, _index, _opts, notifications ) do {:ok, new_value, notifications, []} end defp process_lookups_for_bulk( lookups, record, new_value, _original_value, relationship, _pkeys, changeset, actor, index, opts, notifications ) do Enum.reduce(lookups, {:ok, new_value, notifications, [], []}, fn {input, input_index}, {status, current_value, all_notifications, creates, errors} -> case handle_create( record, current_value, relationship, input, changeset, actor, index, _pkeys = pkeys(relationship, opts), opts ) do {:ok, next_value, notifs} -> next_value = tag_input_index_if_prepended(next_value, current_value, input_index) {status, next_value, notifs ++ all_notifications, creates, errors} {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, current_value, all_notifications, creates, [add_bread_crumb(error, relationship, :manage) | errors]} {:error, error} -> {:error, current_value, all_notifications, creates, [set_error_path(error, relationship, input_index, opts) | errors]} end end) |> case do {:ok, final_value, final_notifs, creates, []} -> {:ok, final_value, final_notifs, creates} {:error, _value, _notifs, _creates, errors} -> {:error, Enum.reverse(errors)} end end defp process_updates( [], _record, new_value, _relationship, _changeset, _actor, _pkeys, _opts, notifications ) do {:ok, new_value, notifications} end defp process_updates( updates, record, new_value, relationship, changeset, actor, pkeys, opts, notifications ) do Enum.reduce(updates, {:ok, new_value, notifications, []}, fn {input, input_index, match}, {status, current_value, all_notifications, errors} -> try do case handle_update( record, current_value, relationship, match, input, changeset, actor, pkeys, opts ) do {:ok, next_value, notifs} -> next_value = tag_input_index_if_prepended(next_value, current_value, input_index) {status, next_value, notifs ++ all_notifications, errors} {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, current_value, all_notifications, [add_bread_crumb(error, relationship, :manage) | errors]} {:error, error} -> {:error, current_value, all_notifications, [set_error_path(error, relationship, input_index, opts) | errors]} end catch {DBConnection, ref, error} -> throw({DBConnection, ref, set_error_path(error, relationship, input_index, opts)}) end end) |> case do {:ok, final_value, final_notifs, []} -> {:ok, final_value, final_notifs} {:error, _value, _notifs, errors} -> {:error, Enum.reverse(errors)} end end defp batch_creates( [], _record, new_value, _relationship, _changeset, _actor, _index, _opts, notifications ) do {:ok, new_value, notifications} end defp batch_creates( creates, record, new_value, relationship, changeset, actor, index, opts, notifications ) do # Filter out struct inputs that don't need actual creation {struct_inputs, map_inputs} = Enum.split_with(creates, fn {input, _input_index} -> is_struct(input, relationship.destination) end) # Handle struct inputs immediately (they don't need creation) new_value = Enum.reduce(struct_inputs, new_value, fn {input, input_index}, acc -> [tag_input_index(input, input_index) | acc] end) # Check for belongs_to_manage_created context {already_created, to_create} = Enum.split_with(map_inputs, fn {_input, input_index} -> changeset.context[:private][:belongs_to_manage_created][relationship.name][input_index] != nil end) # Add already-created records to new_value new_value = Enum.reduce(already_created, new_value, fn {_input, input_index}, acc -> created = changeset.context[:private][:belongs_to_manage_created][relationship.name][input_index] [tag_input_index(created, input_index) | acc] end) if to_create == [] do {:ok, new_value, notifications} else case opts[:on_no_match] do {:create, action_name} -> do_batch_create( to_create, record, new_value, relationship, action_name, changeset, actor, opts, notifications ) {:create, action_name, join_action_name, params} -> do_batch_create_m2m( to_create, record, new_value, relationship, action_name, join_action_name, params, changeset, actor, opts, notifications ) _ -> # Shouldn't happen since can_bulk_create? already checked, but fall back sequential_creates( to_create, record, new_value, relationship, changeset, actor, index, opts, notifications ) end end end defp sequential_creates( creates, record, new_value, relationship, changeset, actor, index, opts, notifications ) do Enum.reduce(creates, {:ok, new_value, notifications, []}, fn {input, input_index}, {status, current_value, all_notifications, errors} -> case do_handle_create( record, current_value, relationship, input, changeset, actor, index, opts ) do {:ok, next_value, notifs} -> next_value = tag_input_index_if_prepended(next_value, current_value, input_index) {status, next_value, notifs ++ all_notifications, errors} {:error, %Ash.Error.Changes.InvalidRelationship{} = error} -> {:error, current_value, all_notifications, [add_bread_crumb(error, relationship, :manage) | errors]} {:error, error} -> {:error, current_value, all_notifications, [set_error_path(error, relationship, input_index, opts) | errors]} end end) |> case do {:ok, final_value, final_notifs, []} -> {:ok, final_value, final_notifs} {:error, _value, _notifs, errors} -> {:error, Enum.reverse(errors)} end end defp do_batch_create( to_create, record, new_value, relationship, action_name, changeset, actor, opts, notifications ) do # Prepare inputs - filter to only action-relevant keys create_inputs = Enum.map(to_create, fn {input, _input_index} -> input = if is_struct(input) do Map.from_struct(input) else input end Map.take( input, Enum.to_list(Ash.Resource.Info.action_inputs(relationship.destination, action_name)) ) end) source_value = Map.get(record, relationship.source_attribute) domain = domain(changeset, relationship) transform_changeset = fn cs -> maybe_force_change_attribute( cs, relationship, :destination_attribute, source_value ) end bulk_context = Map.take(changeset.context, [:shared]) |> Map.merge(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Map.merge(relationship.context || %{}) result = Ash.bulk_create( create_inputs, relationship.destination, action_name, transform_changeset: transform_changeset, skip_unknown_inputs: [:*], return_records?: true, return_notifications?: true, return_errors?: true, stop_on_error?: true, sorted?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: bulk_context, domain: domain, transaction: false ) case result do %Ash.BulkResult{status: :success, records: records, notifications: bulk_notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) new_value = Enum.zip(records || [], to_create) |> Enum.reduce(new_value, fn {created, {_, input_index}}, acc -> [tag_input_index(created, input_index) | acc] end) {:ok, new_value, (bulk_notifications || []) ++ notifications} %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :create, :error, opts[:debug?]) error = List.first(List.wrap(errors)) if error do {_, input_index} = List.first(to_create) error = error |> set_error_path(relationship, input_index, opts) {:error, error} else {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "bulk create failed" ), relationship, :create )} end end end defp do_batch_create_m2m( to_create, record, new_value, relationship, action_name, join_action_name, params, changeset, actor, opts, notifications ) do join_keys = params ++ Enum.map(params, &to_string/1) domain = domain(changeset, relationship) # Split inputs into join params and regular params {create_inputs, join_inputs_list} = to_create |> Enum.map(fn {input, _input_index} -> input = cond do is_struct(input, relationship.destination) -> input is_struct(input) -> Map.from_struct(input) true -> input end {join_params, regular_params} = split_join_keys(input, join_keys) regular_params = if is_struct(input, relationship.destination) do regular_params else Map.take( regular_params, Enum.to_list(Ash.Resource.Info.action_inputs(relationship.destination, action_name)) ) end {regular_params, join_params} end) |> Enum.unzip() # Check if any are already structs of the destination {struct_inputs, map_inputs_with_joins} = Enum.zip(create_inputs, join_inputs_list) |> Enum.zip(to_create) |> Enum.split_with(fn {{_input, _join}, {orig_input, _input_index}} -> is_struct(orig_input, relationship.destination) end) # Struct inputs don't need creation already_created = Enum.map(struct_inputs, fn {{_input, join_params}, {orig_input, input_index}} -> {orig_input, join_params, input_index} end) inputs_to_bulk_create = Enum.map(map_inputs_with_joins, fn {{input, join_params}, {_, input_index}} -> {input, join_params, input_index} end) input_indices_to_bulk_create = Enum.map(inputs_to_bulk_create, fn {_, _, input_index} -> input_index end) if inputs_to_bulk_create == [] do # Only struct inputs - just create join records create_join_records_for_m2m( already_created, record, new_value, relationship, join_action_name, changeset, actor, opts, notifications ) else # Bulk create destination records bulk_inputs = Enum.map(inputs_to_bulk_create, fn {input, _join_params, _input_index} -> input end) bulk_context = Map.take(changeset.context, [:shared]) |> Map.merge(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Map.merge(relationship.context || %{}) result = Ash.bulk_create( bulk_inputs, relationship.destination, action_name, skip_unknown_inputs: [:*], return_records?: true, return_notifications?: true, return_errors?: true, stop_on_error?: true, sorted?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: bulk_context, domain: domain, transaction: false ) case result do %Ash.BulkResult{ status: :success, records: created_records, notifications: bulk_notifications } -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) # Pair created records with their join params and input indexes created_with_joins = Enum.zip(created_records || [], inputs_to_bulk_create) |> Enum.map(fn {created, {_, join_params, input_index}} -> {created, join_params, input_index} end) all_for_join = already_created ++ created_with_joins create_join_records_for_m2m( all_for_join, record, new_value, relationship, join_action_name, changeset, actor, opts, (bulk_notifications || []) ++ notifications ) %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :create, :error, opts[:debug?]) error = List.first(List.wrap(errors)) if error do input_index = List.first(input_indices_to_bulk_create) error = error |> set_error_path(relationship, input_index, opts) {:error, error} else {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "bulk create failed" ), relationship, :create )} end end end end defp create_join_records_for_m2m( created_with_joins, record, new_value, relationship, join_action_name, changeset, actor, opts, notifications ) do source_value = Map.get(record, relationship.source_attribute) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) join_inputs = Enum.map(created_with_joins, fn {_created, join_params, _input_index} -> join_params end) destination_values = Enum.map(created_with_joins, fn {created, _join_params, _input_index} -> Map.get(created, relationship.destination_attribute) end) # Use bulk_create for join records if data layer supports it if Ash.DataLayer.data_layer_can?(relationship.through, :bulk_create) && length(join_inputs) > 1 do transform_changeset = fn cs -> # Get the index from bulk_create context to map to correct destination value idx = cs.context[:bulk_create][:index] dest_value = Enum.at(destination_values, idx) cs |> maybe_force_change_attribute( relationship, :source_attribute_on_join_resource, source_value ) |> maybe_force_change_attribute( relationship, :destination_attribute_on_join_resource, dest_value ) end bulk_context = Map.take(changeset.context, [:shared]) |> Map.merge(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Map.merge(join_relationship.context || %{}) result = Ash.bulk_create( join_inputs, relationship.through, join_action_name, transform_changeset: transform_changeset, skip_unknown_inputs: [:*], return_records?: false, return_notifications?: true, return_errors?: true, stop_on_error?: true, sorted?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: bulk_context, domain: domain(changeset, join_relationship), transaction: false ) case result do %Ash.BulkResult{status: :success, notifications: join_notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) created_records = Enum.map(created_with_joins, fn {created, _, input_index} -> tag_input_index(created, input_index) end) new_value = Enum.reduce(created_records, new_value, fn c, acc -> [c | acc] end) {:ok, new_value, (join_notifications || []) ++ notifications} %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :create, :error, opts[:debug?]) error = List.first(List.wrap(errors)) if error do error = error |> add_bread_crumb(relationship, :create) |> Ash.Error.set_path(opts[:error_path] || [opts[:meta][:id] || relationship.name]) {:error, error} else {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "bulk create of join records failed" ), relationship, :create )} end end else # Fall back to individual join record creation Enum.zip(created_with_joins, destination_values) |> Enum.reduce_while({:ok, new_value, notifications}, fn {{created, join_params, input_index}, dest_value}, {:ok, current_value, all_notifications} -> relationship.through |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Changeset.for_create(join_action_name, join_params, require?: false, authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, skip_unknown_inputs: Map.keys(join_params), domain: domain(changeset, join_relationship) ) |> maybe_force_change_attribute( relationship, :source_attribute_on_join_resource, source_value ) |> maybe_force_change_attribute( relationship, :destination_attribute_on_join_resource, dest_value ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.create(return_notifications?: true) |> case do {:ok, _join_row, join_notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) {:cont, {:ok, [tag_input_index(created, input_index) | current_value], join_notifications ++ all_notifications}} {:error, error} -> debug_log(relationship.name, changeset, :create, {:error, error}, opts[:debug?]) {:halt, {:error, add_bread_crumb(error, relationship, :create)}} end end) end end defp tag_input_index(record, input_index) do Ash.Resource.put_metadata(record, :__manage_relationship_index__, input_index) end defp tag_input_index_if_prepended(new_value, current_value, input_index) do case new_value do [head | ^current_value] -> [tag_input_index(head, input_index) | current_value] _ -> new_value end end defp sort_by_input_index(records) do case records do [%{__metadata__: %{__manage_relationship_index__: _}} | _] -> {managed, unmanaged} = Enum.split_with(records, fn %{__metadata__: metadata} -> is_map_key(metadata, :__manage_relationship_index__) _ -> false end) sorted_managed = managed |> Enum.sort_by(& &1.__metadata__[:__manage_relationship_index__]) |> Enum.map(fn record -> %{ record | __metadata__: Map.delete(record.__metadata__, :__manage_relationship_index__) } end) unmanaged ++ sorted_managed _ -> Enum.reverse(records) end end defp set_error_path(error, relationship, input_index, opts) do error = add_bread_crumb(error, relationship, :manage) case Keyword.fetch(opts[:meta] || [], :inputs_was_list?) do {:ok, false} -> Ash.Error.set_path( error, opts[:error_path] || [ opts[:meta][:id] || relationship.name ] ) _ -> Ash.Error.set_path( error, opts[:error_path] || [opts[:meta][:id] || relationship.name, input_index] ) end end defp handle_input( record, current_value, original_value, relationship, input, pkeys, changeset, actor, index, opts ) do match = find_match( List.wrap(original_value), input, pkeys, relationship, opts[:on_no_match] == :match ) if is_nil(match) || opts[:on_match] == :no_match do case handle_create( record, current_value, relationship, input, changeset, actor, index, pkeys, opts ) do {:ok, current_value, notifications} -> {:ok, current_value, notifications} {:error, error} -> {:error, error} end else handle_update( record, current_value, relationship, match, input, changeset, actor, pkeys, opts ) end end defp handle_create( record, current_value, relationship, input, changeset, actor, index, pkeys, opts ) do case opts[:on_lookup] do :ignore -> do_handle_create( record, current_value, relationship, input, changeset, actor, index, opts ) other -> case Map.fetch( changeset.context[:private][:belongs_to_manage_found][relationship.name] || %{}, index ) do :error -> {key, create_or_update, read, join_keys} = case other do {key, create_or_update, read} -> {key, create_or_update, read, []} {key, create_or_update, read, keys} -> {key, create_or_update, read, keys} end case Ash.Filter.get_filter(relationship.destination, input) do {:ok, keys} -> if is_struct(input, relationship.destination) do {:ok, input} else relationship.destination |> Ash.Query.for_read(read, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), authorize?: opts[:authorize?], domain: domain(changeset, relationship), tenant: changeset.tenant ) |> Ash.Query.filter(^keys) |> sort_and_filter(relationship) |> Ash.Query.set_context(relationship.context) |> Ash.Query.limit(1) |> Ash.read_one() end |> case do {:ok, found} when not is_nil(found) -> debug_log( relationship.name, changeset, :read, :ok, opts[:debug?] ) do_handle_found( relationship, join_keys, input, changeset.domain, opts, found, current_value, create_or_update, actor, key, record, changeset, pkeys ) {:ok, _} -> do_handle_create( record, current_value, relationship, input, changeset, actor, index, opts ) {:error, error} -> {:error, add_bread_crumb(error, relationship, :lookup)} end {:error, _error} -> do_handle_create( record, current_value, relationship, input, changeset, actor, index, opts ) end {:ok, found} -> case {relationship.type, other} do {:belongs_to, {:relate_and_update, action, _}} -> domain = domain(changeset, relationship) action_name = action || Ash.Resource.Info.primary_action!(relationship.destination, :update).name found |> Ash.Changeset.new() |> Ash.Changeset.set_context(relationship.context) |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_update(action_name, input, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], domain: domain, skip_unknown_inputs: Map.keys(input) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.update(return_notifications?: true) |> case do {:ok, updated, notifications} -> debug_log( relationship.name, changeset, :update, :ok, opts[:debug?] ) {:ok, [updated | current_value], notifications} {:error, error} -> debug_log( relationship.name, changeset, :update, :error, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :update)} end _ -> {:ok, [found | current_value], []} end end end end defp sort_and_filter(query, relationship) do # We cannot use relationship filters that reference the `parent` # because the parent is not yet related. if Ash.Actions.Read.Relationships.do_has_parent_expr?(relationship.filter) do query else filter = Ash.Expr.fill_template( relationship.filter, actor: query.context[:private][:actor], tenant: query.to_tenant, args: %{}, context: query.context ) Ash.Query.do_filter(query, filter, parent_stack: relationship.source) end |> Ash.Query.sort(relationship.sort, prepend?: true) end defp do_handle_found( relationship, join_keys, input, _domain, opts, found, current_value, create_or_update, actor, key, record, changeset, pkeys ) do case relationship.type do :many_to_many -> input = if is_map(input) do input else Enum.into(input, %{}) end {join_input, input} = split_join_keys(input, join_keys) join_relationship = Ash.Resource.Info.relationship( relationship.source, relationship.join_relationship ) relationship.through |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Changeset.for_create(create_or_update, join_input, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], require?: false, skip_unknown_inputs: Map.keys(join_input), domain: domain(changeset, join_relationship) ) |> maybe_force_change_attribute( relationship, :source_attribute_on_join_resource, Map.get(record, relationship.source_attribute) ) |> maybe_force_change_attribute( relationship, :destination_attribute_on_join_resource, Map.get(found, relationship.destination_attribute) ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.create(return_notifications?: true) |> case do {:ok, _created, notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) case key do :relate -> {:ok, [found | current_value], notifications} :relate_and_update -> case handle_update( record, current_value, relationship, found, input, changeset, actor, pkeys, opts ) do {:ok, new_value, update_notifications} -> {:ok, new_value, update_notifications ++ notifications} {:error, error} -> {:error, add_bread_crumb(error, relationship, :update)} end end {:error, error} -> debug_log( relationship.name, changeset, :create, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :create)} end type when type in [:has_many, :has_one] -> {found, input} = cond do is_struct(input, relationship.destination) -> {input, %{}} is_struct(input) -> {found, Map.from_struct(input)} true -> {found, input} end input = Map.take( input, Enum.to_list( Ash.Resource.Info.action_inputs(relationship.destination, create_or_update) ) ) found |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_update(create_or_update, input, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], skip_unknown_inputs: Map.keys(input), domain: domain(changeset, relationship) ) |> maybe_force_change_attribute( relationship, :destination_attribute, Map.get(record, relationship.source_attribute) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.update(return_notifications?: true) |> case do {:ok, updated, notifications} -> debug_log(relationship.name, changeset, :update, :ok, opts[:debug?]) {:ok, [updated | current_value], notifications} {:error, error} -> debug_log( relationship.name, changeset, :update, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :update)} end :belongs_to -> {:ok, [found | current_value], []} end end defp do_handle_create(record, current_value, relationship, input, changeset, actor, index, opts) do case opts[:on_no_match] do :error -> # Return NotFound for modify/remove operations or when on_lookup != :ignore if opts[:on_lookup] != :ignore || opts[:on_match] not in [:ignore, :error, :no_match, :missing] do {:error, add_bread_crumb( NotFound.exception( primary_key: input, resource: relationship.destination ), relationship, :create )} else {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "changes would create a new related record" ), relationship, :create )} end {:create, action_name} -> case changeset.context[:private][:belongs_to_manage_created][relationship.name][index] do nil -> created = if is_struct(input, relationship.destination) do {:ok, input, []} else input = if is_struct(input) do Map.from_struct(input) else input end input = Map.take( input, Enum.to_list( Ash.Resource.Info.action_inputs(relationship.destination, action_name) ) ) relationship.destination |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_create(action_name, input, require?: false, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], skip_unknown_inputs: Map.keys(input), domain: domain(changeset, relationship) ) |> maybe_force_change_attribute( relationship, :destination_attribute, Map.get(record, relationship.source_attribute) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.create(return_notifications?: true) end case created do {:ok, created, notifications} -> debug_log( relationship.name, changeset, :create, :ok, opts[:debug?] ) {:ok, [created | current_value], notifications} {:error, error} -> debug_log( relationship.name, changeset, :create, :error, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :create)} end created -> {:ok, [created | current_value], []} end {:create, action_name, join_action_name, params} -> join_keys = params ++ Enum.map(params, &to_string/1) input = cond do is_struct(input, relationship.destination) -> input is_struct(input) -> Map.from_struct(input) true -> input end {join_params, regular_params} = split_join_keys(input, join_keys) created = if is_struct(input, relationship.destination) do {:ok, input, []} else regular_params = Map.take( regular_params, Enum.to_list( Ash.Resource.Info.action_inputs(relationship.destination, action_name) ) ) relationship.destination |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_create(action_name, regular_params, require?: false, authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, skip_unknown_inputs: Map.keys(regular_params), domain: domain(changeset, relationship) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.create(return_notifications?: true) end case created do {:ok, created, regular_notifications} -> debug_log( relationship.name, changeset, :create, :ok, opts[:debug?] ) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) relationship.through |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Changeset.for_create(join_action_name, join_params, require?: false, authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, skip_unknown_inputs: Map.keys(join_params), domain: domain(changeset, join_relationship) ) |> maybe_force_change_attribute( relationship, :source_attribute_on_join_resource, Map.get(record, relationship.source_attribute) ) |> maybe_force_change_attribute( relationship, :destination_attribute_on_join_resource, Map.get(created, relationship.destination_attribute) ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.create(return_notifications?: true) |> case do {:ok, _join_row, notifications} -> debug_log(relationship.name, changeset, :create, :ok, opts[:debug?]) {:ok, [created | current_value], regular_notifications ++ notifications} {:error, error} -> debug_log( relationship.name, changeset, :create, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :create)} end {:error, error} -> debug_log( relationship.name, changeset, :create, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :create)} end ignore when ignore in [:ignore, :match] -> {:ok, current_value, []} end end defp handle_update( source_record, current_value, relationship, match, input, changeset, actor, pkeys, opts ) do domain = domain(changeset, relationship) case opts[:on_match] do # :create case is handled when determining updates/creates :error -> {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "changes would update a record" ), relationship, :update )} :ignore -> {:ok, [match | current_value], []} :missing -> {:ok, current_value, []} {:destroy, action_name} -> case destroy_data( source_record, match, domain, actor, opts, action_name, changeset, relationship ) do {:ok, notifications} -> {:ok, current_value, notifications} {:error, error} -> {:error, add_bread_crumb(error, relationship, :destroy)} end {:unrelate, action_name} -> case unrelate_data( source_record, match, domain, actor, opts, action_name, changeset, relationship ) do {:ok, notifications} -> new_value = Enum.reject(current_value, fn other -> Enum.any?(pkeys, fn pkey -> matches?(other, match, pkey, relationship) end) end) {:ok, new_value, notifications} {:error, error} -> {:error, add_bread_crumb(error, relationship, :unrelate)} end {:update, action_name} -> {match, input} = cond do is_struct(input, relationship.destination) -> {input, %{}} is_struct(input) -> {match, Map.from_struct(input)} true -> {match, input} end input = Map.take( input, Enum.to_list(Ash.Resource.Info.action_inputs(relationship.destination, action_name)) ) match |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.set_context(relationship.context) |> Ash.Changeset.for_update(action_name, input, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], domain: domain, skip_unknown_inputs: Map.keys(input) ) |> Ash.update(return_notifications?: true) |> case do {:ok, updated, update_notifications} -> debug_log(relationship.name, changeset, :update, :ok, opts[:debug?]) {:ok, [updated | current_value], update_notifications} {:error, error} -> debug_log( relationship.name, changeset, :update, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :update)} end {:update, action_name, join_action_name, params} -> join_keys = params ++ Enum.map(params, &to_string/1) {join_params, regular_params} = split_join_keys(input, join_keys) {match, regular_params} = cond do is_struct(regular_params, relationship.destination) -> {regular_params, %{}} is_struct(regular_params) -> {match, Map.from_struct(regular_params)} true -> {match, regular_params} end regular_params = Map.take( regular_params, Enum.to_list(Ash.Resource.Info.action_inputs(relationship.destination, action_name)) ) source_value = Map.get(source_record, relationship.source_attribute) update_result = if action_name do match |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_update(action_name, regular_params, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], domain: domain, skip_unknown_inputs: Map.keys(regular_params) ) |> Ash.Changeset.set_context(relationship.context) |> Ash.update(return_notifications?: true) else {:ok, match, []} end case update_result do {:ok, updated, update_notifications} -> debug_log(relationship.name, changeset, :update, :ok, opts[:debug?]) destination_value = Map.get(updated, relationship.destination_attribute) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) relationship.through |> Ash.Query.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Query.filter( ^ref(relationship.source_attribute_on_join_resource) == ^source_value ) |> Ash.Query.filter( ^ref(relationship.destination_attribute_on_join_resource) == ^destination_value ) |> Ash.Query.set_context(join_relationship.context) |> Ash.Query.limit(1) |> Ash.Query.set_tenant(changeset.tenant) |> Ash.read_one( domain: domain(changeset, join_relationship), authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]) ) |> case do {:ok, result} -> debug_log(relationship.name, changeset, :read, :ok, opts[:debug?]) join_relationship = Ash.Resource.Info.relationship( relationship.source, relationship.join_relationship ) result |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{ source: join_relationship.source, name: join_relationship.name } }) |> Ash.Changeset.for_update(join_action_name, join_params, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], skip_unknown_inputs: Map.keys(join_params), domain: domain(changeset, join_relationship) ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.update(return_notifications?: true) |> case do {:ok, _updated_join, join_update_notifications} -> debug_log( relationship.name, changeset, :update, :ok, opts[:debug?] ) {:ok, [updated | current_value], update_notifications ++ join_update_notifications} {:error, error} -> debug_log( relationship.name, changeset, :update, :error, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :update)} end {:error, error} -> debug_log( relationship.name, changeset, :read, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :lookup)} end {:error, error} -> debug_log( relationship.name, changeset, :update, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :update)} end end end @doc false def find_match([match], _input, _pkeys, %{cardinality: :one}, true) do match end def find_match(current_value, input, pkeys, relationship, _force_has_one?) do Enum.find(current_value, &matches_any?(&1, input, pkeys, relationship)) end defp matches_any?(current_value, input, pkeys, relationship) do Enum.any?(pkeys, fn pkey -> matches?(current_value, input, pkey, relationship) end) end defp matches?(current_value, input, pkey, relationship) do if relationship && relationship.type in [:has_one, :has_many] && relationship.destination_attribute in pkey do Enum.all?(pkey, fn field -> attr = Ash.Resource.Info.attribute(relationship.destination, field) if field == relationship.destination_attribute do if is_struct(input) do do_matches?(current_value, input, field, attr.type, attr.constraints) else # We know that it will be the same as all other records in this relationship # (because that's how has_one and has_many relationships work), so we # can assume its the same as the current value true end else do_matches?(current_value, input, field, attr.type, attr.constraints) end end) else Enum.all?(pkey, fn field -> attr = Ash.Resource.Info.attribute(relationship.destination, field) do_matches?(current_value, input, field, attr.type, attr.constraints) end) end end defp do_matches?(current_value, input, field, type, constraints) do with {:ok, current_val} when not is_nil(current_val) <- Map.fetch(current_value, field), {:ok, input_val} when not is_nil(input_val) <- fetch_field(input, field), {:ok, current_val} <- Ash.Type.cast_input(type, current_val, constraints), {:ok, input_val} <- Ash.Type.cast_input(type, input_val, constraints) do Ash.Type.equal?(type, current_val, input_val) else _ -> false end end defp split_join_keys(%_{__metadata__: metadata} = input, _join_keys) do {metadata[:join_keys] || %{}, input} end defp split_join_keys(input, []) do {%{}, input} end defp split_join_keys(input, :*) do {input, %{}} end defp split_join_keys(input, join_keys) do Map.split(input, join_keys ++ Enum.map(join_keys, &to_string/1)) end defp fetch_field(input, field) do case Map.fetch(input, field) do {:ok, value} -> {:ok, value} :error -> Map.fetch(input, to_string(field)) end end defp delete_unused( source_record, original_value, relationship, pkeys, inputs, changeset, actor, opts ) do domain = domain(changeset, relationship) {_kept, missing} = original_value |> List.wrap() |> Enum.split_with(fn value -> Enum.any?( inputs, &matches_any?(value, &1, pkeys, relationship) ) end) case opts[:on_missing] do :ignore -> {:ok, missing, []} :error when missing != [] -> {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "changes would destroy a record" ), relationship, :destroy )} :error -> {:ok, [], []} {:destroy, action_name} when missing != [] -> bulk_delete_unused_destroy( missing, relationship, action_name, changeset, actor, domain, opts ) {:destroy, _action_name} -> {:ok, [], []} {:destroy, action_name, join_action_name} when missing != [] -> bulk_delete_unused_m2m( missing, source_record, relationship, action_name, join_action_name, changeset, actor, domain, opts ) {:destroy, _action_name, _join_action_name} -> {:ok, [], []} {:unrelate, action_name} when missing != [] -> bulk_delete_unused_unrelate( missing, source_record, relationship, action_name, changeset, actor, domain, opts ) {:unrelate, _action_name} -> {:ok, [], []} end end defp bulk_delete_unused_destroy( missing, relationship, action_name, changeset, actor, domain, opts ) do if Ash.DataLayer.data_layer_can?(relationship.destination, :bulk_create) && length(missing) > 1 do transform_changeset = fn cs -> cs |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.set_context(relationship.context) end result = Ash.bulk_destroy( missing, action_name, %{}, transform_changeset: transform_changeset, resource: relationship.destination, return_notifications?: true, return_errors?: true, stop_on_error?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: Map.take(changeset.context, [:shared]), domain: domain, strategy: :stream, transaction: false ) case result do %Ash.BulkResult{status: :success, notifications: bulk_notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, [], bulk_notifications || []} %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :destroy, :error, opts[:debug?]) error = List.first(List.wrap(errors)) if error do {:error, add_bread_crumb(error, relationship, :destroy)} else {:error, add_bread_crumb( InvalidRelationship.exception( relationship: relationship.name, message: "bulk destroy failed" ), relationship, :destroy )} end end else # Fall back to individual destroys Enum.reduce_while(missing, {:ok, [], []}, fn record, {:ok, current_value, all_notifications} -> record |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_destroy(action_name, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: changeset.tenant, authorize?: opts[:authorize?], domain: domain ) |> Ash.Changeset.set_context(relationship.context) |> Ash.destroy(return_notifications?: true) |> case do {:ok, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:cont, {:ok, current_value, notifications ++ all_notifications}} {:ok, _soft_destroyed_record, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:cont, {:ok, current_value, notifications ++ all_notifications}} {:error, error} -> debug_log(relationship.name, changeset, :destroy, :error, opts[:debug?]) {:halt, {:error, add_bread_crumb(error, relationship, :destroy)}} end end) end end defp bulk_delete_unused_m2m( missing, source_record, relationship, action_name, join_action_name, changeset, actor, domain, opts ) do source_value = Map.get(source_record, relationship.source_attribute) destination_values = Enum.map(missing, &Map.get(&1, relationship.destination_attribute)) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) # Pass the query directly to bulk_destroy — no need to read first join_query = relationship.through |> Ash.Query.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Query.filter(^ref(relationship.source_attribute_on_join_resource) == ^source_value) |> Ash.Query.filter( ^ref(relationship.destination_attribute_on_join_resource) in ^destination_values ) |> Ash.Query.set_tenant(changeset.tenant) |> Ash.Query.set_context(join_relationship.context) |> sort_and_filter(relationship) join_destroy_result = Ash.bulk_destroy( join_query, join_action_name, %{}, return_notifications?: true, return_errors?: true, stop_on_error?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: Map.take(changeset.context, [:shared]), domain: domain(changeset, join_relationship), strategy: [:atomic, :atomic_batches, :stream], transaction: false ) case join_destroy_result do %Ash.BulkResult{status: :success, notifications: notifs} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) if action_name do destroy_missing_destinations( missing, relationship, action_name, changeset, actor, domain, notifs || [], opts ) else {:ok, [], notifs || []} end %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :destroy, :error, opts[:debug?]) error = List.first(List.wrap(errors)) {:error, add_bread_crumb(error || "bulk destroy failed", relationship, :destroy)} end end defp destroy_missing_destinations( missing, relationship, action_name, changeset, actor, domain, notifications, opts ) do if Ash.DataLayer.data_layer_can?(relationship.destination, :bulk_create) && length(missing) > 1 do transform_changeset = fn cs -> cs |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.set_context(relationship.context) end result = Ash.bulk_destroy( missing, action_name, %{}, transform_changeset: transform_changeset, resource: relationship.destination, return_notifications?: true, return_errors?: true, stop_on_error?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: Map.take(changeset.context, [:shared]), domain: domain, strategy: :stream, transaction: false ) case result do %Ash.BulkResult{status: :success, notifications: dest_notifs} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, [], (dest_notifs || []) ++ notifications} %Ash.BulkResult{status: :error, errors: errors} -> debug_log(relationship.name, changeset, :destroy, :error, opts[:debug?]) error = List.first(List.wrap(errors)) {:error, add_bread_crumb(error || "bulk destroy failed", relationship, :destroy)} end else Enum.reduce_while(missing, {:ok, [], notifications}, fn record, {:ok, current_value, all_notifications} -> destroy_destination( record, changeset, relationship, action_name, all_notifications, domain, current_value, actor, changeset.tenant, opts[:authorize?], opts[:debug?] ) end) end end defp bulk_delete_unused_unrelate( missing, source_record, relationship, action_name, changeset, actor, domain, opts ) do case relationship.type do :many_to_many -> # Pass the query directly to bulk_destroy — no need to read first source_value = Map.get(source_record, relationship.source_attribute) destination_values = Enum.map(missing, &Map.get(&1, relationship.destination_attribute)) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) join_query = relationship.through |> Ash.Query.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Query.filter( ^ref(relationship.source_attribute_on_join_resource) == ^source_value ) |> Ash.Query.filter( ^ref(relationship.destination_attribute_on_join_resource) in ^destination_values ) |> Ash.Query.set_tenant(changeset.tenant) |> Ash.Query.set_context(join_relationship.context) result = Ash.bulk_destroy( join_query, action_name, %{}, return_notifications?: true, return_errors?: true, stop_on_error?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: Map.take(changeset.context, [:shared]), domain: domain(changeset, join_relationship), strategy: [:atomic, :atomic_batches, :stream], transaction: false ) case result do %Ash.BulkResult{status: :success, notifications: notifs} -> {:ok, [], notifs || []} %Ash.BulkResult{status: :error, errors: errors} -> error = List.first(List.wrap(errors)) {:error, add_bread_crumb(error || "bulk unrelate failed", relationship, :unrelate)} end type when type in [:has_many, :has_one] -> # For has_many/has_one unrelate, bulk update to nil out the FK if Ash.DataLayer.data_layer_can?(relationship.destination, :bulk_create) && length(missing) > 1 do transform_changeset = fn cs -> cs |> Ash.Changeset.set_context(%{ accessing_from: %{ source: relationship.source, name: relationship.name, unrelating?: true } }) |> Ash.Changeset.set_context(relationship.context) |> maybe_force_change_attribute(relationship, :destination_attribute, nil) end result = Ash.bulk_update( missing, action_name, %{}, transform_changeset: transform_changeset, resource: relationship.destination, return_notifications?: true, return_errors?: true, stop_on_error?: true, authorize?: opts[:authorize?], actor: actor, tenant: changeset.tenant, context: Map.take(changeset.context, [:shared]), domain: domain, strategy: :stream, transaction: false ) case result do %Ash.BulkResult{status: :success, notifications: notifs} -> {:ok, [], notifs || []} %Ash.BulkResult{status: :error, errors: errors} -> error = List.first(List.wrap(errors)) {:error, add_bread_crumb(error || "bulk unrelate failed", relationship, :unrelate)} end else Enum.reduce_while(missing, {:ok, [], []}, fn record, {:ok, _, all_notifs} -> case unrelate_data( source_record, record, domain, actor, opts, action_name, changeset, relationship ) do {:ok, notifications} -> {:cont, {:ok, [], notifications ++ all_notifs}} {:error, error} -> {:halt, {:error, add_bread_crumb(error, relationship, :unrelate)}} end end) end :belongs_to -> {:ok, [], []} end end defp destroy_destination( record, changeset, relationship, action_name, notifications, domain, current_value, actor, tenant, authorize?, debug? ) do record |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_destroy(action_name, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), tenant: tenant, authorize?: authorize?, domain: domain ) |> Ash.Changeset.set_context(relationship.context) |> Ash.destroy(return_notifications?: true) |> case do {:ok, destroy_destination_notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, debug?) {:cont, {:ok, current_value, notifications ++ destroy_destination_notifications}} {:ok, _destroyed_destination, destroy_destination_notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, debug?) {:cont, {:ok, current_value, notifications ++ destroy_destination_notifications}} {:error, error} -> debug_log(relationship.name, changeset, :destroy, {:error, error}, debug?) {:halt, {:error, add_bread_crumb(error, relationship, :destroy)}} end end defp unrelate_data( source_record, record, _domain, actor, opts, action_name, changeset, %{type: :many_to_many} = relationship ) do tenant = changeset.tenant source_value = Map.get(source_record, relationship.source_attribute) destination_value = Map.get(record, relationship.destination_attribute) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) relationship.through |> Ash.Query.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Query.filter(^ref(relationship.source_attribute_on_join_resource) == ^source_value) |> Ash.Query.filter( ^ref(relationship.destination_attribute_on_join_resource) == ^destination_value ) |> Ash.Query.limit(1) |> Ash.Query.set_tenant(tenant) |> Ash.Query.set_context(join_relationship.context) |> Ash.read_one( authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), domain: domain(changeset, join_relationship) ) |> case do {:ok, result} -> debug_log(relationship.name, changeset, :read, :ok, opts[:debug?]) result |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Changeset.for_destroy(action_name, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), authorize?: opts[:authorize?], domain: domain(changeset, join_relationship) ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.Changeset.set_tenant(tenant) |> Ash.destroy(return_notifications?: true) |> case do {:ok, _record, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:ok, notifications} {:ok, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:error, error} -> debug_log( relationship.name, changeset, :destroy, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :unrelate)} end {:error, error} -> debug_log(relationship.name, changeset, :read, {:error, error}, opts[:debug?]) {:error, add_bread_crumb(error, relationship, :lookup)} end end defp unrelate_data( _source_record, record, domain, actor, opts, action_name, changeset, %{type: type} = relationship ) when type in [:has_many, :has_one] do tenant = changeset.tenant record |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name, unrelating?: true} }) |> Ash.Changeset.set_context(relationship.context) |> Ash.Changeset.for_update(action_name, %{}, authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), domain: domain ) |> maybe_force_change_attribute(relationship, :destination_attribute, nil) |> Ash.Changeset.set_tenant(tenant) |> Ash.update(return_notifications?: true) |> case do {:ok, _unrelated, notifications} -> debug_log(relationship.name, changeset, :update, :ok, opts[:debug?]) {:ok, notifications} {:error, error} -> debug_log( relationship.name, changeset, :update, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :unrelate)} end end defp unrelate_data( _source_record, _record, _domain, _actor, _opts, _action_name, _changeset, %{type: :belongs_to} ) do {:ok, []} end defp destroy_data( source_record, record, _domain, actor, opts, action_name, changeset, %{type: :many_to_many} = relationship ) do tenant = changeset.tenant source_value = Map.get(source_record, relationship.source_attribute) destination_value = Map.get(record, relationship.destination_attribute) join_relationship = Ash.Resource.Info.relationship(relationship.source, relationship.join_relationship) relationship.through |> Ash.Query.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Query.filter(^ref(relationship.source_attribute_on_join_resource) == ^source_value) |> Ash.Query.filter( ^ref(relationship.destination_attribute_on_join_resource) == ^destination_value ) |> Ash.Query.limit(1) |> Ash.Query.set_tenant(tenant) |> Ash.read_one( authorize?: opts[:authorize?], actor: actor, context: Map.take(changeset.context, [:shared]), domain: domain(changeset, join_relationship) ) |> case do {:ok, result} -> debug_log(relationship.name, changeset, :read, :ok, opts[:debug?]) result |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: join_relationship.source, name: join_relationship.name} }) |> Ash.Changeset.for_destroy(action_name, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), authorize?: opts[:authorize?], domain: domain(changeset, join_relationship) ) |> Ash.Changeset.set_context(join_relationship.context) |> Ash.Changeset.set_tenant(tenant) |> Ash.destroy(return_notifications?: true) |> case do {:ok, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:ok, _record, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:error, error} -> debug_log( relationship.name, changeset, :destroy, {:error, error}, opts[:debug?] ) {:error, add_bread_crumb(error, relationship, :destroy)} end {:error, error} -> debug_log(relationship.name, changeset, :read, {:error, error}, opts[:debug?]) {:error, add_bread_crumb(error, relationship, :lookup)} end end defp destroy_data( _source_record, record, domain, actor, opts, action_name, changeset, relationship ) do tenant = changeset.tenant record |> Ash.Changeset.new() |> Ash.Changeset.set_context(%{ accessing_from: %{source: relationship.source, name: relationship.name} }) |> Ash.Changeset.for_destroy(action_name, %{}, actor: actor, context: Map.take(changeset.context, [:shared]), authorize?: opts[:authorize?], domain: domain ) |> Ash.Changeset.set_context(relationship.context) |> Ash.Changeset.set_tenant(tenant) |> Ash.destroy(return_notifications?: true) |> case do {:ok, _record, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:ok, notifications} -> debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?]) {:ok, notifications} {:error, error} -> debug_log(relationship.name, changeset, :destroy, {:error, error}, opts[:debug?]) {:error, add_bread_crumb(error, relationship, :destroy)} end end defp add_bread_crumb(error, relationship, action) do Ash.Error.to_ash_error(error, nil, bread_crumbs: [ "Managed relationship #{action}: #{inspect(relationship.source)}.#{relationship.name}" ] ) end defp debug_log(relationship_name, changeset, action, response, debug?) do if debug? do action = action |> Atom.to_string() |> String.capitalize() info = "#{inspect(changeset.resource)}#{inspect(relationship_name)} with action #{changeset.action.name}" message = case response do :ok -> "#{action} success" {:error, error} -> ash_error = Ash.Error.to_ash_error(error) stacktrace = ash_error.stacktrace && ash_error.stacktrace.stacktrace error = Exception.format(:error, ash_error, stacktrace) """ Failed to #{action} #{error} """ _ -> "#{action}" end Logger.debug("Managed Relationship Debug, #{info}: #{message}.") end endend