Blog
0 pixels scrolled
  • Home
  • Work
  • Services
  • About
  • Blog
  • Contact
Introduction to Discard
Andrew Stewart on July 25, 2023
Soft deletes for ActiveRecord done right.
Introduction to Discard
Andrew Stewart on July 25, 2023
Posted in Rails
← Back to the blog

Greetings! I’m Andrew, a senior developer here at Super Good.

Today, we’re going to explore the history behind the discard gem, and how it can be used to add soft-deletion functionality to Rails/ActiveRecord models.

We’ll also cover some common rationale for implementing soft-deletion functionality, and briefly detour to discuss the history of how soft-deletion has been used in Solidus and Spree.

Finally, we’ll provide an example of how you can integrate discard with your own models!

What Soft-Deletion Is (And Why You Might Want It!)

While you might have not heard it by that name before, you have likely encountered soft-deletion before if you’ve ever used Windows’ Recycle Bin, moved a file to macOS’ Trash, or archived/deleted an email in Gmail.

Other apps (such as your phone’s photo gallery) might have a “Recently Deleted” folder that keeps ‘deleted’ items around for a while before permanently deleting them.

In all of these examples, the act of ‘deleting’ an item doesn’t permanently and immediately delete it — instead, the content is moved to a holding-pen of sorts, with more permanent deletion deferred for later (either manually or after a grace period).

Perhaps unsurprisingly then, a commonly cited reason for adding soft-delete functionality is to prevent unintended data loss. This affords a user the opportunity to restore/recover that file or email or photo that they accidentally deleted two weeks ago, but suddenly remembered that they really needed today – because it wasn’t deleted deleted yet.

How Does One Soft-Delete?

There’s a number of ways to go about implementing soft-deletion functionality, and which is best is mostly dependent on the type of software being built, and how users will interact with it. For example, moving a file to Trash on macOS relocates it to a hidden and specially-configured user folder, ~/.Trash/.

For our purposes, we’ll be focusing on a common approach for database-backed apps: tracking a separate deleted_at/discarded_at timestamp column. This approach keeps any soft-deleted records in the same table as their live/kept counterparts, and allows differentiating between them with scoped queries:

# find a kept (not soft-deleted) record
thing = Things.where(deleted_at: nil).first

# marks the record as soft-deleted
thing.update(deleted_at: Time.current)

This has historically been a popular soft-deletion strategy for Rails apps, and is used by both of the discard gem and the paranoia gem it replaces (with some key differences in approach, which we’ll discuss more in the next sections!).

Also note that soft-deletion requires considering the impacts on associated data — for example, if you were to soft-delete a Spree::Product, you likely want to do the same with any child Spree::Variant records it has. Your database cannot (easily) enforce this behaviour, so your application’s logic must do so.

Soft-Deletion in Solidus

In Solidus (as well as Spree), soft-deletion is valuable for models that may no longer be of interest to users but will retain historical and relational significance.

For example, admins may wish to tidy up old products which are no longer for sale. Those products are likely referenced by completed orders, which would no longer render correctly if the product were deleted.

In the earliest days, Spree developers used a hand-rolled approach to implementing soft-deletion functionality for products and variants. This was slowly expanded to include other models for which soft-deletion was found desirable. As of this writing, modern Solidus makes the following core models soft-deletable:

  • Spree::PaymentMethod
  • Spree::Price
  • Spree::Product
  • Spree::PromotionAction
  • Spree::ShippingMethod
  • Spree::StockItem
  • Spree::StoreCredit
  • Spree::StoreCreditEvent
  • Spree::TaxCategory
  • Spree::TaxRate
  • Spree::Variant

A few years into Spree’s development, core maintainer Ryan Bigg (aka radar) extracted and introduced the paranoia gem as a dependency, providing a reusable and easier-to-use abstraction that remains a popular RubyGem today.

paranoia is somewhat modelled after the even-earlier acts_as_paranoid gem, and provides a similar interface. It redefines both the default_scope and the #destroy method on any ActiveRecord models it is included in, in order to provide soft-delete functionality.

When the #destroy or #destroy! methods are called on a record that has paranoia mixed in, it is hidden but not deleted by setting the deleted_at timestamp. The modified default_scope then filters out any hidden records from normal Rails queries (i.e. Model.all). Another scope, .with_deleted, allows loading all records.

These overrides of the default ActiveRecord behaviour, while nice and magical on the surface, can and do lead to some rather confusing debugging sessions. Working around the modified default_scope is often frustrating, both for end users and the maintainers of paranoia

A number of years later, Solidus and paranoia maintainer John Hawthorn released the discard gem to resolve this confusion by providing a mostly drop-in replacement for ActiveRecord models already using paranoia, using much less code to do so, and providing much-improved developer semantics.

Discard does not change the default_scope or #destroy method of models it is included in. Instead, it adds two new scopes (.kept and .discarded) as well as a more explicit #discard method.

Using Discard

Adding discard to an existing model is straight-forward, involving little more than adding a database column and index to track the discarded_at state of each record, and then including the Discard::Model mixin in the model class proper.

You can ask Rails to generate a database migration for you:

$ rails generate migration add_discarded_at_to_products discarded_at:datetime:index

Or write your own similar to the one below:

class AddDiscardToProducts < ActiveRecord::Migration[7.0]
  def change
    add_column :products, :discarded_at, :datetime
    add_index :products, :discarded_at
  end
end

Then don’t forget to include the Discard::Model mixin in your ActiveRecord model class:

class Product < ApplicationRecord
  include Discard::Model
end

And that’s it! Your model is now discardable.

As mentioned earlier, unlike paranoia, discard does not change the default_scope of models it is included in. This means that .all will include both kept and discarded records together by default. Two new scopes are added to the model class (.kept and .discarded) to allow filtering for kept/discarded records respectively.

It also does not override the #destroy method, instead adding a more explicitly-named #discard method which soft-deletes a record. This seemingly-slight change makes it much clearer at a glance when an instance is being deleted or soft-deleted:

Product.all               # => [#<Product id: 1, ...>]
Product.kept              # => [#<Product id: 1, ...>]
Product.discarded         # => []

# records can be discarded
product = Product.first   # => #<Product id: 1, ...>
product.discard!          # => true

# records can only be discarded once
product.discard           # => false
product.discard!          # => Discard::RecordNotDiscarded: Failed to discard the record

# helper methods for checking a record's state
product.discarded?        # => true
product.undiscarded?      # => false
product.kept?             # => false
product.discarded_at      # => 2023-05-01 12:34:56 -0700

# the default query scope is maintained (deleted records are included in .all)
Product.all               # => [#<Product id: 1, ...>]
Product.kept              # => []
Product.discarded         # => [#<Product id: 1, ...>]

Since Solidus’ soft-deletable models were originally integrated with paranoia, it was imperative to keep the existing behaviour (particularly around default_scope) when upgrading to discard.

This proves rather straightforward to accomplish using another module as an integration/compatibility concern, customizing Discard’s default behaviour:

module Spree
  module SoftDeletable
    extend ActiveSupport::Concern

    included do
      include Discard::Model
      self.discard_column = :deleted_at

      default_scope { kept }
    end
  end
end

Now any model which opts to include Spree::SoftDeletable stores soft-deletion information in deleted_at and has soft-deleted records hidden by default when querying.

Wrap-up

Thank you for reading through to the end! Hopefully, you’ve learned a thing or two about how soft-deletion functionality can work along the way and how it’s been applied in Solidus/Spree. Maybe you’ve got a few fresh ideas for how to apply it in your own apps!

Make sure when adding soft-deletion functionality to customer data that you follow user privacy regulations and customer data retention policies. For example, if a user submits a request for erasure of their personal data under the GDPR, this includes permanently deleting any and all archived/soft-deleted records as well.

Cover Image: 📸 Pawel Czerwinski.

Work ServicesAboutBlogCareersContact


Privacy policyTerms and conditions© 2023 Super Good Software Inc. All rights reserved.