Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to hide records, rather than delete them (soft delete from scratch)

Let's keep this simple. Let's say I have a User model and a Post model:

class User < ActiveRecord::Base
    # id:integer name:string deleted:boolean

    has_many :posts       
end

class Post < ActiveRecord::Base
    # id:integer user_id:integer content:string deleted:boolean

    belongs_to :user
end

Now, let's say an admin wants to "delete" (hide) a post. So basically he, through the system, sets a post's deleted attribute to 1. How should I now display this post in the view? Should I create a virtual attribute on the post like this:

class Post < ActiveRecord::Base
    # id:integer user_id:integer content:string deleted:boolean

    belongs_to :user

    def administrated_content
       if !self.deleted
          self.content
       else
          "This post has been removed"
       end
    end
end

While that would work, I want to implement the above in a large number of models, and I can't help feeling that copy+pasting the above comparative into all of my models could be DRYer. A lot dryer.

I also think putting a deleted column in every single deletable model in my app feels a bit cumbersome too. I feel I should have a 'state' table. What are your thoughts on this:

class State
    #id:integer #deleted:boolean #deleted_by:integer

    belongs_to :user
    belongs_to :post
end 

and then querying self.state.deleted in the comparator? Would this require a polymorphic table? I've only attempted polymorphic once and I couldn't get it to work. (it was on a pretty complex self-referential model, mind). And this still doesn't address the problem of having a very, very similar class method in my models to check if an instance is deleted or not before displaying content.

In the deleted_by attribute, I'm thinking of placing the admin's id who deleted it. But what about when an admin undelete a post? Maybe I should just have an edited_by id.

How do I set up a dependent: :destroy type relationship between the user and his posts? Because now I want to do this: dependent: :set_deleted_to_0 and I'm not sure how to do this.

Also, we don't simply want to set the post's deleted attributes to 1, because we actually want to change the message our administrated_content gives out. We now want it to say, This post has been removed because of its user has been deleted. I'm sure I could jump in and do something hacky, but I want to do it properly from the start.

I also try to avoid gems when I can because I feel I'm missing out on learning.

like image 627
Starkers Avatar asked Apr 11 '14 15:04

Starkers


People also ask

Are soft deletes a good idea?

Make sure ALL queries mentioning soft-deleted entities are double-checked, otherwise it can lead to unexpected data leaks and critical performance issues. In the ideal world, a developer should not be aware about soft delete existence.

Is it hard delete or soft delete?

Hard deletes are hard to recover from if something goes wrong (application bug, bad migration, manual query, etc.). This usually involves restoring from a backup and it is hard to target only the data affected by the bad delete. Soft deletes are easier to recover from once you determine what happened.

What is a hard delete?

hard deletion (countable and uncountable, plural hard deletions) (databases) An operation in which data is erased from the database (as opposed to a soft deletion).

What is delete and soft delete?

What Is Soft Delete? Soft delete performs an update process to mark some data as deleted instead of physically deleting it from a table in the database. A common way to implement soft delete is to add a field that will indicate whether data has been deleted or not.


1 Answers

I usually use a field named deleted_at for this case:

class Post < ActiveRecord::Base
  scope :not_deleted, lambda { where(deleted_at: nil) }
  scope :deleted, lambda { where("#{self.table_name}.deleted_at IS NOT NULL") }

  def destroy
    self.update(deleted_at: DateTime.current)
  end

  def delete
    destroy
  end

  def deleted?
    self.deleted_at.present?
  end
  # ...

Want to share this functionnality between multiple models?

=> Make an extension of it!

# lib/extensions/act_as_fake_deletable.rb
module ActAsFakeDeletable
  # override the model actions
  def destroy
    self.update(deleted_at: DateTime.current)
  end

  def delete
    self.destroy
  end

  def undestroy # to "restore" the file
    self.update(deleted_at: nil)
  end

  def undelete
    self.undestroy
  end
  
  # define new scopes
  def self.included(base)
    base.class_eval do
      scope :destroyed, where("#{self.table_name}.deleted_at IS NOT NULL")
      scope :not_destroyed, where(deleted_at: nil)
      scope :deleted, lambda { destroyed }
      scope :not_deleted, lambda { not_destroyed }
    end
  end
end

class ActiveRecord::Base
  def self.act_as_fake_deletable(options = {})
    alias_method :destroy!, :destroy
    alias_method :delete!, :delete
    include ActAsFakeDeletable

    options = { field_to_hide: :content, message_to_show_instead: "This content has been deleted" }.merge!(options)

    define_method options[:field_to_hide].to_sym do
      return options[:message_to_show_instead] if self.deleted_at.present?
      self.read_attribute options[:field_to_hide].to_sym
    end
  end
end

Usage:

class Post < ActiveRecord::Base
  act_as_fake_deletable

Overwriting the defaults:

class Book < ActiveRecord::Base
  act_as_fake_deletable field_to_hide: :title, message_to_show_instead: "This book has been deleted man, sorry!"

Boom! Done.

Warning: This module overwrite the ActiveRecord's destroy and delete methods, which means you won't be able to destroy your record using those methods anymore. Instead of overwriting you could create a new method, named soft_destroy for example. So in your app (or console), you would use soft_destroy when relevant and use the destroy/delete methods when you really want to "hard destroy" the record.

like image 110
MrYoshiji Avatar answered Nov 15 '22 20:11

MrYoshiji