Delete_all will surprise you

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:

NOTE: if you have specified the foreign key with null: true, the result will be that the count didn’t change.

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

Rails docs

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: