Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions documentation/topics/development/backwards-compatibility-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,23 @@ When a field is marked `sensitive?: true`, its value in validation error structs
replaced with a redacted placeholder via `Ash.Helpers.redact/1`. This applies to both
the non-atomic (`validate/3`) and atomic (`atomic/3`) code paths across all built-in
validations.

## many_to_many_destroy_destination_on_match?

```elixir
config :ash, many_to_many_destroy_destination_on_match?: true
```

### Old Behavior

When using `on_match: {:destroy, :action_name}` (2-tuple form) with a `many_to_many`
relationship, only the join record was destroyed using the given action. The
destination record was left intact, making it effectively the same as `:unrelate`.

### New Behavior

The 2-tuple `{:destroy, :action_name}` for `many_to_many` now destroys both
the destination record (using the given action) and the join record (using the
primary destroy action on the join resource). This makes the 2-tuple behavior
consistent with the `:destroy` shorthand and the explicit 3-tuple
`{:destroy, :dest_action, :join_action}`.
119 changes: 103 additions & 16 deletions lib/ash/actions/managed_relationships.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2306,6 +2306,25 @@ defmodule Ash.Actions.ManagedRelationships do
:missing ->
{:ok, current_value, []}

{:destroy, action_name, join_action_name} ->
case destroy_data_m2m(
source_record,
match,
domain,
actor,
opts,
action_name,
join_action_name,
changeset,
relationship
) do
{:ok, notifications} ->
{:ok, current_value, notifications}

{:error, error} ->
{:error, add_bread_crumb(error, relationship, :destroy)}
end

{:destroy, action_name} ->
case destroy_data(
source_record,
Expand Down Expand Up @@ -3277,6 +3296,38 @@ defmodule Ash.Actions.ManagedRelationships do
{:ok, []}
end

# Destroys both the join record and the destination record for a many_to_many relationship.
# Used by on_match: {:destroy, dest_action, join_action} (3-tuple) and on_match: :destroy shorthand.
defp destroy_data_m2m(
source_record,
record,
domain,
actor,
opts,
dest_action_name,
join_action_name,
changeset,
%{type: :many_to_many} = relationship
) do
with {:ok, join_notifications} <-
destroy_m2m_join_record(
source_record,
record,
actor,
opts,
join_action_name,
changeset,
relationship
),
{:ok, dest_notifications} <-
destroy_record(record, domain, actor, opts, dest_action_name, changeset, relationship) do
{:ok, join_notifications ++ dest_notifications}
end
end

# Destroys only the join record for a many_to_many relationship.
# Used by on_match: {:destroy, action} (2-tuple) for backward compatibility.
# Functionally equivalent to :unrelate — the destination record is left intact.
defp destroy_data(
source_record,
record,
Expand All @@ -3287,6 +3338,50 @@ defmodule Ash.Actions.ManagedRelationships do
changeset,
%{type: :many_to_many} = relationship
) do
destroy_m2m_join_record(
source_record,
record,
actor,
opts,
action_name,
changeset,
relationship
)
end

# Destroys the destination record directly. Used by non-many_to_many relationships.
defp destroy_data(
_source_record,
record,
domain,
actor,
opts,
action_name,
changeset,
relationship
) do
destroy_record(
record,
domain,
actor,
opts,
action_name,
changeset,
relationship
)
end

# Finds and destroys a single join record for a many_to_many relationship.
# Looks up the join record by source and destination attribute values, then destroys it.
defp destroy_m2m_join_record(
source_record,
record,
actor,
opts,
action_name,
changeset,
relationship
) do
tenant = changeset.tenant

source_value = Map.get(source_record, relationship.source_attribute)
Expand All @@ -3312,6 +3407,10 @@ defmodule Ash.Actions.ManagedRelationships do
domain: domain(changeset, join_relationship)
)
|> case do
{:ok, nil} ->
debug_log(relationship.name, changeset, :read, :ok, opts[:debug?])
{:ok, []}

{:ok, result} ->
debug_log(relationship.name, changeset, :read, :ok, opts[:debug?])

Expand All @@ -3332,12 +3431,10 @@ defmodule Ash.Actions.ManagedRelationships do
|> 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} ->
Expand All @@ -3358,16 +3455,8 @@ defmodule Ash.Actions.ManagedRelationships do
end
end

defp destroy_data(
_source_record,
record,
domain,
actor,
opts,
action_name,
changeset,
relationship
) do
# Destroys a single record using the given action.
defp destroy_record(record, domain, actor, opts, action_name, changeset, relationship) do
tenant = changeset.tenant

record
Expand All @@ -3385,18 +3474,16 @@ defmodule Ash.Actions.ManagedRelationships do
|> Ash.Changeset.set_tenant(tenant)
|> Ash.destroy(return_notifications?: true)
|> case do
{:ok, _record, notifications} ->
{:ok, notifications} ->
debug_log(relationship.name, changeset, :destroy, :ok, opts[:debug?])

{:ok, notifications}

{: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
end
Expand Down
6 changes: 2 additions & 4 deletions lib/ash/actions/read/calculations.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1001,8 +1001,7 @@ defmodule Ash.Actions.Read.Calculations do
calculation.context.tracer,
domain,
ash_query.resource,
parent_stack:
Ash.Actions.Read.parent_stack_from_context(ash_query.context),
parent_stack: Ash.Actions.Read.parent_stack_from_context(ash_query.context),
source_context: ash_query.context
)

Expand Down Expand Up @@ -1187,8 +1186,7 @@ defmodule Ash.Actions.Read.Calculations do
calculation.context.tracer,
ash_query.domain,
ash_query.resource,
parent_stack:
Ash.Actions.Read.parent_stack_from_context(ash_query.context),
parent_stack: Ash.Actions.Read.parent_stack_from_context(ash_query.context),
source_context: ash_query.context
)

Expand Down
9 changes: 7 additions & 2 deletions lib/ash/changeset/changeset.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5654,11 +5654,16 @@ defmodule Ash.Changeset do
* `:update_join` - update only the join record (only valid for many to many)
* `{:update_join, :join_table_action_name}` - use the specified update action on a join resource
* `{:update_join, :join_table_action_name, [:list, :of, :params]}` - pass specified params from input into a join resource update action
* `{:destroy, :action_name}` - the record is destroyed using the specified action on the destination resource. The action should be:
* `many_to_many` - a destroy action on the join record
* `:destroy` - destroys the record using primary destroy actions. For `many_to_many`, destroys both the join record and the destination record.
* `{:destroy, :action_name}` - the record is destroyed using the specified action. The action should be:
* `many_to_many` - by default, a destroy action on the join resource only (the destination record is NOT destroyed).
When `config :ash, many_to_many_destroy_destination_on_match?: true` is set, destroys both the destination record
(using the given action) and the join record (using the primary destroy action).
* `has_many` - a destroy action on the destination resource
* `has_one` - a destroy action on the destination resource
* `belongs_to` - a destroy action on the destination resource
* `{:destroy, :destination_action_name, :join_action_name}` - (many_to_many only) destroys both the destination record
using the first action and the join record using the second action
* `:error` - an error is returned indicating that a record would have been updated
* `:no_match` - follows the `on_no_match` instructions with these records
* `:missing` - follows the `on_missing` instructions with these records
Expand Down
14 changes: 13 additions & 1 deletion lib/ash/changeset/managed_relationship_helpers.ex
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,17 @@ defmodule Ash.Changeset.ManagedRelationshipHelpers do
{:unrelate, nil}

:destroy when is_many_to_many ->
destroy = primary_action_name!(relationship.destination, :destroy)
join_destroy = primary_action_name!(relationship.through, :destroy)
{:destroy, join_destroy}
{:destroy, destroy, join_destroy}

{:destroy, action} when is_many_to_many ->
if Application.get_env(:ash, :many_to_many_destroy_destination_on_match?, false) do
join_destroy = primary_action_name!(relationship.through, :destroy)
{:destroy, action, join_destroy}
else
{:destroy, action}
end

:destroy ->
destroy = primary_action_name!(relationship.destination, :destroy)
Expand Down Expand Up @@ -180,6 +189,9 @@ defmodule Ash.Changeset.ManagedRelationshipHelpers do
{:unrelate, _nil} ->
nil

{:destroy, destroy, join_destroy} when relationship.type == :many_to_many ->
all([destination(destroy), join(join_destroy, :*)])

{:destroy, join_destroy} when relationship.type == :many_to_many ->
all(join(join_destroy, :*))

Expand Down
6 changes: 6 additions & 0 deletions lib/mix/tasks/install/ash.install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ if Code.ensure_loaded?(Igniter) do
[:redact_sensitive_values_in_errors?],
true
)
|> Igniter.Project.Config.configure(
"config.exs",
:ash,
[:many_to_many_destroy_destination_on_match?],
true
)
end)
end
)
Expand Down
Loading
Loading