├── VERSION ├── Gemfile ├── .gitignore ├── spec ├── support │ ├── kitty.rb │ ├── unused_model.rb │ ├── mole.rb │ ├── muskrat.rb │ ├── dirt.rb │ ├── comment.rb │ ├── difficulty.rb │ ├── location.rb │ ├── earthworm.rb │ ├── database.yml │ ├── ant.rb │ ├── hole.rb │ └── schema.rb ├── permanent_records │ ├── revive_parent_first_spec.rb │ ├── propagate_validation_flag_spec.rb │ └── circular_sti_dependency_spec.rb ├── spec_helper.rb └── permanent_records_spec.rb ├── .document ├── .travis.yml ├── ci ├── CONTRIBUTORS.md ├── Rakefile ├── LICENSE ├── permanent_records.gemspec ├── README.md └── lib └── permanent_records.rb /VERSION: -------------------------------------------------------------------------------- 1 | 4.1.0 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | spec/support/debug.log 2 | Gemfile.lock 3 | pkg 4 | -------------------------------------------------------------------------------- /spec/support/kitty.rb: -------------------------------------------------------------------------------- 1 | class Kitty < ActiveRecord::Base 2 | end -------------------------------------------------------------------------------- /spec/support/unused_model.rb: -------------------------------------------------------------------------------- 1 | class UnusedModel < ActiveRecord::Base 2 | end -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | init.rb 2 | uninstall.rb 3 | rails/* 4 | test/* 5 | README 6 | MIT-LICENSE 7 | -------------------------------------------------------------------------------- /spec/support/mole.rb: -------------------------------------------------------------------------------- 1 | class Mole < ActiveRecord::Base 2 | belongs_to :hole 3 | validates :hole, presence: true 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/muskrat.rb: -------------------------------------------------------------------------------- 1 | class Muskrat < ActiveRecord::Base 2 | belongs_to :hole 3 | validates :hole, presence: true 4 | end 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.0.0 4 | - 2.2.2 5 | env: 6 | - AR_TEST_VERSION: 4.2.0 7 | - AR_TEST_VERSION: 4.2.5 8 | -------------------------------------------------------------------------------- /spec/support/dirt.rb: -------------------------------------------------------------------------------- 1 | class Dirt < ActiveRecord::Base 2 | has_one :hole 3 | # validates :hole, presence: true 4 | has_one :earthworm, :dependent => :destroy 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/comment.rb: -------------------------------------------------------------------------------- 1 | class Comment < ActiveRecord::Base 2 | belongs_to :hole 3 | validates :hole, presence: true 4 | default_scope { where(:deleted_at => nil) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/difficulty.rb: -------------------------------------------------------------------------------- 1 | class Difficulty < ActiveRecord::Base 2 | belongs_to :hole 3 | 4 | default_scope { where(:deleted_at => nil) } 5 | 6 | validates :hole, presence: true 7 | end 8 | -------------------------------------------------------------------------------- /ci: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | for version in `grep AR_TEST_VERSION .travis.yml | awk '{print $3}'`; do 3 | export AR_TEST_VERSION=$version 4 | echo "Testing against ActiveRecord $version" 5 | bundle 6 | bundle exec rspec 7 | done 8 | -------------------------------------------------------------------------------- /spec/support/location.rb: -------------------------------------------------------------------------------- 1 | class Location < ActiveRecord::Base 2 | belongs_to :hole 3 | validates :hole, presence: true 4 | validates_uniqueness_of :name, :scope => :deleted_at 5 | has_many :zones, class_name: 'Location', foreign_key: 'parent_id', dependent: :destroy 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/earthworm.rb: -------------------------------------------------------------------------------- 1 | class Earthworm < ActiveRecord::Base 2 | belongs_to :dirt 3 | validates :dirt, presence: true 4 | # Earthworms have been known to complain if they're left on their deathbeds without any dirt 5 | before_destroy :complain! 6 | 7 | def complain! 8 | raise "Where's my dirt?!" if Dirt.not_deleted.find(self.dirt_id).nil? 9 | end 10 | 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | :adapter: sqlite3 3 | :database: ":memory:" 4 | :min_messages: ERROR 5 | # sqlite: 6 | # :adapter: sqlite 7 | # :database: plugin.sqlite.db 8 | # sqlite3: 9 | # :adapter: sqlite3 10 | # :database: ":memory:" 11 | # mysql: 12 | # :adapter: mysql 13 | # :host: localhost 14 | # :username: rails 15 | # :password: 16 | # :database: plugin_test 17 | -------------------------------------------------------------------------------- /spec/permanent_records/revive_parent_first_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PermanentRecords do 4 | let(:hole) { dirt.hole } 5 | let!(:ant) { hole.ants.create! } 6 | let(:dirt) { Dirt.create!.tap { |dirt| dirt.create_hole } } 7 | 8 | before { hole.destroy } 9 | 10 | describe '#revive' do 11 | 12 | it 'should revive parent first' do 13 | hole.revive(reverse: true) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/support/ant.rb: -------------------------------------------------------------------------------- 1 | class Ant < ActiveRecord::Base 2 | belongs_to :hole 3 | validates :hole, presence: true 4 | 5 | after_revive :reactivate_ants 6 | 7 | def add_ant ant 8 | # do something like you want 9 | 10 | # Force reload 11 | Hole.not_deleted.where(id: hole.id).first.add_to_ants_cache ant 12 | end 13 | 14 | def reactivate_ants 15 | # Ant.unscoped.find(destroyed_ants).each { |ant| add_ant ant } 16 | Array(Ant.create).each { |ant| add_ant ant } 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/permanent_records/propagate_validation_flag_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PermanentRecords do 4 | let(:hole) { dirt.hole } 5 | let(:dirt) { Dirt.create!.tap { |dirt| dirt.create_hole } } 6 | 7 | before { hole.destroy } 8 | 9 | describe '#revive' do 10 | 11 | before do 12 | expect(dirt).to receive(:get_deleted_record) { dirt } 13 | expect(dirt).to receive(:save).with(validate: false) 14 | end 15 | 16 | it 'should propagate validation flag on dependent records' do 17 | hole.revive(validate: false) 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Folks who gave their time and effort and didn't have to: 2 | 3 | * [David Sulc](https://github.com/davidsulc) 4 | * [Joe Nelson](https://github.com/begriffs) 5 | * [Trond Arve Nordheim](https://github.com/tanordheim) 6 | * [Josh Teneycke](https://github.com/jteneycke) 7 | * [Maximilian Herold](https://github.com/mherold) 8 | * [Hugh Evans](https://github.com/hughevans) 9 | 10 | To join this list just open a GH issue with some code you'd like to 11 | change in this project. New features are fine, bug fixes are better. No 12 | experience or credentials necessary to begin contributing - if you can 13 | read this you're welcome to join. 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | require 'yaml' 3 | Bundler::GemHelper.install_tasks 4 | 5 | $config = YAML::load_file File.expand_path('spec/support/database.yml', File.dirname(__FILE__)) 6 | 7 | def test_database_exists? 8 | system "psql -l | grep -q #{$config['test'][:database]}" 9 | $?.success? 10 | end 11 | 12 | def create_test_database 13 | system "createdb #{$config['test'][:database]}" 14 | end 15 | 16 | namespace :db do 17 | task :create do 18 | create_test_database unless test_database_exists? 19 | end 20 | end 21 | 22 | task :default => [:spec] 23 | 24 | desc 'Run all tests' 25 | task :spec => 'db:create' do 26 | exec 'rspec' 27 | end 28 | -------------------------------------------------------------------------------- /spec/support/hole.rb: -------------------------------------------------------------------------------- 1 | class Hole < ActiveRecord::Base 2 | # Because when we're destroying a mole hole we're obviously using high explosives. 3 | belongs_to :dirt, :dependent => :destroy 4 | 5 | # muskrats are permanent 6 | has_many :muskrats, :dependent => :destroy 7 | # moles are not permanent 8 | has_many :moles, :dependent => :destroy 9 | 10 | has_many :ants, :dependent => :destroy 11 | has_one :location, :dependent => :destroy 12 | has_one :unused_model, :dependent => :destroy 13 | has_one :difficulty, :dependent => :destroy 14 | has_many :comments, :dependent => :destroy 15 | 16 | serialize :options, Hash 17 | store :properties, :accessors => [:size] if respond_to?(:store) 18 | 19 | attr_accessor :youre_in_the_hole 20 | 21 | before_destroy :check_youre_not_in_the_hole 22 | 23 | private 24 | 25 | def check_youre_not_in_the_hole 26 | !youre_in_the_hole 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/permanent_records/circular_sti_dependency_spec.rb: -------------------------------------------------------------------------------- 1 | require 'pry' 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:hole) { dirt.hole } 7 | let(:dirt) { Dirt.create!.tap { |dirt| dirt.create_hole } } 8 | let!(:location) { Location.create({name: 'location', hole: hole}) } 9 | let!(:zone) { location.zones.create({name: 'zone', parent_id: location.id, hole: hole}) } 10 | 11 | before do 12 | hole.destroy({validate: false}) 13 | end 14 | 15 | describe '#revive' do 16 | 17 | it 'should revive children properly on STI' do 18 | expect(hole.reload).to be_deleted 19 | expect(dirt.reload).to be_deleted 20 | expect(location.reload).to be_deleted 21 | # expect(hole.location.zones.deleted).to be_exists # STI relations isn't delete 22 | 23 | hole.revive 24 | 25 | expect(hole.reload).to_not be_deleted 26 | expect(dirt.reload).to_not be_deleted 27 | expect(location.reload).to_not be_deleted 28 | expect(hole.location.zones.not_deleted).to be_exists 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Jack Danger Canty 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 | -------------------------------------------------------------------------------- /permanent_records.gemspec: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | Gem::Specification.new do |s| 3 | s.name = "permanent_records" 4 | s.version = File.read('VERSION') 5 | s.license = 'MIT' 6 | 7 | s.authors = ["Jack Danger Canty", "David Sulc", "Joe Nelson", "Trond Arve Nordheim", "Josh Teneycke", "Maximilian Herold", "Hugh Evans"] 8 | s.summary = "Soft-delete your ActiveRecord records" 9 | s.description = "Never Lose Data. Rather than deleting rows this sets Record#deleted_at and gives you all the scopes you need to work with your data." 10 | s.email = "github@jackcanty.com" 11 | s.extra_rdoc_files = [ 12 | "LICENSE", 13 | "README.md" 14 | ] 15 | s.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 16 | s.homepage = "https://github.com/JackDanger/permanent_records" 17 | s.require_paths = ["lib"] 18 | 19 | # For testing against multiple AR versions 20 | ver = ENV['AR_TEST_VERSION'] 21 | ver = ver.dup.chomp if ver 22 | 23 | s.add_runtime_dependency('activerecord', ver || '>= 4.2.0') 24 | s.add_runtime_dependency('activesupport', ver || '>= 4.2.0') 25 | s.add_development_dependency('rake') # For Travis-ci 26 | s.add_development_dependency('sqlite3') 27 | s.add_development_dependency('pry-byebug') 28 | s.add_development_dependency('database_cleaner', '>= 1.5.1') 29 | s.add_development_dependency('rspec', '~> 2.14.1') 30 | end 31 | 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | 2 | # Include this file in your test by copying the following line to your test: 3 | # require File.expand_path(File.dirname(__FILE__) + "/test_helper") 4 | 5 | lib = Pathname.new File.expand_path('../../lib', File.dirname(__FILE__)) 6 | support = Pathname.new File.expand_path('../spec/support', File.dirname(__FILE__)) 7 | $:.unshift lib 8 | $:.unshift support 9 | RAILS_ROOT = File.dirname(__FILE__) 10 | 11 | require 'active_record' 12 | require 'active_support' 13 | require 'permanent_records' 14 | 15 | module Rails 16 | def self.env; 'test'end 17 | end 18 | 19 | if I18n.config.respond_to?(:enforce_available_locales) 20 | I18n.config.enforce_available_locales = true 21 | end 22 | 23 | require 'logger' 24 | ActiveRecord::Base.logger = Logger.new support.join("debug.log") 25 | ActiveRecord::Base.configurations = YAML::load_file support.join('database.yml') 26 | ActiveRecord::Base.establish_connection 27 | 28 | load 'schema.rb' if File.exist?(support.join('schema.rb')) 29 | 30 | Dir.glob(support.join('*.rb')).each do |file| 31 | autoload File.basename(file).chomp('.rb').camelcase.intern, file 32 | end.each do |file| 33 | require file 34 | end 35 | 36 | require 'database_cleaner' 37 | 38 | RSpec.configure do |config| 39 | config.before(:suite) do 40 | DatabaseCleaner.strategy = :transaction 41 | DatabaseCleaner.clean_with(:truncation) 42 | end 43 | 44 | config.before(:each) do 45 | DatabaseCleaner.start 46 | end 47 | 48 | config.after(:each) do 49 | DatabaseCleaner.clean 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(:version => 1) do 2 | 3 | create_table :ants, :force => true do |t| 4 | t.column :name, :string 5 | t.column :deleted_at, :datetime 6 | t.references :hole 7 | end 8 | 9 | create_table :muskrats, :force => true do |t| 10 | t.column :name, :string 11 | t.column :deleted_at, :datetime 12 | t.references :hole 13 | end 14 | 15 | create_table :kitties, :force => true do |t| 16 | t.column :name, :string 17 | end 18 | 19 | create_table :holes, :force => true do |t| 20 | t.integer :number 21 | t.text :options 22 | t.text :properties 23 | t.references :dirt 24 | t.datetime :deleted_at 25 | end 26 | 27 | create_table :moles, :force => true do |t| 28 | t.string :name 29 | t.references :hole 30 | end 31 | 32 | create_table :locations, :force => true do |t| 33 | t.string :name 34 | t.references :hole 35 | t.integer :parent_id 36 | t.datetime :deleted_at 37 | end 38 | 39 | create_table :comments, :force => true do |t| 40 | t.string :text 41 | t.references :hole 42 | t.datetime :deleted_at 43 | end 44 | 45 | create_table :difficulties, :force => true do |t| 46 | t.string :name 47 | t.references :hole 48 | t.datetime :deleted_at 49 | end 50 | 51 | create_table :unused_models, :force => true do |t| 52 | t.string :name 53 | t.references :hole 54 | t.datetime :deleted_at 55 | end 56 | 57 | create_table :dirts, :force => true do |t| 58 | t.string :color 59 | t.datetime :deleted_at 60 | end 61 | 62 | create_table :earthworms, :force => true do |t| 63 | t.references :dirt 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PermanentRecords (Rails 3.1+) 2 | 3 | [http://github.com/JackDanger/permanent_records/](http://github.com/JackDanger/permanent_records/) 4 | 5 | This gem prevents any of your ActiveRecord data from being destroyed. 6 | Any model that you've given a "deleted_at" datetime column will have that column set rather than let the record be deleted. 7 | 8 | ## What methods does it give me? 9 | 10 | ```ruby 11 | User.find(3).destroy # Sets the 'deleted_at' attribute to Time.now 12 | # and returns a frozen record. If halted by a 13 | # before_destroy callback it returns false instead 14 | 15 | User.find(3).destroy(:force) # Executes the real destroy method, the record 16 | # will be removed from the database. 17 | 18 | User.destroy_all # Soft-deletes all User records. 19 | 20 | User.delete_all # bye bye everything (no soft-deleting here) 21 | ``` 22 | There are also two scopes provided for easily searching deleted and not deleted records: 23 | 24 | ```ruby 25 | User.deleted.find(...) # Only returns deleted records. 26 | 27 | User.not_deleted.find(...) # Only returns non-deleted records. 28 | ``` 29 | 30 | Note: Your normal finds will, by default, _include_ deleted records. You'll have to manually use the ```not_deleted``` scope to avoid this: 31 | 32 | ```ruby 33 | User.find(1) # Will find record number 1, even if it's deleted. 34 | 35 | User.not_deleted.find(1) # This is probably what you want, it doesn't find deleted records. 36 | ``` 37 | 38 | ## Can I easily undelete records? 39 | 40 | Yes. All you need to do is call the 'revive' method. 41 | 42 | ```ruby 43 | User.find(3).destroy # The user is now deleted. 44 | 45 | User.find(3).revive # The user is back to it's original state. 46 | ``` 47 | 48 | And if you had dependent records that were set to be destroyed along with the parent record: 49 | 50 | ```ruby 51 | class User < ActiveRecord::Base 52 | has_many :comments, :dependent => :destroy 53 | end 54 | 55 | User.find(3).destroy # All the comments are destroyed as well. 56 | 57 | User.find(3).revive # All the comments that were just destroyed 58 | # are now back in pristine condition. 59 | ``` 60 | 61 | Forcing deletion works the same way: if you hard delete a record, its dependent records will also be hard deleted. 62 | 63 | ## Can I use default scopes? 64 | 65 | ```ruby 66 | default_scope where(:deleted_at => nil) 67 | ``` 68 | 69 | If you use such a default scope, you will need to simulate the `deleted` scope with a method 70 | 71 | ```ruby 72 | def self.deleted 73 | self.unscoped.where('deleted_at IS NOT NULL') 74 | end 75 | ``` 76 | 77 | ## Is Everything Automated? 78 | 79 | Yes. You don't have to change ANY of your code to get permanent archiving of all your data with this gem. 80 | When you call `destroy` on any record (or `destroy_all` on a class or association) your records will 81 | all have a deleted_at timestamp set on them. 82 | 83 | ## Upgrading from 3.x 84 | 85 | The behaviour of the `destroy` method has been updated so that it now returns 86 | `false` when halted by a before_destroy callback. This is in line with behaviour 87 | of ActiveRecord. For more information see 88 | [#47](https://github.com/JackDanger/permanent_records/issues/47). 89 | 90 | ## Productionizing 91 | 92 | If you operate a system where destroying or reviving a record takes more 93 | than about 3 seconds then you'll want to customize 94 | `PermanentRecords.dependent_record_window = 10.seconds` or some other 95 | value that works for you. 96 | 97 | Patches welcome, forks celebrated. 98 | 99 | Copyright 2015 Jack Danger Canty @ [https://jdanger.com](https://jdanger.com) released under the MIT license 100 | -------------------------------------------------------------------------------- /lib/permanent_records.rb: -------------------------------------------------------------------------------- 1 | module PermanentRecords 2 | 3 | # This module defines the public api that you can 4 | # use in your model instances. 5 | # 6 | # * is_permanent? #=> true/false, depending if you have a deleted_at column 7 | # * deleted? #=> true/false, depending if you've called .destroy 8 | # * destroy #=> sets deleted_at, your record is now in the .destroyed scope 9 | # * revive #=> undo the destroy 10 | module ActiveRecord 11 | def self.included(base) 12 | 13 | base.extend Scopes 14 | base.extend IsPermanent 15 | 16 | base.instance_eval do 17 | define_model_callbacks :revive 18 | end 19 | end 20 | 21 | def is_permanent? 22 | respond_to?(:deleted_at) 23 | end 24 | 25 | def deleted? 26 | if is_permanent? 27 | !!deleted_at 28 | else 29 | destroyed? 30 | end 31 | end 32 | 33 | def revive(options = nil) 34 | with_transaction_returning_status do 35 | if PermanentRecords.should_revive_parent_first?(options) 36 | revival.reverse 37 | else 38 | revival 39 | end.each { |p| p.call(options) } 40 | 41 | self 42 | end 43 | end 44 | 45 | def destroy(force = nil) 46 | with_transaction_returning_status do 47 | if !is_permanent? || PermanentRecords.should_force_destroy?(force) 48 | permanently_delete_records_after { super() } 49 | else 50 | destroy_with_permanent_records force 51 | end 52 | end 53 | end 54 | 55 | private 56 | 57 | def revival 58 | [ ->(_validate) { revive_destroyed_dependent_records(_validate) }, 59 | ->(_validate) { run_callbacks(:revive) { set_deleted_at(nil, _validate) } } ] 60 | end 61 | 62 | def get_deleted_record 63 | if self.respond_to?(:parent_id) && self.parent_id.present? # Looking for parent on STI case 64 | self.class.unscoped.find(parent_id) 65 | else 66 | self.class.unscoped.find(id) 67 | end 68 | end 69 | 70 | def set_deleted_at(value, force = nil) 71 | return self unless is_permanent? 72 | record = get_deleted_record 73 | record.deleted_at = value 74 | begin 75 | # we call save! instead of update_attribute so an ActiveRecord::RecordInvalid 76 | # error will be raised if the record isn't valid. (This prevents reviving records that 77 | # disregard validation constraints,) 78 | if PermanentRecords.should_ignore_validations?(force) 79 | record.save(:validate => false) 80 | else 81 | record.save! 82 | end 83 | @attributes = record.instance_variable_get('@attributes') 84 | rescue Exception => e 85 | # trigger dependent record destruction (they were revived before this record, 86 | # which cannot be revived due to validations) 87 | record.destroy 88 | raise e 89 | end 90 | end 91 | 92 | def destroy_with_permanent_records(force = nil) 93 | run_callbacks(:destroy) do 94 | deleted? || new_record? ? save : set_deleted_at(Time.now, force) 95 | true 96 | end 97 | deleted? ? self : false 98 | end 99 | 100 | def add_record_window(request, name, reflection) 101 | send(name).unscope(where: :deleted_at).where( 102 | [ 103 | "#{reflection.quoted_table_name}.deleted_at > ?" + 104 | " AND " + 105 | "#{reflection.quoted_table_name}.deleted_at < ?", 106 | deleted_at - PermanentRecords.dependent_record_window, 107 | deleted_at + PermanentRecords.dependent_record_window 108 | ] 109 | ) 110 | end 111 | 112 | def revive_destroyed_dependent_records(force = nil) 113 | self.class.reflections.select do |name, reflection| 114 | 'destroy' == reflection.options[:dependent].to_s && reflection.klass.is_permanent? 115 | end.each do |name, reflection| 116 | cardinality = reflection.macro.to_s.gsub('has_', '').to_sym 117 | case cardinality 118 | when :many 119 | records = if deleted_at 120 | add_record_window(send(name), name, reflection) 121 | else 122 | send(name) 123 | end 124 | when :one, :belongs_to 125 | self.class.unscoped { records = [] << send(name) } 126 | end 127 | 128 | [records].flatten.compact.each do |dependent| 129 | dependent.revive(force) 130 | end 131 | 132 | # and update the reflection cache 133 | send(name, :reload) 134 | end 135 | end 136 | 137 | def attempt_notifying_observers(callback) 138 | begin 139 | notify_observers(callback) 140 | rescue NoMethodError => e 141 | # do nothing: this model isn't being observed 142 | end 143 | end 144 | 145 | # return the records corresponding to an association with the `:dependent => :destroy` option 146 | def get_dependent_records 147 | dependent_records = {} 148 | 149 | # check which dependent records are to be destroyed 150 | klass = self.class 151 | klass.reflections.each do |key, reflection| 152 | if reflection.options[:dependent] == :destroy 153 | next unless records = self.send(key) # skip if there are no dependent record instances 154 | if records.respond_to? :size 155 | next unless records.size > 0 # skip if there are no dependent record instances 156 | else 157 | records = [] << records 158 | end 159 | dependent_record = records.first 160 | next if dependent_record.nil? 161 | dependent_records[dependent_record.class] = records.map(&:id) 162 | end 163 | end 164 | dependent_records 165 | end 166 | 167 | # If we force the destruction of the record, we will need to force the destruction of dependent records if the 168 | # user specified `:dependent => :destroy` in the model. 169 | # By default, the call to super/destroy_with_permanent_records (i.e. the &block param) will only soft delete 170 | # the dependent records; we keep track of the dependent records 171 | # that have `:dependent => :destroy` and call destroy(force) on them after the call to super 172 | def permanently_delete_records_after(&block) 173 | dependent_records = get_dependent_records 174 | result = block.call 175 | if result 176 | permanently_delete_records(dependent_records) 177 | end 178 | result 179 | end 180 | 181 | # permanently delete the records (i.e. remove from database) 182 | def permanently_delete_records(dependent_records) 183 | dependent_records.each do |klass, ids| 184 | ids.each do |id| 185 | record = begin 186 | klass.unscoped.find id 187 | rescue ::ActiveRecord::RecordNotFound 188 | next # the record has already been deleted, possibly due to another association with `:dependent => :destroy` 189 | end 190 | record.deleted_at = nil 191 | record.destroy(:force) 192 | end 193 | end 194 | end 195 | end 196 | 197 | module Scopes 198 | def deleted 199 | where arel_table[:deleted_at].not_eq(nil) 200 | end 201 | 202 | def not_deleted 203 | where arel_table[:deleted_at].eq(nil) 204 | end 205 | end 206 | 207 | module IsPermanent 208 | def is_permanent? 209 | columns.detect {|c| 'deleted_at' == c.name} 210 | end 211 | end 212 | 213 | def self.should_force_destroy?(force) 214 | if Hash === force 215 | force[:force] 216 | else 217 | :force == force 218 | end 219 | end 220 | 221 | def self.should_revive_parent_first?(order) 222 | Hash === order && true == order[:reverse] 223 | end 224 | 225 | def self.should_ignore_validations?(force) 226 | Hash === force && false == force[:validate] 227 | end 228 | 229 | def self.dependent_record_window 230 | @dependent_record_window || 3.seconds 231 | end 232 | 233 | def self.dependent_record_window=(time_value) 234 | @dependent_record_window = time_value 235 | end 236 | end 237 | 238 | ActiveRecord::Base.send :include, PermanentRecords::ActiveRecord 239 | -------------------------------------------------------------------------------- /spec/permanent_records_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe PermanentRecords do 4 | 5 | let!(:frozen_moment) { Time.now } 6 | let!(:dirt) { Dirt.create! } 7 | let!(:earthworm) { dirt.create_earthworm } 8 | let!(:hole) { dirt.create_hole(:options => {}) } 9 | let!(:muskrat) { hole.muskrats.create! } 10 | let!(:mole) { hole.moles.create! } 11 | let!(:location) { hole.create_location } 12 | let!(:difficulty) { hole.create_difficulty } 13 | let!(:comments) { 2.times.map {hole.comments.create!} } 14 | let!(:kitty) { Kitty.create! } 15 | 16 | 17 | describe '#destroy' do 18 | 19 | let(:record) { hole } 20 | let(:should_force) { false } 21 | 22 | subject { record.destroy should_force } 23 | 24 | it 'returns the record' do 25 | subject.should == record 26 | end 27 | 28 | it 'makes deleted? return true' do 29 | subject.should be_deleted 30 | end 31 | 32 | it 'sets the deleted_at attribute' do 33 | subject.deleted_at.should be_within(0.1).of(Time.now) 34 | end 35 | 36 | it 'does not really remove the record' do 37 | expect { subject }.to_not change { record.class.count } 38 | end 39 | 40 | it 'handles serialized attributes correctly' do 41 | expect(subject.options).to eq({}) 42 | expect(subject.size).to be_nil if record.respond_to?(:size) 43 | end 44 | 45 | context 'with force argument set to truthy' do 46 | let(:should_force) { :force } 47 | 48 | it 'does really remove the record' do 49 | expect { subject }.to change { record.class.count }.by(-1) 50 | end 51 | end 52 | 53 | context 'with hash-style :force argument' do 54 | let(:should_force) {{ force: true }} 55 | 56 | it 'does really remove the record' do 57 | expect { subject }.to change { record.class.count }.by(-1) 58 | end 59 | end 60 | 61 | context 'when validations fail' do 62 | before { 63 | Hole.any_instance.stub(:valid?).and_return(false) 64 | } 65 | it 'raises' do 66 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 67 | end 68 | 69 | context 'with validation opt-out' do 70 | let(:should_force) {{ validate: false }} 71 | it 'doesnt raise' do 72 | expect { subject }.to_not raise_error 73 | end 74 | it 'soft-deletes the invalid record' do 75 | subject.should be_deleted 76 | end 77 | end 78 | end 79 | 80 | context 'when before_destroy returns false' do 81 | before do 82 | record.youre_in_the_hole = true 83 | end 84 | 85 | it 'returns false' do 86 | expect(subject).to eql(false) 87 | end 88 | 89 | it 'does not set deleted_at' do 90 | expect { subject }.not_to change { record.deleted_at } 91 | end 92 | 93 | # 4.x+ only 94 | if ::Gem::Version.new(::ActiveRecord::VERSION::STRING) >= ::Gem::Version.new('4.0.0') 95 | context 'and using the !' do 96 | it 'raises a ActiveRecord::RecordNotDestroyed exception' do 97 | expect { record.destroy! }.to raise_error(ActiveRecord::RecordNotDestroyed) 98 | end 99 | end 100 | end 101 | end 102 | 103 | context 'when model has no deleted_at column' do 104 | let(:record) { kitty } 105 | 106 | it 'really removes the record' do 107 | expect { subject }.to change { record.class.count }.by(-1) 108 | end 109 | 110 | it 'makes deleted? return true' do 111 | subject.should be_deleted 112 | end 113 | end 114 | 115 | context 'with dependent records' do 116 | context 'that are permanent' do 117 | it '' do 118 | expect { subject }.to_not change { Muskrat.count } 119 | end 120 | 121 | context 'with has_many cardinality' do 122 | it 'marks records as deleted' do 123 | subject.muskrats.each {|m| m.should be_deleted } 124 | end 125 | 126 | context 'when error occurs' do 127 | before { Hole.any_instance.stub(:valid?).and_return(false) } 128 | it 'does not mark records as deleted' do 129 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 130 | expect(record.muskrats.not_deleted.count).to eq(1) 131 | end 132 | end 133 | 134 | context 'with force delete' do 135 | let(:should_force) { :force } 136 | it('') { expect { subject }.to change { Muskrat.count }.by(-1) } 137 | it('') { expect { subject }.to change { Comment.count }.by(-2) } 138 | 139 | context 'when error occurs' do 140 | before { Difficulty.any_instance.stub(:destroy).and_return(false) } 141 | it('') { expect { subject }.not_to change { Muskrat.count } } 142 | it('') { expect { subject }.not_to change { Comment.count } } 143 | end 144 | end 145 | end 146 | 147 | context 'with has_one cardinality' do 148 | it 'marks records as deleted' do 149 | subject.location.should be_deleted 150 | end 151 | 152 | context 'when error occurs' do 153 | before { Hole.any_instance.stub(:valid?).and_return(false) } 154 | it('does not mark records as deleted') do 155 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 156 | expect(record.location(true)).not_to be_deleted 157 | end 158 | end 159 | 160 | context 'with force delete' do 161 | let(:should_force) { :force } 162 | it('') { expect { subject }.to change { Muskrat.count }.by(-1) } 163 | it('') { expect { subject }.to change { Location.count }.by(-1) } 164 | 165 | context 'when error occurs' do 166 | before { Difficulty.any_instance.stub(:destroy).and_return(false) } 167 | it('') { expect { subject }.not_to change { Muskrat.count } } 168 | it('') { expect { subject }.not_to change { Location.count } } 169 | end 170 | end 171 | end 172 | 173 | context 'with belongs_to cardinality' do 174 | it 'marks records as deleted' do 175 | subject.dirt.should be_deleted 176 | end 177 | 178 | context 'when error occurs' do 179 | before { Hole.any_instance.stub(:valid?).and_return(false) } 180 | it 'does not mark records as deleted' do 181 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 182 | expect(record.dirt(true)).not_to be_deleted 183 | end 184 | end 185 | 186 | context 'with force delete' do 187 | let(:should_force) { :force } 188 | it('') { expect { subject }.to change { Dirt.count }.by(-1) } 189 | 190 | context 'when error occurs' do 191 | before { Difficulty.any_instance.stub(:destroy).and_return(false) } 192 | it('') { expect { subject }.not_to change { Dirt.count } } 193 | end 194 | end 195 | end 196 | end 197 | 198 | context 'that are non-permanent' do 199 | it 'removes them' do 200 | expect { subject }.to change { Mole.count }.by(-1) 201 | end 202 | end 203 | 204 | context 'as default scope' do 205 | let(:load_comments) { Comment.unscoped.where(:hole_id => subject.id) } 206 | context 'with :has_many cardinality' do 207 | before { 208 | load_comments.size.should == 2 209 | } 210 | it 'deletes them' do 211 | load_comments.all?(&:deleted?).should be_true 212 | subject.comments.should be_blank 213 | end 214 | end 215 | context 'with :has_one cardinality' do 216 | it 'deletes them' do 217 | subject.difficulty.should be_deleted 218 | Difficulty.find_by_id(subject.difficulty.id).should be_nil 219 | end 220 | end 221 | end 222 | end 223 | end 224 | 225 | describe '#revive' do 226 | 227 | let!(:record) { hole.tap(&:destroy) } 228 | let(:should_validate) { nil } 229 | 230 | subject { record.revive should_validate } 231 | 232 | it 'returns the record' do 233 | subject.should == record 234 | end 235 | 236 | it 'unsets deleted_at' do 237 | expect { subject }.to change { 238 | record.deleted_at 239 | }.to(nil) 240 | end 241 | 242 | it 'makes deleted? return false' do 243 | subject.should_not be_deleted 244 | end 245 | 246 | context 'when validations fail' do 247 | before { 248 | Hole.any_instance.stub(:valid?).and_return(false) 249 | } 250 | it 'raises' do 251 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 252 | end 253 | 254 | context 'with validation opt-out' do 255 | let(:should_validate) {{ validate: false }} 256 | it 'doesnt raise' do 257 | expect { subject }.to_not raise_error 258 | end 259 | it 'makes deleted? return false' do 260 | subject.should_not be_deleted 261 | end 262 | end 263 | end 264 | 265 | context 'with dependent records' do 266 | context 'that are permanent' do 267 | it '' do 268 | expect { subject }.to_not change { Muskrat.count } 269 | end 270 | 271 | context 'that were deleted previously' do 272 | before { muskrat.update_attributes! :deleted_at => 2.minutes.ago } 273 | it 'does not restore' do 274 | expect { subject }.to_not change { muskrat.deleted? } 275 | end 276 | end 277 | 278 | context 'with has_many cardinality' do 279 | it 'revives them' do 280 | subject.muskrats.each {|m| m.should_not be_deleted } 281 | end 282 | context 'when error occurs' do 283 | before { Hole.any_instance.stub(:valid?).and_return(false) } 284 | it 'does not revive them' do 285 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 286 | expect(record.muskrats.deleted.count).to eq(1) 287 | end 288 | end 289 | end 290 | 291 | context 'with has_one cardinality' do 292 | it 'revives them' do 293 | subject.location.should_not be_deleted 294 | end 295 | context 'when error occurs' do 296 | before { Hole.any_instance.stub(:valid?).and_return(false) } 297 | it('does not mark records as deleted') do 298 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 299 | expect(record.location(true)).to be_deleted 300 | end 301 | end 302 | end 303 | 304 | context 'with belongs_to cardinality' do 305 | it 'revives them' do 306 | subject.dirt.should_not be_deleted 307 | end 308 | 309 | context 'when error occurs' do 310 | before { Hole.any_instance.stub(:valid?).and_return(false) } 311 | it 'does not revive them' do 312 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 313 | expect(record.dirt(true)).to be_deleted 314 | end 315 | end 316 | end 317 | end 318 | 319 | context 'that are non-permanent' do 320 | it 'cannot revive them' do 321 | expect { subject }.to_not change { Mole.count } 322 | end 323 | end 324 | 325 | context 'as default scope' do 326 | context 'with :has_many cardinality' do 327 | its('comments.size') { should == 2 } 328 | it 'revives them' do 329 | subject.comments.each {|c| c.should_not be_deleted } 330 | subject.comments.each {|c| Comment.find_by_id(c.id).should == c } 331 | end 332 | end 333 | context 'with :has_one cardinality' do 334 | it 'revives them' do 335 | subject.difficulty.should_not be_deleted 336 | Difficulty.find_by_id(subject.difficulty.id).should == difficulty 337 | end 338 | end 339 | end 340 | end 341 | end 342 | 343 | describe 'scopes' do 344 | 345 | before { 346 | 3.times { Muskrat.create!({hole: hole}) } 347 | 6.times { Muskrat.create!({hole: hole}).destroy } 348 | } 349 | 350 | context '.not_deleted' do 351 | 352 | it 'counts' do 353 | Muskrat.not_deleted.count.should == Muskrat.all.reject(&:deleted?).size 354 | end 355 | 356 | it 'has no deleted records' do 357 | Muskrat.not_deleted.each {|m| m.should_not be_deleted } 358 | end 359 | end 360 | 361 | context '.deleted' do 362 | it 'counts' do 363 | Muskrat.deleted.count.should == Muskrat.all.select(&:deleted?).size 364 | end 365 | 366 | it 'has no non-deleted records' do 367 | Muskrat.deleted.each {|m| m.should be_deleted } 368 | end 369 | end 370 | end 371 | end 372 | --------------------------------------------------------------------------------