├── install.rb ├── uninstall.rb ├── Gemfile ├── .gitignore ├── init.rb ├── lib ├── version.rb ├── instance_methods.rb ├── class_methods.rb └── delete_softly.rb ├── Manifest ├── delete_softly.gemspec ├── Rakefile ├── MIT-LICENSE ├── README └── test ├── delete_softly_test.rb └── test_helper.rb /install.rb: -------------------------------------------------------------------------------- 1 | # Install hook code here 2 | -------------------------------------------------------------------------------- /uninstall.rb: -------------------------------------------------------------------------------- 1 | # Uninstall hook code here 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "http://rubygems.org" 2 | 3 | gemspec -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | doc 3 | test/db.sqlite3 4 | *.gem 5 | \.idea -------------------------------------------------------------------------------- /init.rb: -------------------------------------------------------------------------------- 1 | # Include hook code here 2 | require 'delete_softly' 3 | -------------------------------------------------------------------------------- /lib/version.rb: -------------------------------------------------------------------------------- 1 | module DeleteSoftly 2 | VERSION = "0.0.6" 3 | end -------------------------------------------------------------------------------- /Manifest: -------------------------------------------------------------------------------- 1 | MIT-LICENSE 2 | README 3 | Rakefile 4 | delete_softly.gemspec 5 | init.rb 6 | install.rb 7 | lib/class_methods.rb 8 | lib/delete_softly.rb 9 | lib/instance_methods.rb 10 | test/delete_softly_test.rb 11 | test/test_helper.rb 12 | uninstall.rb 13 | Manifest 14 | -------------------------------------------------------------------------------- /lib/instance_methods.rb: -------------------------------------------------------------------------------- 1 | module DeleteSoftly 2 | module InstanceMethods 3 | 4 | # This method reports whether or not the record has been soft deleted. 5 | # 6 | def deleted? 7 | self.deleted_at? 8 | end 9 | 10 | # Custom destroy method for models using delete_softly 11 | def destroy 12 | if persisted? 13 | with_transaction_returning_status do 14 | _run_destroy_callbacks do 15 | update_attribute :deleted_at, Time.now 16 | end 17 | end 18 | end 19 | 20 | @destroyed = true 21 | freeze 22 | end 23 | 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /delete_softly.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $:.push File.expand_path("../lib", __FILE__) 3 | require "version" 4 | 5 | Gem::Specification.new do |s| 6 | s.name = "delete_softly" 7 | s.version = DeleteSoftly::VERSION 8 | s.platform = Gem::Platform::RUBY 9 | s.authors = ["Benjamin ter Kuile", "Coroutine", "John Dugan"] 10 | s.email = ["gems@coroutine.com"] 11 | s.homepage = "http://github.com/coroutine/delete_softly" 12 | s.summary = %q{This gem adds soft delete functionality to your ActiveRecord models.} 13 | s.description = %q{This gem adds soft delete functionality to your ActiveRecord models.} 14 | 15 | s.add_dependency "rails", ">= 3.0.0" 16 | 17 | s.add_development_dependency "rspec", ">= 2.0.0" 18 | 19 | s.rubyforge_project = "delete_softly" 20 | 21 | s.files = `git ls-files`.split("\n") 22 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 23 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 24 | s.require_paths = ["lib"] 25 | end 26 | 27 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake' 2 | require 'rake/testtask' 3 | require 'rake/rdoctask' 4 | require 'echoe' 5 | 6 | desc 'Default: run unit tests.' 7 | task :default => :test 8 | 9 | desc 'Test the delete_softly plugin.' 10 | Rake::TestTask.new(:test) do |t| 11 | t.libs << 'lib' 12 | t.libs << 'test' 13 | t.pattern = 'test/**/*_test.rb' 14 | t.verbose = true 15 | end 16 | 17 | desc 'Echoe' 18 | Echoe.new('delete_softly', '0.0.3') do |p| 19 | p.description = "Add soft delete functionality to your ActiveRecord models" 20 | p.url = "http://github.com/bterkuile/delete_softly" 21 | p.author = "Benjamin ter Kuile" 22 | p.email = "bterkuile@gmail.com" 23 | p.ignore_pattern = ["tmp/*", "script/*"] 24 | p.runtime_dependencies = ["meta_where"] 25 | p.development_dependencies = [] 26 | end 27 | 28 | desc 'Generate documentation for the delete_softly plugin.' 29 | Rake::RDocTask.new(:rdoc) do |rdoc| 30 | rdoc.rdoc_dir = 'rdoc' 31 | rdoc.title = 'DeleteSoftly' 32 | rdoc.options << '--line-numbers' << '--inline-source' 33 | rdoc.rdoc_files.include('README') 34 | rdoc.rdoc_files.include('lib/**/*.rb') 35 | end 36 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 [name of plugin creator] 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | DeleteSoftly 2 | ============ 3 | 4 | Add soft delete functionality to ActiveRecord models. Important information: 5 | This is Rails3 only, no backwards compatibility. Important features are 6 | * It works through relations 7 | * papertrail support 8 | 9 | Tested with Postgresql 10 | 11 | New in version 0.3 12 | * without_deleted, same as active, but not meant to be overwritten 13 | * deleted is back, misteriously disappeared in version 0.2 14 | 15 | Example 16 | ======= 17 | class Post 18 | # Replace normal behavior of object completely 19 | delete_softly 20 | end 21 | 22 | class Comment 23 | # Rely on calling active for this object when needed 24 | delete_softly false 25 | end 26 | 27 | Now the following stuff works: 28 | == The Post model == 29 | p1 = Post.create 30 | p2 = Post.create 31 | Post.count #=> 2 32 | p2.destroy 33 | Post.count #=> 1 34 | Post.at_time(1.year.ago).count #=> 0 35 | 36 | c1 = Comment.create 37 | c2 = Comment.create 38 | Comment.count #=> 2 39 | c1.destroy 40 | Comment.count #=> 2 (Since we added false) 41 | Comment.active.count #=> 1 42 | 43 | See the rdoc for better examples and documentation 44 | 45 | Copyright (c) 2010 [Benjamin ter Kuile], released under the MIT license 46 | -------------------------------------------------------------------------------- /test/delete_softly_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class DeleteSoftlyTest < ActiveSupport::TestCase 4 | # Replace this with your real tests. 5 | setup do 6 | puts "New setup" 7 | Post.with_deleted.delete_all 8 | Comment.with_deleted.delete_all 9 | Tag.delete_all 10 | @post1 = Post.create(:title => "post1") 11 | @post1_id = @post1.id 12 | @comment1_1 = @post1.comments.create(:email => "comment1_1", :body => "Comment 1 for post 1") 13 | @comment2_1 = @post1.comments.create("email" => "comment1_2", :body => "Comment 2 for post 1") 14 | 15 | @post2 = Post.create(:title => "post2") 16 | @post2_id = @post2.id 17 | end 18 | test "the truth" do 19 | assert true 20 | end 21 | test "two records available" do 22 | assert_equal 2, Post.count 23 | end 24 | 25 | test "destroy count test" do 26 | @post1.destroy 27 | assert_equal 1, Post.count 28 | assert_equal 2, Post.with_deleted.count 29 | assert_nil Post.find_by_id(@post1_id) 30 | @post1 = Post.with_deleted.find(@post1_id) 31 | assert @post1.deleted_at 32 | @post1.revive 33 | assert 2, Post.count 34 | end 35 | 36 | test "deleted, without_deleted methods" do 37 | assert_equal [@post1, @post2], Post.all.sort_by{|p| p.title} 38 | @post1.destroy 39 | assert_equal [@post2], Post.without_deleted.all 40 | assert_equal [@post1], Post.deleted.all 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'rubygems' 2 | require 'test/unit' 3 | require 'arel' 4 | require 'active_support' 5 | require 'active_support/all' 6 | require 'lib/delete_softly' 7 | require 'sqlite3' 8 | 9 | 10 | ActiveRecord::Base.logger = Logger.new(STDOUT) 11 | ActiveRecord::Base.establish_connection( 12 | :adapter => 'sqlite3', 13 | :database => 'test/db.sqlite3' 14 | ) 15 | 16 | class Post < ActiveRecord::Base 17 | delete_softly 18 | has_many :comments 19 | end 20 | 21 | class Comment < ActiveRecord::Base 22 | delete_softly false 23 | belongs_to :post 24 | has_many :tags 25 | end 26 | 27 | class Tag < ActiveRecord::Base 28 | belongs_to :comment 29 | end 30 | 31 | unless Post.table_exists? 32 | puts "Creating table posts" 33 | ActiveRecord::Base.connection.create_table "posts" do |t| 34 | t.string :title 35 | t.text :body 36 | t.datetime :deleted_at 37 | t.timestamps 38 | end 39 | end 40 | unless Comment.table_exists? 41 | puts "Creating table comments" 42 | ActiveRecord::Base.connection.create_table "comments" do |t| 43 | t.string :email 44 | t.text :body 45 | t.integer :post_id 46 | t.datetime :deleted_at 47 | t.timestamps 48 | end 49 | end 50 | unless Tag.table_exists? 51 | puts "Creating table tags" 52 | ActiveRecord::Base.connection.create_table "tags" do |t| 53 | t.string :name 54 | t.integer :comment_id 55 | t.timestamps 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/class_methods.rb: -------------------------------------------------------------------------------- 1 | module DeleteSoftly 2 | module ClassMethods 3 | 4 | # Same as active, but not to be overwritten. Active might become with disabled => false 5 | # or something like that. Without deleted should remain intact 6 | def without_deleted 7 | where(:deleted_at => nil) 8 | end 9 | 10 | # Include deleted items when performing queries 11 | # class Item < ActiveRecord::Base 12 | # default_scope order(:content) 13 | # delete_softly 14 | # end 15 | # Will result in: 16 | # Item.first #=> SELECT "items".* FROM "items" WHERE ("items"."deleted_at" IS NULL) ORDER BY "items"."content" LIMIT 1 17 | # Item.with_deleted.first #=> SELECT "items".* FROM "items" ORDER BY "items"."content" LIMIT 1 18 | # Item.where(:content.matches => 'a%') #=> SELECT "items".* FROM "items" WHERE ("items"."deleted_at" IS NULL) AND ("items"."content" ILIKE 'a%') ORDER BY "items"."content" 19 | # Item.with_deleted do 20 | # Item.where(:content.matches => 'a%') #=> SELECT "items".* FROM "items" WHERE ("items"."content" ILIKE 'a%') ORDER BY "items"."content" 21 | # end 22 | # IHaveManyItems.first.items #=> SELECT "items".* FROM "items" WHERE ("items"."deleted_at" IS NULL) AND ("items".i_have_many_items_id = 1) ORDER BY "items"."content" 23 | # IHaveManyItems.first.items.with_deleted #=> SELECT "items".* FROM "items" WHERE ("items".i_have_many_items_id = 1) ORDER BY "items"."content" 24 | # For rails 3.2 method is being commented out as it uses private methods that have been removed from the 3.2. To achieve the same behavior, unscoped method should be used. 25 | #def with_deleted(&block) 26 | # if scoped_methods.any? # There are scoped methods in place 27 | # 28 | # # remove deleted at condition if present 29 | # del = scoped_methods.last.where_values.delete(:deleted_at => nil) 30 | # 31 | # # Execute block with deleted or just run scoped 32 | # r = block_given? ? yield : scoped 33 | # 34 | # # Add deleted condition if it was present 35 | # scoped_methods.last.where_values << del if del 36 | # 37 | # # Return de relation generated without deleted_at => nil 38 | # r 39 | # else 40 | # # Do not do anything special when there are no scoped_methods 41 | # r = block_given? ? yield : scoped 42 | # end 43 | #end 44 | 45 | def deleted 46 | with_deleted.where("deleted_at is not null") 47 | end 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/delete_softly.rb: -------------------------------------------------------------------------------- 1 | # DeleteSoftly 2 | # This is a gem/plugin that adds soft delete functionality to ActiveRecord. 3 | # Take a look at the 4 | # DeleteSoftly::ArExtender 5 | # DeleteSoftly::ClassMethods 6 | # DeleteSoftly::InstanceMethods 7 | # to get a feel of what is being done. This gem works through many relations 8 | 9 | require 'active_record' 10 | require 'class_methods' 11 | require 'instance_methods' 12 | module DeleteSoftly 13 | module ARExtender 14 | 15 | # Always have Model.active available. It is a good practice to use it 16 | # when you want the active records. Even use it when no logic is in 17 | # place yet. Now it is an alias for scoped, but can be overwritten 18 | # for custom behaviour, for example: 19 | # class Post < ActiveRecord::Base 20 | # delete_softly 21 | # has_many :comments 22 | # def self.active 23 | # super.where(:disabled.ne => true) 24 | # end 25 | # end 26 | # class Comment < ActiveRecord::Base 27 | # belongs_to :post 28 | # end 29 | # will result in: 30 | # Post.all #=> SELECT * FROM posts WHERE deleted_at IS NULL; 31 | # Post.active #=> SELECT * FROM posts WHERE deleted_at IS NULL AND disabled != 't'; 32 | # Comment.all #=> SELECT * FROM comments; 33 | # Comment.active #=> SELECT * FROM comments; 34 | def active 35 | scoped 36 | end 37 | 38 | # Make the model delete softly. A deleted_at:datetime column is required 39 | # for this to work. 40 | # The two most important differences are that it can be enforce on a model 41 | # or be more free. 42 | # class Post < ActiveRecord::Base 43 | # delete_softly 44 | # end 45 | # will enforce soft delete on the post model. A deleted post will never appear, 46 | # unless the explicit with_deleted is called. When the model is: 47 | # class Post < ActiveRecord::Base 48 | # delete_softly :enforce => false 49 | # end 50 | # An object will still be available after destroy is called. 51 | def delete_softly(options = {:enforce => :without_deleted}) 52 | # Make destroy! the old destroy 53 | alias_method :destroy!, :destroy 54 | 55 | include DeleteSoftly::InstanceMethods 56 | extend DeleteSoftly::ClassMethods 57 | # Support single argument 58 | # delete_softly :active # Same as :enforce => :active, default behaviour 59 | # delete_softly :enforce=> :with_deleted # Same as without argument 60 | options = {:enforce=> options } unless options.is_a?(Hash) 61 | if options[:enforce] 62 | if options[:enforce].is_a?(Symbol) && respond_to?(options[:enforce]) 63 | default_scope send(options[:enforce]) 64 | else 65 | default_scope active 66 | end 67 | end 68 | end 69 | end 70 | end 71 | 72 | ActiveRecord::Base.send(:extend, DeleteSoftly::ARExtender) 73 | 74 | # Overwrite ActiveRecord::Base#default_scope 75 | #Override is not needed any more for the rails 3.2. 76 | #class ActiveRecord::Base 77 | # # default_scope fix discussed in ticket: 78 | # # https://rails.lighthouseapp.com/projects/8994/tickets/4583-merge-default-scopes-by-default#ticket-4583-11 79 | # def self.default_scope(options = {}) 80 | # key = :"#{self}_scoped_methods" 81 | # Thread.current[key] = nil 82 | # self.default_scoping << construct_finder_arel(options, default_scoping.pop) 83 | # end 84 | #end 85 | --------------------------------------------------------------------------------