When deletion of a parent record should be blocked by one or more conditions on the same child table, declare a has_many association per condition with its own scope and dependent: :restrict_with_error.
The pattern
The example below uses two scoped associations to keep things readable, but the same approach works with one, three, or any number of independent restrictions.
class Account < ApplicationRecord
has_many :subscriptions
has_many :active_subscriptions,
-> { where(status: :active) },
class_name: 'Subscription',
dependent: :restrict_with_error
has_many :recently_canceled_subscriptions,
-> { where(status: :canceled, canceled_at: 30.days.ago..) },
class_name: 'Subscription',
dependent: :restrict_with_error
before_destroy do
subscriptions.destroy_all
end
end
When account.destroy runs, Rails fires every restrict_with_error check first. If any scope returns at least one row, an error is added to the parent and destruction is aborted. If all scopes are empty, the before_destroy callback removes the remaining (unrestricted) child rows.
Why before_destroy instead of dependent: :destroy on the unscoped association
You might be tempted to write:
has_many :subscriptions, dependent: :destroy # WRONG in this pattern
But dependent: :destroy and dependent: :restrict_with_error both install before_destroy callbacks, and Rails fires them in declaration order. If :destroy runs first, it wipes out the very rows the :restrict_with_error scopes were supposed to find. The restriction silently passes, and the parent is deleted.
Putting cleanup into an explicit before_destroy block at the end of the class guarantees that the restrict checks run first against an unmodified table, and cleanup only happens once they have all passed.
Propagating the restriction up to a grandparent
If a grandparent (e.g. Organization) owns the parent through dependent: :destroy, the restriction propagates automatically. When the grandparent is destroyed, Rails calls destroy on each child. The child's restrict_with_error adds an error and aborts. The aborted child rolls back the surrounding transaction, and the grandparent is preserved.
class Organization < ApplicationRecord
has_many :accounts, dependent: :destroy
end
organization = organizations(:big_co)
account = organization.accounts.create!(...)
account.subscriptions.create!(status: :active)
organization.destroy
assert Organization.exists?(organization.id)
assert_includes organization.accounts.first.errors[:base], 'Cannot delete record because dependent active subscriptions exist'
The grandparent does not need its own restrict_with_error declaration. Forwarding through dependent: :destroy is sufficient, because the child's abort halts the whole destroy chain.
Two things to watch out for:
- The grandparent must use
dependent: :destroy, notdependent: :delete_all. The latter issues a singleDELETEand skips child callbacks entirely, so the restriction never runs. - If the grandparent has a
before_destroyof its own that wipes child rows ahead of the dependent chain (the same anti-pattern as before), the restriction is bypassed at the grandparent level too.