Do not let conventions prevent correct behaviour.
Often within an ActiveRecord::Base model, statements of a certain ilk are grouped together. For example the single line validations are often in the same place. It is tempting to place additional statements such that the model file is consistent. One might put all the before_destroy callback macros near the before_save or after_save statements.
Without loss of generality the following example models illustrate a behavioural anomaly that was encountered.
ActiveRecord::Schema.define(:version => 1) do
create_table "accounts", :force => true do |t|
end
create_table "invoices", :force => true do |t|
t.integer "account_id"
end
create_table "receipts", :force => true do |t|
t.integer "account_id"
end
end
class Account < ActiveRecord::Base
has_many :receipts, :dependent => :destroy
has_many :invoices, :dependent => :destroy
before_destroy :ensure_no_invoices
def ensure_no_invoices
return true if invoices.empty?
false
end
end
class Invoice < ActiveRecord::Base
belongs_to :account
end
class Receipt < ActiveRecord::Base
belongs_to :account
end
The intent is to prevent the destruction of an account and related models when the account still has at least one invoice. The above code is how not to do it. The following sandboxed example demonstrates that the account will not be destroyed but that associated models will be.
test_before_destroy> ./script/console --sandbox
Loading development environment in sandbox (Rails 2.1.1)
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 1>
>> account.invoices.create
=> #<Invoice id: 1, account_id: 1>
>> account.receipts.create
=> #<Receipt id: 1, account_id: 1>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
=> false
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 0, 0]
The Rails documentation on before_destroy can be found on the callbacks entry. There is a vital clue stated there.
*IMPORTANT:* In order for inheritance to work for the callback queues, you must specify the callbacks before specifying the associations. Otherwise, you might trigger the loading of a child before the parent has registered the callbacks and they won‘t be inherited.
The documentation mentions only “inheritance” but further experimentation reveals that the desired behaviour of not destroying a model and related models is obtained when the documentation is heeded.
class Account < ActiveRecord::Base
before_destroy :ensure_no_invoices
has_many :receipts, :dependent => :destroy
has_many :invoices, :dependent => :destroy
def ensure_no_invoices
return true if invoices.empty?
false
end
end
Here the before_destroy macro is written before the associations. This is how to do it.
test_before_destroy> ./script/console --sandbox
Loading development environment in sandbox (Rails 2.1.1)
Any modifications you make will be rolled back on exit
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 2>
>> account.invoices.create
=> #<Invoice id: 2, account_id: 2>
>> account.receipts.create
=> #<Receipt id: 2, account_id: 2>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
=> false
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
Interleaving the before_destroy macro between the associations allows the destruction of one associated model but not the other.
class Account < ActiveRecord::Base
has_many :receipts, :dependent => :destroy
before_destroy :ensure_no_invoices
has_many :invoices, :dependent => :destroy
def ensure_no_invoices
return true if invoices.empty?
false
end
end
test_before_destroy> ./script/console --sandbox
Loading development environment in sandbox (Rails 2.1.1)
Any modifications you make will be rolled back on exit
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 3>
>> account.invoices.create
=> #<Invoice id: 3, account_id: 3>
>> account.receipts.create
=> #<Receipt id: 3, account_id: 3>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
=> false
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 0]
class Account < ActiveRecord::Base
has_many :invoices, :dependent => :destroy
before_destroy :ensure_no_invoices
has_many :receipts, :dependent => :destroy
def ensure_no_invoices
return true if invoices.empty?
false
end
end
test_before_destroy> ./script/console --sandbox
Loading development environment in sandbox (Rails 2.1.1)
Any modifications you make will be rolled back on exit
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 4>
>> account.invoices.create
=> #<Invoice id: 4, account_id: 4>
>> account.receipts.create
=> #<Receipt id: 4, account_id: 4>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
=> false
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 0, 1]
Note that when the before_destroy macro is declared after the associations and the specified check raises an exception, the script/console sandboxed environment does not rollback the destruction of the associated models because of the transactional nature of the entire script/console session.
class Account < ActiveRecord::Base
has_many :receipts, :dependent => :destroy
has_many :invoices, :dependent => :destroy
before_destroy :ensure_no_invoices
def ensure_no_invoices
return true if invoices.empty?
raise RuntimeError.new('Attempted to destroy account that still had invoices!')
end
end
test_before_destroy> ./script/console --sandbox
Loading development environment in sandbox (Rails 2.1.1)
Any modifications you make will be rolled back on exit
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 5>
>> account.invoices.create
=> #<Invoice id: 5, account_id: 5>
>> account.receipts.create
=> #<Receipt id: 5, account_id: 5>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
RuntimeError: Attempted to destroy account that still had invoices!
from ... (irb):6>> [Account.count, Invoice.count, Receipt.count]
=> [1, 0, 0]
When script/console is executed without the sandbox option, the effects of a rollback can be observed upon the attempted destruction of an account with invoices.
test_before_destroy> ./script/console
Loading development environment (Rails 2.1.1)
>> [Account.count, Invoice.count, Receipt.count]
=> [0, 0, 0]
>> account = Account.create
=> #<Account id: 6>
>> account.invoices.create
=> #<Invoice id: 6, account_id: 6>
>> account.receipts.create
=> #<Receipt id: 6, account_id: 6>
>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
>> account.destroy
RuntimeError: Attempted to destroy account that still had invoices!
from ... (irb):6>> [Account.count, Invoice.count, Receipt.count]
=> [1, 1, 1]
Read the documentation.
Put the before_destroy macro before associations.
The sandbox option in script/console may mask rollbacks.