jessica dussault

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 ❤️