header-sample
Technology Software Ruby on Rails Toolset Soft Deletion Paranoia Discard
June 12th, 2019 - Dave Strock

Rails Soft Delete: Comparing Paranoia and Discard

Rails makes it very easy to build web apps that perform basic CRUD operations, which can be enough to handle the requirements of many applications.

One common use case that is a first step outside that pattern comes when the application needs to perform a soft delete, making records appear to be deleted while retaining the actual database record. This can be handled simply by adding a flag column and checking it each time you query the database. However, this soon becomes unwieldy and you’ll want additional features and a way to simplify usage.

acts_as_paranoid was an early Rails plugin that added the ability to hide and restore database records, but it fell into disrepair. Eventually it was rewritten to support Rails 3/4/5 in the form of the Paranoia gem, which gained wide popularity. However the project is now effectively deprecated as an acknowledgment of fundamental issues with the design of Paranoia. To this end, the Discard project was born.

Below is an overview of both libraries providing details about their usage, the issues with Paranoia's design, and an analysis of Discard to determine if it avoids Paranoia’s issues without introducing its own.

A Closer Look at Paranoia

Paranoia started as a reimplementation of acts_as_paranoid to support modern versions of Rails with far less code. It works by adding a deleted_at column to a model's table and then defining a default scope that ignores records containing a date in deleted_at, allowing the records to be hidden by default.

To get started, you need to add the column to your model’s database table, in this case users:


  class AddDeletedAtToUsers < ActiveRecord::Migration
    def change
      add_column :users, :deleted_at, :datetime
      add_index :users, :deleted_at
    end
  end
          

Then set the default scope to ignore deleted records by adding the acts_as_paranoid macro to the model class:


  class User < ActiveRecord::Base
    acts_as_paranoid
  end
            

This not only creates a default scope to ignore soft-deleted records, but also overrides the ActiveRecord destroy method to insert the current datetime into the deleted_at column instead of deleting the row:


  user = User.first
  user.deleted_at
    # => nil
  user.destroy
    # => user
  user.deleted_at
    # => [current timestamp]
            

To actually delete the database row, you can use the really_destroy! method:


  user.really_destroy!
            

Now Users.all will include all records that are not soft-deleted. To get all records, you must use the with_deleted scope:


  Users.with_deleted
            

This holds true for model associations, as well. If you want to include the soft-deleted records, you need to add a custom scope:


  class User < ActiveRecord::Base
    belongs_to :group, -> { with_deleted }
  end

  User.includes(:group).all
            
Assessment

This design makes the simple case of a single model quite trivial, but it leads to many less desirable behaviors as things get more complex. For one, overriding standard ActiveRecord methods makes things a lot more difficult to understand at a glance, particularly because most models won’t be using Paranoia but all will use the same database calls. The same applies to the setting of a default scope. An additional problem in that the unscoped method, the way to remove a default scope, removes more than expected, including associations which leads to silly errors far too often.

One of the biggest non-obvious issues when using Paranoia is that it attempts to recursively soft-delete. Any model associations that are configured with dependant: :destroy will be deleted (soft or otherwise) when the record is soft-deleted, regardless of whether the association model is also configured to use acts_as_paranoid. This leads many developers to just make any records that touch a soft-deleted record also acts_as_paranoid to avoid unintended data loss.

A Closer Look At Discard

Discard was written by a maintainer of the Paranoia project with the goal of removing the downsides of Paranoia — the most obvious of which are the created default scope and overriding ActiveRecord methods.

Discard uses an added column in the model's table, similar to Paranoia, and requires that each model be configured:


  class AddDiscardToUsers < ActiveRecord::Migration[5.0]
    def change
      add_column :users, :discarded_at, :datetime
      add_index :users, :discarded_at
    end
  end

  class User < ActiveRecord::Base
    include Discard::Model
  end
            

If you want to migrate from Paranoia, you can keep the same column name and just configure Discard to use the old column:


  class User < ActiveRecord::Base
    include Discard::Model
    self.discard_column = :deleted_at
  end
            

Discard does not override the ActiveRecord destruction methods — instead it adds its own:


  user = User.first
  user.discarded_at
    # => nil
  user.discard
    # => user
  user.discarded_at
    # => [current timestamp]
  user.discarded?
    # => true
  user.undiscard
    # => user
  user.discarded?
    # => false
            

Instead of automatically soft-deleting associated records, Discard favors a more explicit approach using callbacks to perform the soft-deletes on the association when needed. This allows for more granular control of the process:


  class Post < ActiveRecord::Base
    include Discard::Model

    scope :featured, -> { where.not(featured: nil) }
    scope :not_featured, -> { where(featured: nil) }
  end

  class User < ActiveRecord::Base
    include Discard::Model

    has_many :posts

    after_discard do
      posts.not_featured.discard_all
    end

    after_undiscard do
      posts.undiscard_all
    end
  end
  
Assessment

Since Discard provides the same feature set of Paranoia, removing a few of the rough edges seems to be a good replacement.