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!
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.
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.
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.
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.
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.