Deleting Associated Data

Learning Goals

  • Understand dependencies between models based on their relationships
  • Delete child records when the associated parent record is removed

Introduction

In this lesson, we'll be adding delete functionality to our API so that users can remove a dog house from the database. We'll keep using the same starter code from the previous lesson. You can set up the models with:

$ bundle install
$ rails db:migrate db:seed

Deleting Associations

In our AirBudNB app, there is a one-to-many relationship between a dog house and its reviews:

AirBudNB entity relationship diagram

Recall that at the level of the database, this means that for every entry in the reviews table, there is a dog_house_id foreign key column that points to the row in the dog_houses table:

AirBudNB reviews table

Imagine we're creating a feature to give users the ability to delete a dog house from the site. When that dog house is deleted, what should happen to the reviews? Both from our users' perspective and from the database's perspective, it doesn't make much sense to keep a review around if there's no dog house for it to be associated with.

In fact, if you try removing a record from the database now, you'll see an error! Try this out in a Rails console session with rails c:

DogHouse.first.destroy
# => ActiveRecord::InvalidForeignKey (SQLite3::ConstraintException: FOREIGN KEY constraint failed)

The issue is that this dog house has reviews associated with it:

DogHouse.first.reviews
# => #ActiveRecord::Associations::CollectionProxy [#Review id: 1, ...

Those reviews must have a valid dog_house_id for their foreign key because of a database constraint that was established when we created the reviews table:

class CreateReviews < ActiveRecord::Migration[6.1]
  def change
    create_table :reviews do |t|
      t.string :username
      t.string :comment
      t.integer :rating

      # foreign_key: true establishes a relationship between a review and a dog house
      t.belongs_to :dog_house, null: false, foreign_key: true

      t.timestamps
    end
  end
end

So before removing the dog house, we must first remove the reviews. We can do this manually from the Rails console:

DogHouse.first.reviews.destroy_all

Notice in the SQL generated by Active Record, this finds all the associated reviews for the dog house and deletes them from the database:

  DogHouse Load (0.3ms)  SELECT "dog_houses".* FROM "dog_houses" ORDER BY "dog_houses"."id" ASC LIMIT ?  [["LIMIT", 1]]
  Review Load (0.2ms)  SELECT "reviews".* FROM "reviews" WHERE "reviews"."dog_house_id" = ?  [["dog_house_id", 1]]
  Review Destroy (0.4ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 1]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 2]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 3]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 4]]

After deleting the reviews, we can then safely delete the dog house:

DogHouse.first.destroy

However, there is a better way!

Using dependent: :destroy

As part of the class definition for our DogHouse model, we included the has_many association reference:

# app/models/dog_house.rb
class DogHouse < ApplicationRecord
  has_many :reviews
end

This is what lets us easily find all the reviews associated with a dog house instance by simply calling .reviews on any instance of the DogHouse class.

The has_many association reference also lets you provide additional options to customize its behavior. In our case (and in many cases involving a one-to-many relationship), we can use the dependent: :destroy option. This will tell Active Record to delete all the associated records when the parent record is deleted.

Exit the Rails console session, then add this code to the DogHouse class:

# app/models/dog_house.rb
class DogHouse < ApplicationRecord
  has_many :reviews, dependent: :destroy
end

So as soon as we call .destroy on an instance of a DogHouse, all the reviews associated with that instance will be destroyed! Restart the Rails console and try it out:

DogHouse.second.destroy

No more error! We were able to delete the dog house from the database along with its associated reviews with just this one line of code. And the SQL generated by Active Record matches our two-step approach from earlier of deleting the reviews first, then deleting the dog house:

  DogHouse Load (0.1ms)  SELECT "dog_houses".* FROM "dog_houses" ORDER BY "dog_houses"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 1], ["OFFSET", 1]]
  TRANSACTION (0.1ms)  begin transaction
  Review Load (0.1ms)  SELECT "reviews".* FROM "reviews" WHERE "reviews"."dog_house_id" = ?  [["dog_house_id", 2]]
  Review Destroy (0.4ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 5]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 6]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 7]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 8]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 9]]
  Review Destroy (0.1ms)  DELETE FROM "reviews" WHERE "reviews"."id" = ?  [["id", 10]]
  DogHouse Destroy (0.1ms)  DELETE FROM "dog_houses" WHERE "dog_houses"."id" = ?  [["id", 2]]

Conclusion

It's always a good idea to clean up any unused data in the database when deleting records, and to make sure there aren't any records that lose a necessary association when their parent record is deleted. With Active Record, we can use the dependent: :destroy option to automatically remove associated records when the parent record is deleted.

Check For Understanding

Before you move on, make sure you can answer the following question:

  1. In what situations would you need to use the dependent: :destroy option?

Resources