Paranoia nearly ruined my day (and data)
- Date
- 15 May, 2025
- Category
- code
- Tags
- Ruby on Rails
A few weeks ago, I needed to migrate a model whose primary keys were UUIDs and change them to integers (see following a PaperTrail of intermittently failing tests for more info). This meant I needed to also go through any tables with associations to that model and update the foreign key to use the new integer id. I moved the old ids to the :student_id_old
field on each model for safekeeping. This will become important later.
All the records appeared to be updated correctly, and the rake task I had written to make sure that the count and selected details of associations for a handful of students remained the same before and after the migration looked identical, so I figured things had gone off without a hitch and moved on.
About a week later, the clients let us know that in multiple places where they normally viewed historical student activities, they could only see the most recent item. I was a little nervous I had something to do with the problem, since the two places they had noticed something wrong were both models I had updated. But off the top of my head I couldn't think of why they would still have a recent record and not older ones, if it was something I broke during the migration. It was time to dig in!
A mysterious case of .unscoped
As I started investigating one place where the historical information was no longer displaying, I came across a query in the controller that I thought was interesting:
# the courses variable contains only those records that the specific user has authorization to access
StudentCourse.unscoped.where(course: courses).where.not(deleted_at: nil)
I didn't really understand why StudentCourse
was unscoped when there was no default scope set in the StudentCourse
model. At any rate, I decided I would move the query over into the model so that it would be easy to reuse (and test) StudentCourse.cancellations
instead. At the same time, I dropped the unscoped portion since it clearly wasn't needed.
# app/model/student_course.rb
scope :cancellations, ->(courses) { where(course: courses).where.not(deleted_at: nil) }
That done, I wrote a test where I created several StudentCourse records, only some of which were associated with a particular Course, and set the :deleted_at
value. I then passed the course to my new StudentCourse.cancellations
scope, expecting to get that single "deleted" record would show up. But my test failed. There were no results at all! After a little investigation, it became clear that the unscoped
portion of the query that I had spurned before was doing some important work. Anything with a value for :deleted_at
wasn't showing up without it. Where was that scope coming from?
Enter paranoia
If only I had looked at the top of the model, I might have noticed this line:
class StudentCourse < ApplicationRecord
acts_as_paranoid
Turns out that it's from a gem, Paranoia, which will mark a record as deleted and then hide it from you, rather than actually removing it from your database. The idea being that if you ever need to get the deleted information back, it's still relatively accessible, but otherwise your interactions with the model act as if those deleted records are truly deleted.
In this application, Paranoia is used to keep track of historical information. I might say "misused" because I am not convinced this is better than just adding a boolean field like "cancelled," but it is what it is. Anyway, let's say we've got students who can check out books. When a book is checked out, a record is added to StudentBook
. When a book is checked back in, the record is soft deleted. If you want to see what books a student has checked out in the past, student.student_books
won't help you -- because of the default scope to ignore "deleted" records, that would only return those currently checked out. You would need to alter the scope with either StudentBook.unscoped
or use a Paranoia helper like StudentBook.with_deleted
to see previously checked out books.
A close call
My lack of familiarity with the Paranoia functionality could have been disastrous if we had considered the data migration from a few weeks ago to be successful and removed all the previous student ids from related tables. Fortunately, that information still existed as :student_id_old
, so I was able to write a few lines to restore all the historical records that had been hidden.
meetings = StudentCourse.unscoped
.where(student_id: nil)
.where.not(deleted_at: nil, student_id_old: nil)
.find_in_batches
meetings.each do |batch|
batch.each do |record|
begin
student = Student.find_by!(uuid: record.student_id_old)
record.update_column(:student_id, student.id)
rescue ActiveRecord::RecordNotFound
puts "Was not able to find a matching student for #{record.student_id_old}"
end
end
end
The takeaways
Now I know what Paranoia does and I'll be prepared if I come across it in the future. However, I didn't enjoy the nasty surprise it gave me! Seems like some other people feel the same way, because the README currently says:
paranoia has some surprising behaviour (like overriding ActiveRecord's delete and destroy) and is not recommended for new projects.
Paranoia's successor project, Discard, looks a little more intuitive at a glance. I'll have to give it a try sometime to see if that impression holds up. Discard's README mentions a few things that still make me nervous, though, like that users you "discard" will still be able to log in unless if you alter the default devise behavior, though presumably you are aware of that if you are discarding a user record rather than destroying it like normal? Feels like something bad waiting to happen.
But one line of Discard's README resonated with me, after the confusion I had just suffered:
It's usually undesirable to add a default scope. It will take more effort to work around and will cause more headaches. If you know you need a default scope, it's easy to add yourself ❤️