When you soft delete a record, you do not remove it from the database, but you mark it as deleted instead. Then you can decide if you want to show it to your users or not.
There are many ways how to implement soft deletes in Rails. For example, you can use the paranoia
gem or you can implement it from scratch.
Add it to Gemfile:
gem "paranoia"
Install it:
bundle install
Add deleted_at
to your model:
rails g migration add_deleted_at_to_comments deleted_at:datetime:index
rails db:migrate
And call acts_as_paranoid
in it:
class Comment < ApplicationRecord
acts_as_paranoid
end
Done! Now we can soft delete comments.
irb(main):001:0> Comment.first.destroy
...
(0.1ms) BEGIN
SQL (0.4ms) UPDATE "comments" SET "deleted_at" = '2016-12-21 19:04:14.923481' WHERE "comments"."id" = $1 [["id", 1]]
(0.4ms) COMMIT
As you can see, calling #destroy
did not delete the comment, but it just set its updated_at
attribute to the current time.
When we query the comments, then we can see that only non-deleted comments are selected:
irb(main):002:0> Comment.count
(0.6ms) SELECT COUNT(*) FROM "comments" WHERE "comments"."deleted_at" IS NULL
=> 2
See WHERE "comments"."deleted_at" IS NULL
which was added to the SQL query automatically.
We can query deleted comments too:
irb(main):003:0> Comment.only_deleted.count
(0.3ms) SELECT COUNT(*) FROM "comments" WHERE ("comments"."deleted_at" IS NOT NULL)
=> 1
Or all comments:
irb(main):004:0> Comment.with_deleted.count
(0.3ms) SELECT COUNT(*) FROM "comments"
=> 3
And last but not least, we can still delete a comment from the database by calling #really_destroy!
:
irb(main):005:0> Comment.first.really_destroy!
...
(0.1ms) BEGIN
SQL (0.2ms) DELETE FROM "comments" WHERE "comments"."id" = $1 [["id", 2]]
(1.0ms) COMMIT
All of this (and more) was added to our Comment
model by acts_as_paranoid
method call.
Now, let's implement the similar functionality by ourself.
We already have the deleted_at
column in place. So let’s just remove the call to acts_as_paranoid
and implement our own #destroy
method:
class Comment < ApplicationRecord
def destroy
update(deleted_at: Time.current)
end
end
Now, we can soft delete a comment by calling #destroy
.
In the next step we add the default scope which will exclude all deleted comments.
class Comment < ApplicationRecord
# ...
default_scope -> { where(deleted_at: nil) }
end
And create only_deleted
and with_deleted
scopes.
class Comment < ApplicationRecord
# ...
scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
end
Now, we can soft delete comments and query non-deleted and deleted comments. This looks promising, but we still need to add the #really_destroy!
method.
Actually, we do not need to add it. We can just rename the original one:
class Comment < ApplicationRecord
# ...
alias_method :really_destroy!, :destroy
end
When we put everything together it looks like this:
class Comment < ApplicationRecord
scope :only_deleted, -> { unscope(where: :deleted_at).where.not(deleted_at: nil) }
scope :with_deleted, -> { unscope(where: :deleted_at) }
default_scope -> { where(deleted_at: nil) }
alias_method :really_destroy!, :destroy
def destroy
update(deleted_at: Time.current)
end
end
And that’s it! We recreated the functionality provided by the paranoia
gem.
Well, kind of. We just scratched the surface. For example calling #delete
on a comment
will still delete it from the database. Also, before_destroy/after_destroy
callbacks will not run when we call our new #destroy
method.
We saw two different approaches how to implement soft deletes with ActiveRecord
.
The former is battle tested and production ready. The latter is just an example how to do it yourself and I wouldn't use it in production.
But, I recently needed to implement soft deletes. I did not use either of mentioned solutions and created even simpler one instead:
class Comment < ApplicationRecord
default_scope -> { where(deleted_at: nil) }
def soft_delete
update(deleted_at: Time.current)
end
end
I like it because calling #soft_delete
is explicit and intention revealing, and I didn't override default ActiveRecord
methods. I believe that future programmers working on that code will thank me.
Also, I did not need all the fancy stuff provided by the paranoia
gem, so there was no reason to introduce another dependency in our app.