Skip to main content
Popular Rails Gems:

Soft Delete with Paranoia

33

Episode 41 · February 5, 2015

Learn how to soft delete records instead of deleting them permanently from your database

Gems ActiveRecord


Transcripts

Today we're going to talk about a gem called Paranoia, and how you can use it to basically archive stuff in your application, or do soft deletes, as they're sometimes called. In previous episodes we've built this forum and we have a bunch of forum threads, and if you click on a thread, there's posts by users, and we keep track of who created what, so the thread was created by me, Chris Oliver, and I made the first post, and then the second post was by "Test user", and we have a few conversations testing different things, and what happens here is that if test user deletes their account, and we can simulate that by

rails c
User.last.destroy

Once we do that and we refresh the page, this is now broken, because we have the forum_post.user.name in our view, and what this is saying is "undefined method 'name' for nil:NilClass", which means that we called name on something, and that thing was nil, so forum_post.user is nil. And if we look at the forum post, we have a user id, so it's trying to see that the user exists, and now when we call forum_post.user it does a query for users where ID is 2, and there is none. So we get a nil, and then we say nil.name, of course you get the same error as here. How do we go about solving this problem?

There's a handful of different ways you can do it, and one thing that you'll see for example on reddit is to take this and put an if statement around it, so that rather than just putting out the user name, you could say if forum_post.user.present, then we will display the forum_post.user.name, otherwise we'll just print out something like "Deleted User", and this will return the "Deleted User" string when there is no user otherwise, it will work as we expect, and we can go and update our views to do this, and you can build a helper method for it, or make a presenter to make this a little prettier, but you're introducing if statements in your application, and you have to be aware of that, because that causes more problems down the road. Now we have this where it says "deleted user" and this is updated. But when it comes to things like financial information, and you're recording payments, and things like that, you actually can't just straight up delete your users, because you need their names and email addresses for things like that. So that's something that you have to be very aware of, so what we're going to use in this episode, is a gem called Paranoia. Now there's an old gem called acts-as-paranoid that was rewritten on by a handful of people, and it's a very lightweight gem now, and it's amazing, there's really not much to it. This is all there is, one file of code which is under 225 lines, so that's great, easy to debug and flexible enough. Now this gem basically does soft deletes or archiving, so rather than just straight up deleting the record from your database, you add a field called deleted_at, and if this field is null, then your record is not deleted, but if it does have a timestamp there, then that means that that record was deleted, so then, what happens is, you just query all of your updates to your queries, so if this thread for example had paranoia on it, you could say it was deleted_at whenever, and then you could have the forum thread section only show the ones that aren't deleted. So let's go ahead and add this to our application. I'm going to grab the latest version of the paranoia gem. Let's undo the change that we just made there, and then jump into the bottom of our Gemfile, add in paranoia, and here actually I'm going to go create that user again, so that we can delete them and test things out once more.

User.create email: "[email protected]", first_name: "Test", last_name: "User", password: "password", password_confirmation: "password"

bundle install

We've got our rails server restarted, and now we need to go run a migration to add the deleted_at columns to our models. Let's first do this with forum threads, and we'll see how we can apply that to forum threads in a similar fashion. If we generate a migration

rails g migration AddDeletedAtToForumThreads deleted_at:datetime

rake db:migrate

bundle exec rake db:migrate

because I have multiple versions or rake installed, and now that that's finished, we can hop back over to Chrome and take a look at what it requires to add this to our model. Another thing we want to do is add the index in that I missed, so to do that in development, we can just rake db:rollback, and then we need to roll that with bundle exec rake db:rollback, so we'll rollback that adding deleted_at column, and then we'll open up the migration the migration that we just created, and paste in adding the add_index :forum_threads, deleted_at. This index is important since you're going to be querying for this field all the time. You're going to always look for if it's null or not, basically. This index will help the database index that column so that all your queries are faster, and it's very very important that you add that. So we can take the acts_as_paranoid and put that into our forum_thread.rb. Run bundle exec rake db:migrate, the "be" shortcut is because I'm using zsh, and it comes with a shortcut for that. So now we can go back to our forum threads, and if you look at your documentation when you call destroy, it just sets the deleted_at timestamp, and that's all.

Now we can go into here, and I forgot to create that user apparently, or no, I forgot to update the users id to match the one that we had before, so if we take the last user when I created it, it got an id of 4, but we need to update it because the original id was 2, so we'll change the user over to that, and we're back in the same state we were before deleting the record, and you can see kind of how much trouble it causes when you delete records like that. So going back out to the forum threads index, let's go and grab the ForumThread.last, which is our "episode 26 announcement", so we're going to delete this forum thread ForumThread.last.destroy. So we did that, but the record came back, it still exists, and this time it has the deleted_at timestamp. If we come back to our page, you can see that it disappeared, and the reason it disappeared is because paranoia sets a default scope, which isn't mentioned in the README here, but there is a handful of other scopes that you can have. So there's with_deleted, only_deleted, and these will give you either all records, or only the deleted records, and by default the scope is modified so that you only get undeleted records. Now we need to take a little bit look at this and see what happens. Now we deleted id number 3, and we go and view that page, it's no longer available. This has actually changed our full scope here, so when we say ForumThread.find(3), it can't find it and we get this error, and the error is "ActiveRecord::RecordNotFound" one, so it couldn't find an id 3, but if you notice here, it's also adding the condition that the deleted_at column needs to be null. So imagine that you're having an admin account on the application, and you want to say ForumThread.with_deleted.find(3), and you'll actually get it back, so maybe admins use the with_deleted scope, and the rest of the users only see stuff that is not deleted, and maybe you have a section in your application that is archived threads and maybe those are the ones that are all listed there, so only the deleted ones are listed in your archive section. This is really nifty because it allows us to do a lot of stuff like this, and what if we want to restore that Forum Thread? ForumThread.restore(3), and this now updates the record's deleted_at to nil, so it comes back. Now we want to go and see how to delete users because that's what we started with, and forum threads is a little bit simpler because we're just accessing the records directly from the model. Now with this, though, we're going through the thread or the post, and we're trying to talk to the association, and we have to update and override our association to handle this, because we need to include with_deleted in this case. Let's go ahead and add our migration in here

railg g migration AddDeletedAtToUsers deleted_at:timestamp

db/migrate/add_deleted_at_to_users

def change 
    add_column :users, :deleted_at, :timestamp 
    add_index :users, deleted_at 
end 

rake db:migrate

Now our users will be able to be soft deleted, and we'll have to go to the user model and add in the acts_as_paranoid method in here, and once we've done that, now we can run our rails console and gran that last user

User.last.destroy

They've been deleted, and now when we visit this page, we get the same error that we started with, and that's a little annoying, because we know the record exists, and in this case, it's a public forum so if you delete your account, that's fine, but we can still keep your post up or show your name, we'll just go with that, because the content is public anyways. In some cases, you may want to truly delete it, an replace these with placeholders, or just not show this post for example, but in our case we want to display the content even if it's deleted or not, and the way that we need to do that is to open up the app/models/forum_post.rb, and we need to override the getter that the belongs_to user sets for us. If you understand how belongs_to works, you have a def user =(), and it takes some arguments, and then you have a def user that doesn't take any arguments, and this is what returns the User.find on the user id, so this User find getter is basically just calling that using the user_id column. So it's as simple as that, it looks at the symbol, adds "_id", and then bascially generates a method behind the scenes that does that user = is very similar, it passes those your options over to User.create, it saves the user id into a variable, and the updates the forum posts with that id and so on. You can create the user methods like that, but you can also override them. So belongs_to, you could create by hand if you wanted, but we have this helpful association shortcut, but it doesn't allow us to find users that are deleted, so we can say

def user 
    User.with_deleted.find(user_id)
end

instead, and this should allow us to refresh the page and now it shows "Test User" there still, so that's cool. This is one way of doing it, the example in their documentation in the paranoia gem is to use User.unscoped { super }, which I think is a better solution

def user 
    User.unscoped { super }
end 

Super calls the original method that we're overriding, and that original method would be from the belongs_to If we come back and refresh this page, it works as we expected, and I would recommend using their example to do this, because the unscoped basically removes the deleted_at = null condition, and then it calls with the original query. Now the way you implement this is going to be very application-specific, if you're doing things like Stripe would with payments, you probably want to keep those records around, on the other hand, if you're doing something like a forum, you may want to actually delete the user's account permanently. Paranoia is a perfect gem to straddle that balance, you can use it at will on any models that you want, and you have all of these helper methods and other things like this, really_destroy! to actually delete the record. It provides all of those things that you could want from a soft delete library, and I'm really really happy with it, they've done a great job on it, so I hope this helps and you're able to add soft deletes into your applications as well.

Transcript written by Miguel

Loading...

Subscribe to the newsletter

Join 18,000+ developers who get early access to new screencasts, articles, guides, updates, and more.

By clicking this button, you agree to the GoRails Terms of Service and Privacy Policy.

More of a social being? We're also on Twitter and YouTube.