Skip to main content

guides/cascade-soft-deletes.md

# Cascade Soft-Deletes

When you soft-delete record(s), you can cascade soft-delete relationships.

Lazarus cascades only when the delete runs with `cascade: true`. Cascading is
disabled by default, so Lazarus otherwise soft-deletes only the records matched
by the delete call.

When cascading is enabled, Lazarus follows schema association metadata
recursively in a transaction:

| Association option       | Soft-delete mode                          |
| ------------------------ | ----------------------------------------- |
| `on_delete: :delete_all` | soft-deletes the child branch             |
| `on_delete: :nilify_all` | noops because the parent row still exists |
| `on_delete: :nothing`    | noops                                     |

If a related schema is being soft-deleted via `on_delete: :delete_all` but does
not include `deleted_at`, the operation raises `ArgumentError`. It will NOT
silently fail, skip, or hard delete.

Related schemas do not need `deleted_at` when that branch is hard-deleted via
`@hard_delete_on_cascade` (see
[Forcing hard delete during cascades](#forcing-hard-delete-during-cascades)).

If any of the soft-delete cascade branches fail, the whole transaction will be
rolled back.

## Schema examples

Suppose we have a `Post` which has many `Comment`'s

If we want `Repo.soft_delete(post, cascade: true)` to cascade soft-delete all
comments that belong to that post, we'd need to set them up like so:

```elixir
# comment.ex
schema "comments" do
  soft_deletes()
end

# post.ex
schema "posts" do
  has_many :comments, Comment, on_delete: :delete_all
end
```

These `Post` configurations would NOT cascade:

```elixir
# Do not cascade (default association behaviour)
schema "posts" do
  has_many :comments, Comment
end
```

```elixir
# Do not cascade (explicit)
schema "posts" do
  has_many :comments, Comment, on_delete: :nothing
end
```

```elixir
# Do not cascade (parent row still exists, so there's nothing to nilify)
schema "posts" do
  has_many :comments, Comment, on_delete: :nilify_all
end
```

## Controlling cascades

By default, cascading is disabled. A soft-delete call without `cascade: true`
only deletes the matched record:

```elixir
Repo.soft_delete(post)
```

Enable cascading for a specific call:

```elixir
Repo.soft_delete(post, cascade: true)
```

You can also skip specific associations:

```elixir
Repo.soft_delete(post, cascade: true, skip_associations: [:comments])
```

## Forcing hard delete during cascades

Use `@hard_delete_on_cascade` when a particular association should always be
physically deleted even though the parent delete is a soft delete:

```elixir
@hard_delete_on_cascade [:ratings]

schema "post" do
  has_many :comments, Comment, on_delete: :delete_all
  has_many :ratings, Rating, on_delete: :delete_all
end
```

```elixir
Repo.soft_delete(post, cascade: true)
```

In the example above, `:comments` are soft-deleted if they support soft
deletion, while `:ratings` are hard-deleted.

Associations listed in `@hard_delete_on_cascade` do not need `deleted_at`.
`@hard_delete_on_cascade` only switches associations that also use
`on_delete: :delete_all`; it does not override `on_delete: :nilify_all`.

Once Lazarus enters a hard-delete branch, descendant associations follow their
own schema metadata recursively:

| Association option       | Hard-delete mode                                          |
| ------------------------ | --------------------------------------------------------- |
| `on_delete: :delete_all` | hard-deletes the child branch                             |
| `on_delete: :nilify_all` | nulls the child foreign key                               |
| `on_delete: :nothing`    | noops and database constraints may reject the hard delete |

## Additional notes

Keep cascade rules in schemas, not only in database migrations: soft-delete
behavior is driven by Ecto schema metadata, not by database FK actions, so it's
recommended that you keep cascade rules in schemas rather than relying on
migrations/database only. Keep in mind defining them in both places can easily
drift out of sync.