Cascade Soft-Deletes

Copy Markdown View Source

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 optionSoft-delete mode
on_delete: :delete_allsoft-deletes the child branch
on_delete: :nilify_allnoops because the parent row still exists
on_delete: :nothingnoops

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).

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:

# 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:

# Do not cascade (default association behaviour)
schema "posts" do
  has_many :comments, Comment
end
# Do not cascade (explicit)
schema "posts" do
  has_many :comments, Comment, on_delete: :nothing
end
# 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:

Repo.soft_delete(post)

Enable cascading for a specific call:

Repo.soft_delete(post, cascade: true)

You can also skip specific associations:

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:

@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
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 optionHard-delete mode
on_delete: :delete_allhard-deletes the child branch
on_delete: :nilify_allnulls the child foreign key
on_delete: :nothingnoops 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.