Delete_all will surprise you
I was working recently on a Rails project and I faced an interesting behavior of delete_all
from ActiveRecord. In this post, I’ll go through the steps that I have done to understand what happened and how I did manage to get around it.
Preparation
Let’s start with an example for a has-many association:
class Author < ApplicationRecord
has_many :books
end
class Book < ApplicationRecord
belongs_to :author
end
Let’s add also a service to make the example more realistic.
class RemoveBooks
attr_reader :author
def initialize(author)
@author = author
end
def call
delete_books
# Submit Notification events
# Submit tracking events
end
private
def delete_books
author.books.delete_all
end
end
Of course, we should add a spec file :grin:
require 'rails_helper'
describe RemoveBooks do
let(:author) { Author.create(name: 'John doe') }
let!(:intro_to_ruby) do
Book.create(title: 'Intro to Ruby', author: author)
end
let!(:css_book) do
Book.create(title: 'Skip to MDN', author: author)
end
subject { described_class.new(author).call }
describe '#call' do
it 'deletes the associated books' do
expect { subject }.to change {
Book.where(author: author).count
}.by(-2)
end
end
end
As we can see, we are trying to test the deletion of records with the count method. Running the spec above will result in the following error:
Failures:
1) RemoveBooks#call deletes the associated books
Failure/Error: author.books.delete_all
ActiveRecord::NotNullViolation:
SQLite3::ConstraintException: NOT NULL constraint failed: books.author_id
That looks a bit weird, so if we experiment a bit and change RemoveBooks#delete_book
with the following snippets, the spec will pass:
def delete_books
author.books.destroy_all
end
or
def delete_books
author.books.each(&:delete)
end
In case, you are wondering why I’m using delete_all
, here is a reminder about the difference between destroy_all
and delete_all
from Rails docs:
Note: Instantiation, callback execution, and deletion of each record can be time consuming when you’re removing many records at once. It generates at least one SQL DELETE query per record (or possibly more, to enforce your callbacks). If you want to delete many rows quickly, without concern for their associations or callbacks, use delete_all instead.
So let’s get back to our debugging. We need to know what’s going on and the best place is, of course, the logs :scroll:
For convenience, I want to output SQL logs to STDOUT, so it will be easier to see the output when running RSpec.
# config/environment/test.rb
...
ActiveRecord::Base.logger = Logger.new(STDOUT)
After running the spec again, I noticed something interesting. As we can see from the screenshot, the method is trying to run an update query to nullify the association instead of a deleting it.
SQL logs
But why delete_all
is updating records instead of deleting them? :thinking:
Let’s head back to Rails docs and check the description of delete_all
for CollectionProxy
(in other words, it means calling delete_all on the association collection like: author.books.destroy_all
)
Deletes all the records from the collection according to the strategy specified by the :dependent option. If no :dependent option is given, then it will follow the default strategy.
For has_many associations, the default deletion strategy is :nullify. This sets the foreign keys to NULL.
Does it mean that we need to add the dependent
option to the association? Well, that depends on the requirements. But we can use a different approach:
def delete_books
Book.where(author: author).delete_all
end
Bingo, our specs pass :smiley:
Conclusion
We saw together the steps to debug destroy_all
from logging to checking the API docs. We should also keep in mind that we can use this approach to debug SQL queries in testing mode.
That’s it for today, happy debugging :wave: