Understanding ActiveRecord Dependent Options
Rails makes defining relationships between models using Active Record associations super easy. Simply add a macro style declaration such as has_one
, has_many
, belongs_to
etc. to your model and Rails will take care of maintaining the relationship. However, some thought is required when it comes to removing data from the database, while maintaining data integrity such as foreign key constraints. All the Rails courses and tutorials I've done so far cover adding associations, but none have covered what happens when a record with associations needs to be removed.
Many years ago, before the likes of GDPR and privacy concerns, the "solution" was to never remove any data or use soft delete. But with increased privacy regulations and the right to be forgotten, applications need the ability to safely remove certain data.
The Rails Guide on Active Record Associations does cover deletion options. However, since there are so many options besides just for deletion, I found myself scrolling up and down through the docs and losing track of which side of the association was being referred to. Also, since different options can be specified on each side of an association, it can be tricky to reason about what would happen with various combinations of options.
A further complexity is that there are two different ways to remove an ActiveRecord model in rails: Delete and Destroy. They sound similar but do different things (more on this later). If you add that into the mix of removal options, the number of scenarios grows even further.
In the vein of "a picture is worth a thousand words", I decided to build a sample Rails application and do a deep dive on all the removal options for some commonly used associations and how they behave when deleting or destroying a record. This post will walk through the results of this.
For those in a hurry, you can skip ahead to the Which Should You Use? section.
Project Setup
I started by scaffolding a new Rails project with Ruby 3 and Rails 7, using the default SQLite database:
rbenv install 3.1.2
rbenv local 3.1.2
gem install rails -v 7.0.4
rails new learn-associations
I added the annotate gem to add the corresponding table schema as code comments above each model class when migrations are run:
# Gemfile
group :development do
gem "annotate"
end
I then generated several models to study the belongs_to
and has_many
associations. This is a very common use case. For example, in an e-commerce application a Customer has many Orders, and an Order belongs to a single Customer. Or a Product has many Sku variations, and a Sku belongs to a Product. For this sample application, I used the Book and Author models from the Active Record Associations Guide:
I also added a title
attribute to the Book
model to make it easier to look up individual books for removal.
Here are the commands to generate the models, which also generate the migrations to create the tables. I also generated a Post
model that is not associated with anything, to demonstrate the difference between destroy
and delete
in a simple case where there are no associated models.
# simple model with no associations to demonstrate difference between destroy and delete
bin/rails generate model post title:string body:text
# models with associations
bin/rails generate model author name:string
bin/rails generate model book title:string published_at:date author:references
Here are the migrations that are generated. Notice that the Book model has a foreign key reference to an Author since each Book belongs to one Author. The Post model is not related to anything:
class CreatePosts < ActiveRecord::Migration[7.0]
def change
create_table :posts do |t|
t.string :title
t.text :body
t.timestamps
end
end
end
class CreateAuthors < ActiveRecord::Migration[7.0]
def change
create_table :authors do |t|
t.string :name
t.timestamps
end
end
end
class CreateBooks < ActiveRecord::Migration[7.0]
def change
create_table :books do |t|
t.string :title
t.date :published_at
t.references :author, null: false, foreign_key: true
t.timestamps
end
end
end
After running the migrations, here are the model classes showing that an Author has many Books and a Book belongs to an Author. The Post model has no associations. The schema information was added by the annotate gem:
# == Schema Information
#
# Table name: posts
#
# id :integer not null, primary key
# body :text
# title :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Post < ApplicationRecord
end
# == Schema Information
#
# Table name: authors
#
# id :integer not null, primary key
# name :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Author < ApplicationRecord
has_many :books
end
# == Schema Information
#
# Table name: books
#
# id :integer not null, primary key
# published_at :date
# title :string
# created_at :datetime not null
# updated_at :datetime not null
# author_id :integer not null
#
# Indexes
#
# index_books_on_author_id (author_id)
#
# Foreign Keys
#
# author_id (author_id => authors.id)
#
class Book < ApplicationRecord
belongs_to :author
end
Let's seed some example Posts, Authors, and Books so that we can test what happens when trying to remove them:
# db/seeds.rb
# Post is a simple model with no associations to demonstrate the difference between
# ActiveRecord methods destroy and delete
Post.create!(title: "Hello Post", body: "Learning about destroy and delete in Rails.")
Post.create!(title: "Another Post", body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit.")
# Author with multiple books
author_ap = Author.create!(name: "Andrew Park")
# Author with a single book
author_jjk = Author.create!(name: "Julian James McKinnon")
# Author with no books
Author.create!(name: "John Doe")
# Create 3 books for Andrew Park and just one book for Julian James McKinnon
Book.create!(
[
{ title: "Python Programming for Beginners", published_at: "2022-07-20", author: author_ap },
{ title: "Machine Learning: 4 Books in 1", published_at: "2020-01-20", author: author_ap },
{ title: "Python for Data Analysis", published_at: "2021-01-20", author: author_ap },
{ title: "Computer Programming Crash Course: 7 Books in 1", published_at: "2021-01-20", author: author_jjk }
]
)
Run the seeds in both the development and test databases. We'll be using the development database to experiment a little, and later the test database to automate all the experiments:
bin/rails db:seed
RAILS_ENV=test bin/rails db:seed
If you want to follow along with the project, here is the source on Github.
Delete Destroy
I wanted to understand what would happen when attempting to remove various Author or Book records from the database with respect to their associations. However, there's an additional complexity in that there's two different methods to remove a record, which sound similar, but do slightly different things. From the Rails API Documentation:
- delete: Deletes the record in the database and freezes this instance to reflect that no changes should be made (since they can't be persisted). Returns the frozen instance. The row is simply removed with an SQL
DELETE
statement on the record's primary key, and no callbacks are executed. - destroy: Deletes the record in the database and freezes this instance to reflect that no changes should be made (since they can't be persisted). There's a series of callbacks associated with destroy. If the
before_destroy
callback throws:abort
the action is cancelled and destroy returnsfalse
. - destroy!: Similar to
destroy
, except raisesActiveRecord::RecordNotDestroyed
rather than returning false if the record is not destroyed.
To summarize the difference, delete
immediately invokes SQL to remove the record from the database, whereas destroy/destroy!
first invokes the model's before_destroy
callback which can potentially stop the deletion. I'm assuming this could be a place for cleanup code.
To understand this with a simple case, let's modify the Post
model, which has no associations, to add a before_destroy
callback. This callback will simply output a message:
class Post < ApplicationRecord
before_destroy :one_last_thing
def one_last_thing
puts "Post model #{id} will be destroyed"
end
end
Launch a Rails console with bin/rails c
and let's see how the removal methods behave:
post = Post.find_by(title: "Hello Post")
post.delete
# Post Destroy (3.8ms) DELETE FROM "posts" WHERE "posts"."id" = ? [["id", 5]]
post.frozen?
# true
another_post = Post.find_by(title: "Another Post")
another_post.destroy
# Post model 6 will be destroyed
# TRANSACTION (0.1ms) begin transaction
# Post Destroy (0.5ms) DELETE FROM "posts" WHERE "posts"."id" = ? [["id", 6]]
# TRANSACTION (1.1ms) commit transaction
From the above, we can see that delete
immediately invokes the SQL DELETE
statement whereas destroy
first invokes the before_destroy
callback. Now with this understood, we can move on to removal of records with associations.
Options Matrix
Recall we have Books that belong_to
an Author, and Author's have_many
books. Let's start by looking at the dependent options for belongs_to:
:destroy
when the object is destroyed, destroy will be called on its associated objects.:delete
when the object is destroyed, all its associated objects will be deleted directly from the database without calling their destroy method.
For example, to use the destroy
option, the Book model would look like this to indicate that whenever a Book is destroyed, its associated Author should also be destroyed:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
Here are the dependent options for has_many:
:destroy
causes all the associated objects to also be destroyed:delete_all
causes all the associated objects to be deleted directly from the database (so callbacks will not execute):destroy_async
when the object is destroyed, anActiveRecord::DestroyAssociationAsyncJob
job is enqueued which will call destroy on its associated objects. Active Job must be set up for this to work.:nullify
causes the foreign key to be set to NULL. Polymorphic type column is also nullified on polymorphic associations. Callbacks are not executed.:restrict_with_exception
causes an ActiveRecord::DeleteRestrictionError exception to be raised if there are any associated records:restrict_with_error
causes an error to be added to the owner if there are any associated objects
For example, to use the destroy
option, the Author model would look like this to indicate that whenever an Author is destroyed, its Books should also be destroyed:
class Author < ApplicationRecord
has_many :books, dependent: :destroy
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
Summing up the options, there are 2 dependent options on the belongs_to
association, plus the "option" to not specify anything which makes 3. There are 6 dependent options on the has_many
association, plus the do nothing "option" which makes 7.
On the has_many
side, the restrict_with_error
option behaves similarly to the restrict_with_exception
except that it populates the errors
property of the model instead of raising an exception. Similarly, the destroy_async
option behaves similarly to the destroy
except it does so via a job. To reduce the number of combinations and avoid the complexity of setting up ActiveJob in this analysis, will only consider restrict_with_exception
and leave off destroy_async
.
This means there are 3 * 5 = 15 possible combinations of dependent options:
Scenario | belongs_to | has_many |
---|---|---|
1 | not specified | not specified |
2 | not specified | :destroy |
3 | not specified | :delete_all |
4 | not specified | :nullify |
5 | not specified | :restrict_with_exception |
6 | :destroy | not specified |
7 | :destroy | :destroy |
8 | :destroy | :delete_all |
9 | :destroy | :nullify |
10 | :destroy | :restrict_with_exception |
11 | :delete | not specified |
12 | :delete | :destroy |
13 | :delete | :delete_all |
14 | :delete | :nullify |
15 | :delete | :restrict_with_exception |
For each combination, I want to understand what happens given the following:
- An Author with no books is destroyed.
- An Author with a single book is destroyed.
- An Author with multiple books is destroyed.
- 1 through 3 above but deleted instead of destroyed.
- A book belonging to an Author that only has that one book is destroyed.
- A book belonging to an Author that has multiple other books is destroyed.
- 5 through 6 above but deleted instead of destroyed.
So that's 7 actions * 15 scenarios = 105 actions. Definitely too much work to try out each one in the console, but easily handled by a test framework. I'll use RSpec as that's what I'm familiar with, and it automatically resets the database after each example.
Test Setup
After adding RSpec to the project, I added the following tests for the Author and Book models. These are exploratory tests, that is, they're being used to learn what will happen rather than declaring what should happen. That's why there are no expectations, and exceptions are caught and logged.
The bang version destroy!
is used since it raises an exception so its easier to see what went wrong. I'm also logging the beginning and ending of each test to make it clear in the log file what activity is coming from which test. Finally, a test helper is added to show the counts of how many authors and books were deleted at the end of each test:
# spec/helpers.rb
# Add `require "./spec/helpers"` to rails_helper.rb after Rails is loaded
module Helpers
def show_model_counts
original_author_count = 3
original_book_count = 4
Rails.logger.info(" NUMBER AUTHORS DELETED: #{original_author_count - Author.count}; NUMBER BOOKS DELETED: #{original_book_count - Book.count}")
end
end
# spec/models/author_spec.rb
require "rails_helper"
describe Author, type: :model do
before(:each) do |example|
Rails.logger.info("\n=== BEGIN TEST #{example.metadata[:full_description]} ===")
end
after(:each) do
show_model_counts
Rails.logger.info("=== END TEST ===\n")
end
describe "#destroy!" do
it "remove an author with no books" do
author = Author.find_by(name: "John Doe")
begin
author.destroy!
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove an author with a single book" do
author = Author.find_by(name: "Julian James McKinnon")
begin
author.destroy!
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove an author with multiple books" do
author = Author.find_by(name: "Andrew Park")
begin
author.destroy!
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
end
describe "#delete" do
it "remove an author with no books" do
author = Author.find_by(name: "John Doe")
begin
author.delete
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove an author with a single book" do
author = Author.find_by(name: "Julian James McKinnon")
begin
author.delete
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove an author with multiple books" do
author = Author.find_by(name: "Andrew Park")
begin
author.delete
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
end
end
# spec/models/book_spec.rb
require "rails_helper"
describe Book, type: :model do
before(:each) do |example|
Rails.logger.info("\n=== BEGIN TEST #{example.metadata[:full_description]} ===")
end
after(:each) do
show_model_counts
Rails.logger.info("=== END TEST ===\n")
end
describe "#destroy!" do
it "remove a book from author that only has that book" do
book = Book.find_by(title: "Computer Programming Crash Course: 7 Books in 1")
begin
book.destroy!
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove a book from author that has multiple books" do
book = Book.find_by(title: "Python Programming for Beginners")
begin
book.destroy!
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
end
describe "#delete" do
it "remove a book from author that only has that book" do
book = Book.find_by(title: "Computer Programming Crash Course: 7 Books in 1")
begin
book.delete
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
it "remove a book from author that has multiple books" do
book = Book.find_by(title: "Python Programming for Beginners")
begin
book.delete
rescue StandardError => e
Rails.logger.error("#{e.class} - #{e.message}")
end
end
end
end
Scenarios
In this next section, each model class will be modified with respect to its dependent option, then the tests will be run and the log files analyzed to understand the behavior of each option.
1: No Options
In this scenario, we do not specify any dependent
option on either side of the belongs_to/has_many
relationship. So the models simply look like this:
class Book < ApplicationRecord
belongs_to :author
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
Let's run the tests with bundle exec rspec
and then examine the log/test.log
file:
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author model 3 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (7.6ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author model 2 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (0.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (0.9ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.4ms) SAVEPOINT active_record_1
Book Destroy (3.2ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test Log Summary
- Author (i.e. model on the
has_many
side of the relationship) with one or more books cannot be removed. Bothdestroy!
anddelete
methods raiseActiveRecord::InvalidForeignKey
. - Author with no books can be successfully removed with either
destroy!
ordelete
methods. - Book instances (
belongs_to
side of relationship) can be destroyed or deleted with no errors and this action only affects the book, not the related author.
If your system never needs to delete any instance of the model on the has_many
side, then not specifying any dependent
options is ok, otherwise, consider one of the other scenarios.
2: Has Many Destroy
In this case, we leave the Book model with no dependent option, and specify destroy
on the Author model:
class Book < ApplicationRecord
belongs_to :author
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :destroy # causes all the associated objects to also be destroyed
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (1.6ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Book model 4 will be destroyed
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Book model 1 will be destroyed
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Book model 2 will be destroyed
Book Destroy (0.2ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 2]]
Book model 3 will be destroyed
Book Destroy (0.1ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 3]]
Author model 1 will be destroyed
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 3
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (1.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (1.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy/destroy!
on Author model (has_many
side of relationship) instance works to delete that author and also destroys each of the author's books. That is, each associated book has itsbefore_destroy
callback invoked, and then is deleted from the database with a SQL DELETE statement. Note that in the case of an Author with multiple Books, the books are deleted one at a time with multiple SQL DELETE statements. This is because each book also must have its callbacks invoked. - Calling
delete
on Author model fails on foreign key constraint for author instances that have one or more associated books. (delete
on Author model with no books succeeds). - Book instances (
belongs_to
side of relationship) can be destroyed or deleted with no errors and this action only affects the book, not the related author.
Use the dependent: :destroy
option to support a cascade style delete where a parent model and all its associations can be removed, with the associated models before_destroy
callbacks invoked for final cleanup.
3: Has Many Delete All
In this case, we leave the Book model with no dependent option, and specify delete_all
on the Author model:
class Book < ApplicationRecord
belongs_to :author
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :delete_all # causes all the associated objects to be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (0.5ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (0.6ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Delete All (0.3ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 3
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy/destroy!
on Author model (has_many
side of relationship) instance works to delete that author and also directly deletes each of the author's books. That is, each associated book will be deleted via SQL DELETE statement but will not have itsbefore_destroy
callback invoked. Unlike in Scenario 2 where the books were deleted one at a time, in this case, all of an author's books are deleted in a single SQL DELETE statement. - Calling
delete
on Author model fails on foreign key constraint for author instances that have one or more associated books. - Book instances (
belongs_to
side of relationship) can be destroyed or deleted with no errors and this action only affects the book, not the related author.
Use the dependent: :delete_all
option to support a cascade style delete where a parent model and all its associations can be removed, and where the associated models can be immediately deleted via SQL DELETE, with no need for their before_destroy
callbacks invoked, i.e. no need for any final cleanup.
4: Has Many Nullify
In this case, we leave the Book model with no dependent option, and specify nullify
on the Author model:
class Book < ApplicationRecord
belongs_to :author
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :nullify # causes the foreign key to be set to NULL
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (1.2ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (1.2ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 2]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::NotNullViolation - SQLite3::ConstraintException: NOT NULL constraint failed: books.author_id
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Update All (0.4ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::NotNullViolation - SQLite3::ConstraintException: NOT NULL constraint failed: books.author_id
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (1.0ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (1.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.6ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy!
on an Author with no books invokes itsbefore_destroy
callback then removes the author from the database. - Calling
destroy!
on an Author with one or more books fails, but not on a foreign key constraint, rather, anActiveRecord::NotNullViolation
. This is because it's attempting to updateauthor_id
on the associated Book models tonull
which is not allowed due to how the Book migration was defined. - Calling
delete
on an Author with one or more books fails, raisingActiveRecord::InvalidForeignKey
. - Book instances (
belongs_to
side of relationship) can be destroyed or deleted with no errors and this action only affects the book, not the related author.
The nullify
option could be useful if the system requirements were that an Author (parent model on the has_many
side) could be removed from the database, but its associated Books could not be removed. In order for this to work, the Book model (child model on the belongs_to
) side would need to allow its foreign keyauthor_id
to be null, i.e. the migration would have to look like this:
class CreateBooks < ActiveRecord::Migration[7.0]
def change
create_table :books do |t|
t.string :title
t.date :published_at
# Original column definition
# t.references :author, null: false, foreign_key: true
# Modified column definition to support nullify option on Author model
t.references :author
t.timestamps
end
end
end
With a null author_id
allowed, the Author destroy tests succeed in removing the Author from the database, and updating the Author's associated Books with a null author_id
as shown in this test log below, focusing only on the Author tests:.
Test log for Author destroy with null FK allowed on Book
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (0.4ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (1.6ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
In a real application, the code that handles or displays Books would have to be flexible enough to handle a null Author rather than assuming it will always be populated.
One thing to watch out with the nullify option, is that calling delete
on an author simply removes the author via SQL DELETE, but does not update the author's associated books to have a null author_id
. This leaves the books as orphans, referencing an author that no longer exists, as shown in this snippet of the test log:
Test log for Author delete with null FK allowed on Book
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
Lesson learned: When using the dependent: :nullify
, remember to use the destroy
method rather than delete
.
5: Has Many Restrict With Exception
In this case, we leave the Book model with no dependent option, and specify restrict_with_exception
on the Author model:
class Book < ApplicationRecord
belongs_to :author
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :restrict_with_exception # ActiveRecord::DeleteRestrictionError exception raised if there are any associated records
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Exists? (0.4ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 3], ["LIMIT", 1]]
Author model 3 will be destroyed
Author Destroy (2.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 2], ["LIMIT", 1]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 1], ["LIMIT", 1]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.2ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.6ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy!
on an Author with no books invokes itsbefore_destroy
callback then removes the author from the database. - Calling
destroy!
on an Author with one or more books fails, but not on a foreign key constraint, rather, anActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
. This is the behaviour ofrestrict_with_exception
. - Calling
delete
on an Author with one or more books fails, raisingActiveRecord::InvalidForeignKey
. - Book instances (
belongs_to
side of relationship) can be destroyed or deleted with no errors and this action only affects the book, not the related author.
Use this option if you want to prevent models with dependent models from being removed, but with an explicit exception that can be handled rather than catching a foreign key constraint violation from the database. Note that you have to use the destroy
method to raise DeleteRestrictionError
. The delete
method still raises ActiveRecord::InvalidForeignKey
.
6: Belongs to Destroy
In this case, we update the Book model to specify destroy
and the Author model is unspecified:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy # when the object is destroyed, destroy will be called on its associated objects
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author model 3 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (2.0ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author model 2 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (0.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.5ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (1.6ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (1.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (1.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author model 1 will be destroyed
Author Destroy (1.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Author tests behave the same as Scenario 1, where any author with an associated book cannot be removed due to foreign key constraint.
- Calling
destroy
on a book that belongs to an author that only has that one book, destroys both the book and the author. - Calling
destroy
on a book that belongs to an author that has other books as well fails. It attempts to delete the author but then runs into a foreign key constraint because there's still other books in the system that belong to that author. In this case, nothing is deleted. - Calling delete on a book (whether the author has only that one book or others as well), deletes only that book and does not attempt to go up to the author for further deletion.
Note that the Rails Guides warn not to use this dependent: :destroy
option in this way:
You should not specify this option on a belongs_to association that is connected with a has_many association on the other class. Doing so can lead to orphaned records in your database.
The test that attempts to destroy a book belonging to an author that has other books is where this issue could happen. If the associated author had been removed, that would leave the other books "orphaned". With a non nullable foreign key constraint on the Book model, this was not allowed.
However, if the Book migration allowed a nullable foreign key for author:
class CreateBooks < ActiveRecord::Migration[7.0]
def change
create_table :books do |t|
t.string :title
t.date :published_at
# Original column definition
# t.references :author, null: false, foreign_key: true
# For experimentation in allowing a nullable foreign key
t.references :author
t.timestamps
end
end
end
Then calling destroy on a book whose author has other books will remove both the book and author from the system, leaving the other books from this author as "orphans" in that they still have their author_id
property populated, but the referenced author has been removed.
Let's see this in a Rails console:
# book by author that has other books
book = Book.find_by(title: "Python Programming for Beginners")
# removing book also removes associated author, even though that author has other books!
book.destroy!
# Book model 1 will be destroyed
# TRANSACTION (0.1ms) begin transaction
# Book Destroy (0.8ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
# Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
# Author model 1 will be destroyed
# Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
# TRANSACTION (0.7ms) commit transaction
# this leaves some books still pointing to the deleted author - these are orphans
orphaned_books = Book.where(author_id: 1)
orphaned_books.count
# => 2
The dependent: :destroy
option on the belongs_to
side of a relationship could be useful in a 1-1 situation, for example, if an Author specified has_one
book instead of has_many
. But otherwise, it doesn't really make sense because either it will lead to orphan records or foreign key constraint errors in some cases.
7: Belongs to Destroy Has Many Destroy
In this case, the destroy
option is specified on both sides of the relationship:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy # when the object is destroyed, destroy will be called on its associated objects
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :destroy # causes all the associated objects to also be destroyed
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
This could be a strange situation as declaring destroy on each side says "when this model is destroyed, also destroy its associated models". This means destroying a book would attempt to destroy its author, but that in turn would attempt to destroy its book(s), could this cause an infinite destroy loop? Let's see what happens with the tests.
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (2.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (8.2ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Book model 4 will be destroyed
Book Destroy (0.6ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::RecordNotDestroyed - Failed to destroy Book with id=4
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Book model 1 will be destroyed
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::RecordNotDestroyed - Failed to destroy Book with id=1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.9ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.9ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.6ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (2.0ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Book model 2 will be destroyed
Book Destroy (0.1ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::RecordNotDestroyed - Failed to destroy Book with id=1
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy!
on an Author with no books invokes itsbefore_destroy
callback then removes the author from the database. - Calling
destroy!
on an Author with one or more books fails, but not on a foreign key constraint, rather, anActiveRecord::RecordNotDestroyed - Failed to destroy Book with id={author id}
. In this case it's attempting to destroy the Author's associated book(s) but failing to do so. Interesting that the error message doesn't say why it failed. Recall that in Scenario 2 where the destroy on the has_many side was used by itself, destroying an author with one or more books was successful. - Calling
delete
on an Author with one or more books fails, raisingActiveRecord::InvalidForeignKey
. - Calling
destroy!
on a book belonging to an Author that only has that one book works - both the book and its author are removed from the database, although it does run a query first to see if that author has any other books. - Calling
destroy!
on a book belonging to an Author that has other books fails withActiveRecord::RecordNotDestroyed - Failed to destroy Book with id={book_id}
. In Scenario 6, where destroy was only specified on Book but not Author, this case also failed but with a more useful error indicating why the book could not be removed:ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
. - Calling
delete
on a book, regardless of its author having just that book or others, successfully removes that book and does not attempt to remove any authors.
Even though Rails will not go into an infinite loop with destroy
on both sides of the relationship, this seems like an invalid combination. In a simple application such as this one, it's easy to see the destroy
on both sides and fix it (choose just one), but in a larger app with more models, it could be tricky to find.
8: Belongs to Destroy Has Many Delete All
In this case, the destroy
option is specified on Book and delete_all
on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy # when the object is destroyed, destroy will be called on its associated objects
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :delete_all # causes all associated objects to be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (1.1ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.8ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (0.7ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Delete All (0.4ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 3
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (1.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Book Delete All (0.1ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Book Delete All (0.1ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 3
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy!
an author has the same effect as it did in Scenario 3, in that it first directly removes all of the author's books via SQL DELETE, then calls the author'sbefore_destroy
callback, then removes the author from the database. - Calling
delete
on an author with books fails on a foreign key constraint. - Calling
destroy
on a book belonging to an author that only has that one book removes both the book and its associated author from the database. - Calling
destroy
on a book belonging to an author that has other books is the most interesting case - it removes the book from the database, then finds the author, then finds all the other books belonging to this author and removes those as well, then finally, it removes the author. - Calling
delete
on a book removes just that book.
I wouldn't use this combination as it can produce a surprising result when calling book.destroy
on a book that belongs to an author that also has other books. It ends up removing not just that book but all the other books written by this author. It's a kind of cascade but starting from a child, going up to the parent, then back down to other children.
9: Belongs to Destroy Has Many Nullify
In this case, the destroy
option is specified on Book and nullify
on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy # when the object is destroyed, destroy will be called on its associated objects
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :nullify # causes the foreign key to be set to NULL
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
In Scenario 4, we learned that in order for nullify
to work, the model on the other side of the relationship (Book) needs to allow a nullable foreign key. So we'll run the tests with the database having this migration for the books
table:
class CreateBooks < ActiveRecord::Migration[7.0]
def change
create_table :books do |t|
t.string :title
t.date :published_at
# t.references :author, null: false, foreign_key: true
# for experimentation in allowing a nullable foreign key
t.references :author
t.timestamps
end
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (0.5ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.6ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (0.7ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Update All (0.4ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (16.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (1.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Book Update All (0.1ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.0ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Book Update All (0.1ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.7ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an author with one or more books is successful. It first finds the author's associated books, updates theirauthor_id
column tonil
, then SQL DELETEs the author record. - Calling
delete
on an author is also successful. However, it does not update its associated book models to have a nullauthor_id
. This will leave book records "orphaned" in that they're referencing an author that no longer exists. - Calling
destroy
on a book that belongs to an author that only has that one book successfully removes first the book, and then the author from the database. - Calling
destroy
on a book that belongs to an author that has other books is interesting. The book and author are removed from the database, and also, any other books the author has are updated to have a nullauthor_id
. - Calling
delete
on any book executes SQL DELETE to remove just that book and doesn't affect any author or other books.
I wouldn't use this combination because there's a surprising result when calling destroy
on a book that belongs to an author that has other books. Not only does it remove that book and author, but also updates the author's other books, let's call them siblings to the deleted book, to have a null author_id
.
10: Belongs to Destroy Has Many Restrict With Exception
In this case, the destroy
option is specified on Book and restrict_with_exception
on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :destroy # when the object is destroyed, destroy will be called on its associated objects
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :restrict_with_exception # ActiveRecord::DeleteRestrictionError exception raised if there are any associated records
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Exists? (0.3ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 3], ["LIMIT", 1]]
Author model 3 will be destroyed
Author Destroy (1.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 2], ["LIMIT", 1]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 1], ["LIMIT", 1]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.9ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 2], ["LIMIT", 1]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Book Exists? (0.2ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 1], ["LIMIT", 1]]
TRANSACTION (0.2ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.7ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an author with one or more books fails due toActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
. This is the same behaviour observed in Scenario 5. - Calling
delete
on an author with one or more books also fails, but with aActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
error raised. Again, the same as Scenario 5. - Calling
destroy
on a book belonging to an author that only has that one book removes both the book and author. - Calling
destroy
on a book belonging to an author that has other books is interesting - it starts a SQL DELETE to remove the book, then goes up to the author to remove it as well (due todependent: :destroy
being specified on Book model), but then thedependent: :restrict_with_exception
option on Author model kicks in and raisesActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
. Since all this activity is wrapped in a transaction, it gets rolled back and nothing is removed. - Calling
delete
on any book executes SQL DELETE to remove just that book and doesn't affect any author or other books.
I wouldn't use this combination as it results in a conflict between the child model Book saying when I'm deleted, delete my parent, and the parent model Author saying, do not delete me if I have children.
11: Belongs to Delete
In this case, the delete
option is specified on Book and the Author model does not specify any dependent option:
class Book < ApplicationRecord
belongs_to :author, dependent: :delete # when the object is destroyed, all its associated objects will be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author model 3 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Author Destroy (0.7ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author model 2 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author model 1 will be destroyed
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.6ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author Destroy (0.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.4ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Similar to Scenario 6, when no dependent options are specified on the Author/has_many side of the relationship, trying to either
destroy
ordelete
an author with one or more books fails withActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
. - Calling
destroy
on a Book that belongs to an Author that only has that one book destroys the book (i.e. callbacks and SQL DELETE), and also deletes the Author (i.e. SQL DELETE, no callbacks). This is the effect of thedelete
option specified on Book. - Calling
destroy
on a Book that belongs to an Author that has other books as well first destroys the book, then attempts to SQL DELETE the author. This fails on a foreign key constraint, since the other books belonging to this Author are still in the database referencing it, and so the entire transaction is rolled back and nothing is removed. - Calling
delete
on a book invokes SQL DELETE on just that book, no other models are affected.
I wouldn't use this option as the results seem inconsistent with respect to removing child models (on the belongs_to side of the relationship). That is, if removing a book that belongs to an author that only has that one book, then both the child and parent models are removed (with the child model having its destroy callback invoked but the parent model does not). But if a child model belongs to a parent with other children, then nothing is removed.
12: Belongs to Delete Has Many Destroy
In this case, the delete
option is specified on Book and destroy
option is specified on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :delete # when the object is destroyed, all its associated objects will be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :destroy # causes all the associated objects to also be destroyed
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
Similar to Scenario 7, this seems like a situation that could lead to an infinite delete/destroy loop with each side of the relationship trying to remove the other. Let's see what happens:
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.3ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Book model 4 will be destroyed
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
Author model 2 will be destroyed
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Book model 1 will be destroyed
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (0.6ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (1.8ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.9ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.6ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Destroy (6.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.3ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (2.6ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an Author with a single book invokes destroy callback on the book and removes it from the database, and then removes the author from the database. Interesting that according to the test log, the SQL DELETE statement to delete the author runs before the author'sbefore_destroy
callback is invoked. This is different than in Scenario 2 in which thebefore_destroy
callback is invoked before the SQL DELETE. - Calling
destroy
on an Author with multiple books, finds all books belonging to this Author, invokes the first book's callback, then executes SQL DELETE on the first book, then SQL DELETE on the author. At that point, it fails on a foreign key constraint because the author still has other books remaining that reference it. Normally thedependent: :destroy
option on Author performs a cascading delete on all its Books but havingdependent: :delete
on the book prevents this. - Calling
delete
on an Author with one or more books fails on a foreign key constraint, becausedelete
method does not attempt to remove any associated models, and it would be invalid to have a book model referencing an author that no longer exists. - Calling
destroy
on a Book belonging to an Author that only has that one book successfully removes the book (first invoking its callback), then removes the author via SQL DELETE (no callbacks). - Calling
destroy
on a Book belonging to an Author that has other books fails on a foreign key constraint when attempting to SQL DELETE the author, because that would leave the other books referencing an author that no longer exists. - Calling
delete
on a book works to remove just that book.
I wouldn't use this combination of options as the desired cascading delete effect from an Author to its Books won't happen as expected. See Scenario 2 for the expected cascade.
13: Belongs to Delete Has Many Delete All
In this case, the delete
option is specified on Book and delete_all
option is specified on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :delete # when the object is destroyed, all its associated objects will be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :delete_all # causes all the associated objects to be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
Again a potential loop of deletes in that when a book is removed, it should delete its author, but when an author is removed, it should delete all its books.
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (0.1ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (1.8ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Delete All (0.4ms) DELETE FROM "books" WHERE "books"."author_id" = ? [["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 3
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (1.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.9ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.4ms) SAVEPOINT active_record_1
Book Destroy (1.0ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author Destroy (0.2ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Destroy (0.9ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (1.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.4ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an author with a single book first deletes the associated book, then invokes the author's callback, then deletes the author. - Calling
destroy
on an author with multiple books deletes all its associated books, then invokes the author's callback, then deletes the author. - Calling
delete
on an author with one or more books attempts to delete just that author, then fails on a foreign key constraint because there still exist books that reference this author. - Calling
destroy
on a book belonging to an author that only has that one book invokes the book's callback, deletes the book, then finds the associated author and deletes that. - Calling
destroy
on a book belonging to an author that has other books fails on a foreign key constraint, at the point when attempting to delete the associated author, because there are still other books referencing this author. - Calling
delete
on a book works to delete just that book.
It looks like these options don't interfere with each other and behave the same as if only one was specified. But having dependent: :delete
on the belongs_to side of the relationship is still problematic, as was explained in Scenario 11.
14: Belongs to Delete Has Many Nullify
In this case, the delete
option is specified on Book and nullify
option is specified on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :delete # when the object is destroyed, all its associated objects will be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :nullify # causes the foreign key to be set to NULL
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
Just like in Scenario 4, in order to observe the effects of nullify
, the books
migration needs to allow a nullable reference to author_id
:
class CreateBooks < ActiveRecord::Migration[7.0]
def change
create_table :books do |t|
t.string :title
t.date :published_at
# Original column definition
# t.references :author, null: false, foreign_key: true
# Modified column definition to support nullify option on Author model
t.references :author
t.timestamps
end
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Update All (0.2ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 3]]
Author model 3 will be destroyed
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Update All (0.4ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 2]]
Author model 2 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Update All (0.4ms) UPDATE "books" SET "author_id" = ? WHERE "books"."author_id" = ? [["author_id", nil], ["author_id", 1]]
Author model 1 will be destroyed
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (1.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author Destroy (0.1ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.7ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.5ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.3ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an author with one or more books first updates its associated book'sauthor_id
to nil, then invokes the author's callback and deletes the author. So no books are removed from the database, but the book records are updated to have a nil author_id. - Calling
delete
on an author, whether it has 0, 1, or more books deletes the author from the database, and does not attempt to remove or update the associated book records. This creates an orphan situation where book records are left in the database referencing an author that no longer exists. - Calling
destroy
on a book that belongs to an author that only has that one book invokes the book's callback and deletes it. Then it finds the associated author and deletes that as well. - Calling
destroy
on a book that belongs to an author that has other books deletes both the book and author, leaving the author's other books as orphans, i.e. referencing an author that no longer exists. This is different behaviour than when nullify was used in Scenario 9, in which case the other books were updated to have a nil author_id. - Calling
delete
on a book removes just that book.
Most surprising: The nullify option on the has_many side of the relationship behaves differently depending on whether the belongs_to specifies dependent option destroy or delete.
I wouldn't use this combination as there's some surprising results. Specifically calling destroy
on a book will result in its sibling books being made orphans because their author_id will still be populated, pointing to an author that no longer exists. With the nullify option on the author, it's expected that some books will have a nil author_id, but not that author_id could be populated but invalid.
15: Belongs to Delete Has Many Restrict With Exception
In this case, the delete
option is specified on Book and restrict_with_exception
option is specified on Author:
class Book < ApplicationRecord
belongs_to :author, dependent: :delete # when the object is destroyed, all its associated objects will be deleted directly from the database
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Book model #{id} will be destroyed")
end
end
class Author < ApplicationRecord
has_many :books, dependent: :restrict_with_exception # ActiveRecord::DeleteRestrictionError exception raised if there are any associated records
before_destroy :one_last_thing
def one_last_thing
Rails.logger.warn("Author model #{id} will be destroyed")
end
end
View test log
=== BEGIN TEST Author#destroy! remove an author with no books ===
Author Load (0.4ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Exists? (0.4ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 3], ["LIMIT", 1]]
Author model 3 will be destroyed
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
TRANSACTION (0.0ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with a single book ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 2], ["LIMIT", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#destroy! remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
TRANSACTION (0.0ms) SAVEPOINT active_record_1
Book Exists? (0.1ms) SELECT 1 AS one FROM "books" WHERE "books"."author_id" = ? LIMIT ? [["author_id", 1], ["LIMIT", 1]]
TRANSACTION (0.0ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with no books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "John Doe"], ["LIMIT", 1]]
Author Destroy (0.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 3]]
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with a single book ===
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Julian James McKinnon"], ["LIMIT", 1]]
Author Destroy (7.4ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Author#delete remove an author with multiple books ===
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."name" = ? LIMIT ? [["name", "Andrew Park"], ["LIMIT", 1]]
Author Destroy (0.8ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that only has that book ===
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book model 4 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
Author Load (0.2ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
Author Destroy (0.3ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 2]]
TRANSACTION (0.1ms) RELEASE SAVEPOINT active_record_1
NUMBER AUTHORS DELETED: 1; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#destroy! remove a book from author that has multiple books ===
Book Load (2.3ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book model 1 will be destroyed
TRANSACTION (0.1ms) SAVEPOINT active_record_1
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
Author Load (0.1ms) SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
Author Destroy (0.5ms) DELETE FROM "authors" WHERE "authors"."id" = ? [["id", 1]]
TRANSACTION (0.1ms) ROLLBACK TO SAVEPOINT active_record_1
ActiveRecord::InvalidForeignKey - SQLite3::ConstraintException: FOREIGN KEY constraint failed
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 0
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that only has that book ===
Book Load (0.2ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Computer Programming Crash Course: 7 Books in 1"], ["LIMIT", 1]]
Book Destroy (0.5ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 4]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
=== BEGIN TEST Book#delete remove a book from author that has multiple books ===
Book Load (1.1ms) SELECT "books".* FROM "books" WHERE "books"."title" = ? LIMIT ? [["title", "Python Programming for Beginners"], ["LIMIT", 1]]
Book Destroy (1.2ms) DELETE FROM "books" WHERE "books"."id" = ? [["id", 1]]
NUMBER AUTHORS DELETED: 0; NUMBER BOOKS DELETED: 1
=== END TEST ===
Test log summary
- Calling
destroy
on an author with no books removes it (callback + SQL DELETE). But for an author with one or more books, an error is raised:ActiveRecord::DeleteRestrictionError - Cannot delete record because of dependent books
. - Calling
delete
on an author with one or more books fails on a foreign key constraint. - Calling
destroy
on a book that belongs to an author with just that one book removes the book (callback + SQL DELETE), then deletes the author. - Calling
destroy
on a book that belongs to an author with other books fails to remove anything. This is because a foreign key constraint exception is raised when trying to delete the author, that would leave its other book records with invalid references. - Calling
delete
on a book works and only removes that book.
The combination of dependent: :delete
on belongs_to
and dependent: :restrict_with_exception
on has_many
behave the same as if each option was specified independently and the other side was not specified.
Which Should You Use?
Requirements
The answer, as in nearly all things tech is... it depends. Firstly, it depends on the business and/or legal requirements of the application. Which models need to be removed? For example, should a customer be able to click a button to remove their account? If so, what should happen to all other models that may refer to this customer record? For an e-commerce or SaaS application, the customer's shipping addresses and payment methods should probably be removed which would be a good use case for has_many :shipping_addresses, dependent: :destroy
option on the Customer model.
But what about the customer's orders, would removing those affect reporting and financial systems? How about any product reviews the customer may have written? This might be a good use case for allowing nullable foreign keys and using has_many: product_reviews, dependent: :nullify
if the review content is considered the company's content, not the customers. All these questions need to be answered.
Performance
Both dependent: :destroy
and dependent: :delete_all
on the has_many side will perform a cascade style delete. But the :destroy
option will execute multiple SQL DELETE statements, one for each associated model to be removed because its callbacks are also being invoked. Whereas the :delete_all
option will remove all associated models in a single SQL DELETE statement. If the parent model is expected to have a lot of children, using :destroy
could be noticeably slower than :delete_all
. If the callbacks absolutely have to be executed, consider the :destroy_async
option.
Surprises
Another factor to consider is the principle of least surprise. For example, the combination of dependent: :destroy
on the belongs_to side and dependent: :nullify
on the has_many side results in siblings having their foreign key references set to nil when one of them is destroyed. We saw this in Scenario 9 where having nullify on the has_many side causes the belongs_to: :destroy
to go from a book, up to its author for removal, and then back to all the other books to nullify their author_id foreign keys.
We also encountered surprising results in Scenario 8, with the combination of dependent: :destroy
on belongs_to and dependent :delete_all
on has_many. In this case, removing a book that belongs to an author that has other books resulted in all of these books being removed. I would avoid combinations of options that produce surprising results.
Consistency
Another thing to consider is consistency. It's one thing to allow nullable foreign keys, but in Scenario 14, the combination of dependent: :delete
on the belongs_to side, and dependent: :nullify
on the has_many side can result in books referencing an author that no longer exists. That is, their foreign key is not null, but specifies an author_id that no longer exists. Although ActiveRecord can handle this by simply returning nil
when referencing book.author
, it could result in inconsistencies if using direct SQL statements for a reporting system.
This inconsistency can also happen when using dependent: :nullify
as we learned in Scenario 4 when invoking the delete
method on the model with the has_many
rather than the destroy
method.
Combinations
Proceed with caution when combining options. From this exercise, we've learned that the same option can behave differently depending on what option if any is specified on the other side of the relationship. For example, when dependent: :destroy
is used on the has_many side by itself as in Scenario 2, an author with one or more books can be successfully destroyed and all its books are also destroyed. But when this option is combined with dependent: :destroy
on the belongs_to side as in Scenario 7, then the destroy method on an Author with one or more books fails. Even stranger, when this option is combined with dependent: :delete
on the belongs_to side as in Scenario 12, then an Author with a single book can be destroyed, but its before_destroy callback gets invoked after the SQL DELETE statement to remove it. And trying to destroy an Author with multiple books fails.
Orphans
Consider the advice in the Rails Guides, specifically the warning not to use dependent: :destroy
on the belongs_to
side of a one-to-many relationship due to possibility of orphan records. We tried this in a few scenarios, starting with Scenario 6. This case did not result in orphan records (as long as a nullable foreign key was not allowed) but trying to remove a book belonging to an author that had other books failed on a foreign key constraint. In this case ActiveRecord prevents orphan records, but will result in more complex code that needs to handle some entities can be removed and some cannot due to how many other entities their parent has. Scenarios 7, 8, 9, and 10 are other variations on using dependent: :destroy
on the belongs_to
side and all had some problems or possibly unintended consequences.
Removal Method
A final thing to be aware of is when using any of the dependent options to affect related entities: This behaviour only kicks in when invoking the destroy
or destroy!
methods on the model instance. The delete
method only invokes direct SQL on the model on which it is called and does not attempt to affect any of its associations.
Conclusion
This post has used exploratory tests to understand how ActiveRecord dependent options function in a one to many relationship to remove (or fail to remove) records from the database when used only on one, or both sides of the relationship. It has also explored differences in the delete
vs destroy
methods. It then covered some factors to consider in choosing which dependent option(s) to use.