Deleting associated records [1d]
When you delete a record from a database, you have to decide what to do about associated records. Often associated records need the associated record to exist.
Important
Work on this lesson in
advisormode.
Learning goals
- You can explain what happens to associated records when their parent is destroyed under each
:dependentoption, and pick the right one for a relationship. - You can explain why we never want an invalid record in the database, and how deleting a parent can leave its children invalid.
- You can write validations that only apply when a value changes, so existing records stay valid.
- You can explain soft delete — marking records as deleted instead of removing them — and how listing, finding and associations behave for soft-deleted records.
- You can decide between
:dependentand soft delete from the shape of your model graph: compositions, inner nodes, leaves. - You can implement soft delete for a model, including its effects on forms, views, lookups and login.
- You pick the cheapest kind of test that proves each behavior instead of writing an end-to-end test for everything.
Resources
Read what's new to you, skim what's familiar, skip what you already master. Stop when you can meet the learning goals.
Your agent can also generate an overview, a tutorial or an explanation for anything here, tailored to what you already know. Just ask.
- 📄
Rails Guide: the
:dependentoption Show archive.org snapshot — what each option does when the parent goes away - 📄 assignable_values: values are only validated when they change Show archive.org snapshot — why a movie whose creator is gone can still be saved
- 📄 Discard README Show archive.org snapshot — soft deletes as a gem, and why it deliberately avoids default scopes; read the "Why not paranoia?" section even if you implement soft delete by hand
Understanding the :dependent option
What happens if you destroy a creator with associated movies and ...
- ... and nothing is specified?
- ... you use
has_many :movies, dependent: :destroy? - ... you use
has_many :movies, dependent: :delete_all? - ... you use
has_many :movies, dependent: :nullify? - ... you use
has_many :movies, dependent: :restrict_with_exception? - ... you use
has_many :movies, dependent: :restrict_with_error?
Here are some useful questions to evaluate each :dependent option:
- Can we delete a user when she still has associated movies?
- Do associated movie records still exist after the user was destroyed?
- Does a movie's show view still render without errors after its user was destroyed?
- Do movies stay valid after their creator was deleted?
- Also: When we open the form for a movie that had its user destroyed, does the user
<select>come up blank? - Also: Can we save an existing movie without changes, or do we get a validation error due to its missing creator?
- Also: When we open the form for a movie that had its user destroyed, does the user
Important
We never want a single invalid record in our database. It should always be able to update any record without figuring out what to do about its missing author.
Soft delete
Soft delete is an alternative approach to using :dependent:
- Records are not really deleted from the database. Instead they are merely marked as deleted, e.g. by setting a flag
trashed: true. - When listing records (e.g. on an index view) we only show records that are not marked as
trashed. To make this more convenient we like to define ascope :active, -> { where(trashed: false) }on soft-deletable models. - Controllers should no longer deliver show views or forms for a soft-deleted record, usually by scoping their
find()to only active records (e.g.Model.active.find(params[:id])). - We still allow soft-deleted records to be found when traversing
belongs_toassociations. E.g. accessingMovie#usershould still return the soft-deleted user. - Some apps also use soft-deletion to offer an "undelete" feature.
When to use :dependent, when to use soft delete?
To decide when to use which option, it helps to look at a UML class diagram Show archive.org snapshot of your model.
Use { dependent: :destroy } for compositions
When two models are a "composition" (filled diamond in UML terminology), the container should use dependent: destroy on its children.
Example: You might have an Invoice has_many :items. An invoice item cannot exist without a containing invoice, so we always destroy items when destroying invoices.
Use soft-delete for inner nodes in your object graph
When a model is a validated association for many other models, using { dependent :destroy } would cascade the deletion through a large part of your database.
Example: A senior employee leaves a company after 20 years, and that user is associated as the author of thousands of projects and documents. We probably want to keep these projects and documents after the employee is gone. So we implement soft-delete for the Employee model.
For leaves in your object graphs, soft-delete is optional
When a record has no has_many or has_one association, it is probably a leaf in your class diagram. Since the record's foreign key does not appear in any other table, we can freely delete it without affecting other records.
You might still want to implement soft-delete for such a leaf model, but only to offer an "undelete" feature.
Exercises
A creator for every movie
For this exercise, update your MovieDB so each movie must belong to a user:
class Movie < ApplicationRecord
belongs_to :creator, class_name: "User"
validates_presence_of :creator_id
end
class User < ApplicationRecord
has_many :movies, foreign_key: :creator_id
end
In a movie form an admin should be able to select a creator from all available users whose roles are either admin or writer. The movie's show view should also display the movie's associated user.
Imagine we want to be able to remove users. What should happen with the user's movies?
Conditional validations
The assignable_values gem solves the mentioned issue with persisted invalid records by only ever validating values
when they have just changed
Show archive.org snapshot
.
Now have a look at the following code snippet. It tries to solve a similar use case where vacation requests should only be made for future dates. As an exercise, identify the issue with the given implementation and come up with an improved version of it:
class VacationRequest < ApplicationRecord
validate :validate_future_date
private
def validate_future_date
if date && !date.future?
errors.add(:date, 'must be in the future')
end
end
end
Soft-deletable users
As an exercise, make users soft-deletable in MovieDB. Write tests to ensure the following behaviors:
- Soft-deleted users are no longer visible on the users index. Add the index if it does not yet exist.
- When creating a new movie, a soft-deleted user is no longer suggested in the
<select name="movie[user_id]">. - When we edit an existing movie with a soft-deleted user, that user should still be the preselected option in the
<select>. Only when we select another user and hit Save, the soft-deleted user disappears from the<select>. - On a movie's show view we can still see the associated user's name, even when that user was soft-deleted. However, the name should no longer link to the user's show view.
- Accessing a soft-deleted user's show view (f.ex. by entering the URL directly) shows a
404 not founderror. - Soft-deleted users may no longer log in.
- When a user is already logged in and then is soft-deleted, that user loses her authentication session.
Tip
Try to not use expensive E2E tests for everything. Try to use test types that are faster to write, run and maintain, like model specs, request specs or helper specs.
For this you may need to extract logic from your views into classes and modules. E.g. if you move an
if/elsestatement from a view to a helper, you can write a helper spec for that method and no longer need an E2E test.