├── lib ├── acts_as_paranoid │ ├── version.rb │ ├── validations.rb │ ├── relation.rb │ ├── associations.rb │ └── core.rb └── acts_as_paranoid.rb ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── ruby.yml ├── Gemfile ├── gemfiles ├── active_record_52.gemfile ├── active_record_60.gemfile ├── active_record_61.gemfile └── active_record_edge.gemfile ├── test ├── test_inheritance.rb ├── test_table_namespace.rb ├── test_dependent_recovery.rb ├── test_validations.rb ├── test_default_scopes.rb ├── test_relations.rb ├── test_associations.rb ├── test_helper.rb └── test_core.rb ├── LICENSE ├── .rubocop.yml ├── acts_as_paranoid.gemspec ├── Rakefile ├── .rubocop_todo.yml ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /lib/acts_as_paranoid/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsParanoid 4 | VERSION = "0.7.3" 5 | end 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pkg 2 | .bundle 3 | .DS_Store 4 | Gemfile.lock 5 | gemfiles/*.lock 6 | .idea/ 7 | .ruby-version 8 | coverage/ 9 | log/ 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Documentation for all configuration options: 2 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | - package-ecosystem: "bundler" 7 | directory: "/" 8 | schedule: 9 | interval: "daily" 10 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", require: "active_record" 6 | gem "activesupport", require: "active_support" 7 | 8 | # Development dependencies 9 | group :development do 10 | gem "activerecord-jdbcsqlite3-adapter", platforms: [:jruby] 11 | gem "sqlite3", platforms: [:ruby] 12 | end 13 | 14 | gemspec 15 | -------------------------------------------------------------------------------- /gemfiles/active_record_52.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 5.2.0", require: "active_record" 6 | gem "activesupport", "~> 5.2.0", require: "active_support" 7 | 8 | # Development dependencies 9 | group :development do 10 | gem "activerecord-jdbcsqlite3-adapter", platforms: [:jruby] 11 | gem "sqlite3", platforms: [:ruby] 12 | end 13 | 14 | gemspec path: "../" 15 | -------------------------------------------------------------------------------- /gemfiles/active_record_60.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.0.0", require: "active_record" 6 | gem "activesupport", "~> 6.0.0", require: "active_support" 7 | 8 | # Development dependencies 9 | group :development do 10 | gem "activerecord-jdbcsqlite3-adapter", "~> 60.0", platforms: [:jruby] 11 | gem "sqlite3", platforms: [:ruby] 12 | end 13 | 14 | gemspec path: "../" 15 | -------------------------------------------------------------------------------- /gemfiles/active_record_61.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "activerecord", "~> 6.1.0", require: "active_record" 6 | gem "activesupport", "~> 6.1.0", require: "active_support" 7 | 8 | # Development dependencies 9 | group :development do 10 | gem "activerecord-jdbcsqlite3-adapter", "~> 61.0", platforms: [:jruby] 11 | gem "sqlite3", platforms: [:ruby] 12 | end 13 | 14 | gemspec path: "../" 15 | -------------------------------------------------------------------------------- /gemfiles/active_record_edge.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | github "rails/rails" do 6 | gem "activerecord", require: "active_record" 7 | gem "activesupport", require: "active_support" 8 | end 9 | 10 | # Development dependencies 11 | group :development do 12 | gem "activerecord-jdbcsqlite3-adapter", platforms: [:jruby] 13 | gem "sqlite3", platforms: [:ruby] 14 | end 15 | 16 | gemspec path: "../" 17 | 18 | gem "arel", github: "rails/arel" 19 | -------------------------------------------------------------------------------- /test/test_inheritance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class InheritanceTest < ParanoidBaseTest 6 | def test_destroy_dependents_with_inheritance 7 | has_many_inherited_super_paranoidz = HasManyInheritedSuperParanoidz.new 8 | has_many_inherited_super_paranoidz.save 9 | has_many_inherited_super_paranoidz.super_paranoidz.create 10 | assert_nothing_raised { has_many_inherited_super_paranoidz.destroy } 11 | end 12 | 13 | def test_class_instance_variables_are_inherited 14 | assert_nothing_raised { InheritedParanoid.paranoid_column } 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/acts_as_paranoid/validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/core_ext/array/wrap" 4 | 5 | module ActsAsParanoid 6 | module Validations 7 | def self.included(base) 8 | base.extend ClassMethods 9 | end 10 | 11 | class UniquenessWithoutDeletedValidator < ActiveRecord::Validations::UniquenessValidator 12 | private 13 | 14 | def build_relation(klass, attribute, value) 15 | super.where(klass.paranoid_default_scope) 16 | end 17 | end 18 | 19 | module ClassMethods 20 | def validates_uniqueness_of_without_deleted(*attr_names) 21 | validates_with UniquenessWithoutDeletedValidator, _merge_attributes(attr_names) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/test_table_namespace.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class TableNamespaceTest < ActiveSupport::TestCase 6 | module Paranoid 7 | class Blob < ActiveRecord::Base 8 | acts_as_paranoid 9 | 10 | validates_presence_of :name 11 | 12 | def self.table_name_prefix 13 | "paranoid_" 14 | end 15 | end 16 | end 17 | 18 | def setup 19 | ActiveRecord::Schema.define(version: 1) do 20 | create_table :paranoid_blobs do |t| 21 | t.string :name 22 | t.datetime :deleted_at 23 | 24 | timestamps t 25 | end 26 | end 27 | end 28 | 29 | def teardown 30 | teardown_db 31 | end 32 | 33 | def test_correct_table_name 34 | assert_equal "paranoid_blobs", Paranoid::Blob.table_name 35 | 36 | b = Paranoid::Blob.new(name: "hello!") 37 | b.save! 38 | assert_equal b, Paranoid::Blob.first 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/acts_as_paranoid/relation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsParanoid 4 | module Relation 5 | def self.included(base) 6 | base.class_eval do 7 | def paranoid? 8 | klass.try(:paranoid?) ? true : false 9 | end 10 | 11 | def paranoid_deletion_attributes 12 | { klass.paranoid_column => klass.delete_now_value } 13 | end 14 | 15 | alias_method :orig_delete_all, :delete_all 16 | def delete_all!(conditions = nil) 17 | if conditions 18 | where(conditions).delete_all! 19 | else 20 | orig_delete_all 21 | end 22 | end 23 | 24 | def delete_all(conditions = nil) 25 | if paranoid? 26 | where(conditions).update_all(paranoid_deletion_attributes) 27 | else 28 | delete_all!(conditions) 29 | end 30 | end 31 | 32 | def destroy_fully!(id_or_array) 33 | where(primary_key => id_or_array).orig_delete_all 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2017 Zachary Scott, Gonçalo Silva, Rick Olson 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 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | inherit_from: .rubocop_todo.yml 2 | 3 | require: 4 | - rubocop-minitest 5 | 6 | # Exclude any vendored gems 7 | AllCops: 8 | Exclude: 9 | - 'vendor/**/*' 10 | NewCops: enable 11 | TargetRubyVersion: 2.5 12 | 13 | # Be lenient with line length 14 | Layout/LineLength: 15 | Max: 92 16 | 17 | # Multi-line method calls should be simply indented. Aligning them makes it 18 | # even harder to keep a sane line length. 19 | Layout/MultilineMethodCallIndentation: 20 | EnforcedStyle: indented 21 | 22 | # Multi-line assignment should be simply indented. Aligning them makes it even 23 | # harder to keep a sane line length. 24 | Layout/MultilineOperationIndentation: 25 | EnforcedStyle: indented 26 | 27 | # Allow test classes to have any length 28 | Metrics/ClassLength: 29 | Exclude: 30 | - 'test/**/*' 31 | 32 | # Allow test methods to have any length 33 | Metrics/MethodLength: 34 | Exclude: 35 | - 'test/**/*' 36 | 37 | # Allow else clauses with explicit nil value 38 | Style/EmptyElse: 39 | EnforcedStyle: empty 40 | 41 | # In guard clauses, if ! is often more immediately clear 42 | Style/NegatedIf: 43 | Enabled: false 44 | 45 | # Do not commit to use of interpolation 46 | Style/StringLiterals: 47 | EnforcedStyle: double_quotes 48 | 49 | # Prefer symbols to look like symbols 50 | Style/SymbolArray: 51 | EnforcedStyle: brackets 52 | -------------------------------------------------------------------------------- /test/test_dependent_recovery.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class DependentRecoveryTest < ActiveSupport::TestCase 6 | class ParanoidForest < ActiveRecord::Base 7 | acts_as_paranoid 8 | has_many :paranoid_trees, dependent: :destroy 9 | end 10 | 11 | class ParanoidTree < ActiveRecord::Base 12 | acts_as_paranoid 13 | belongs_to :paranoid_forest, optional: false 14 | end 15 | 16 | def setup 17 | ActiveRecord::Schema.define(version: 1) do 18 | create_table :paranoid_forests do |t| 19 | t.string :name 20 | t.boolean :rainforest 21 | t.datetime :deleted_at 22 | 23 | timestamps t 24 | end 25 | 26 | create_table :paranoid_trees do |t| 27 | t.integer :paranoid_forest_id 28 | t.string :name 29 | t.datetime :deleted_at 30 | 31 | timestamps t 32 | end 33 | end 34 | end 35 | 36 | def teardown 37 | teardown_db 38 | end 39 | 40 | def test_recover_dependent_records_with_required_belongs_to 41 | forest = ParanoidForest.create! name: "forest" 42 | 43 | tree = ParanoidTree.new name: "tree" 44 | refute tree.valid? 45 | tree.paranoid_forest = forest 46 | assert tree.valid? 47 | tree.save! 48 | 49 | forest.destroy 50 | forest.recover 51 | 52 | assert_equal 1, ParanoidTree.count 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /lib/acts_as_paranoid/associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsParanoid 4 | module Associations 5 | def self.included(base) 6 | base.extend ClassMethods 7 | class << base 8 | alias_method :belongs_to_without_deleted, :belongs_to 9 | alias_method :belongs_to, :belongs_to_with_deleted 10 | end 11 | end 12 | 13 | module ClassMethods 14 | def belongs_to_with_deleted(target, scope = nil, options = {}) 15 | if scope.is_a?(Hash) 16 | options = scope 17 | scope = nil 18 | end 19 | 20 | with_deleted = options.delete(:with_deleted) 21 | if with_deleted 22 | if scope 23 | old_scope = scope 24 | scope = proc do |*args| 25 | if old_scope.arity == 0 26 | instance_exec(&old_scope).with_deleted 27 | else 28 | old_scope.call(*args).with_deleted 29 | end 30 | end 31 | else 32 | scope = proc do 33 | if respond_to? :with_deleted 34 | self.with_deleted 35 | else 36 | all 37 | end 38 | end 39 | end 40 | end 41 | 42 | result = belongs_to_without_deleted(target, scope, **options) 43 | 44 | result.values.last.options[:with_deleted] = with_deleted if with_deleted 45 | 46 | result 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /acts_as_paranoid.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path("lib", __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require "acts_as_paranoid/version" 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "acts_as_paranoid" 9 | spec.version = ActsAsParanoid::VERSION 10 | spec.authors = ["Zachary Scott", "Goncalo Silva", "Rick Olson"] 11 | spec.email = ["e@zzak.io"] 12 | spec.summary = "Active Record plugin which allows you to hide and restore" \ 13 | " records without actually deleting them." 14 | spec.description = "Check the home page for more in-depth information." 15 | spec.homepage = "https://github.com/ActsAsParanoid/acts_as_paranoid" 16 | spec.license = "MIT" 17 | 18 | spec.required_ruby_version = ">= 2.5.0" 19 | 20 | spec.files = Dir["{lib}/**/*.rb", "LICENSE", "*.md"] 21 | spec.test_files = Dir["test/*.rb"] 22 | spec.require_paths = ["lib"] 23 | 24 | spec.add_dependency "activerecord", ">= 5.2", "< 7.0" 25 | spec.add_dependency "activesupport", ">= 5.2", "< 7.0" 26 | 27 | spec.add_development_dependency "bundler", ">= 1.5", "< 3.0" 28 | spec.add_development_dependency "minitest", "~> 5.14" 29 | spec.add_development_dependency "minitest-focus", "~> 1.3.0" 30 | spec.add_development_dependency "pry" 31 | spec.add_development_dependency "rake" 32 | spec.add_development_dependency "rdoc" 33 | spec.add_development_dependency "rubocop", "~> 1.19.0" 34 | spec.add_development_dependency "rubocop-minitest", "~> 0.15.0" 35 | spec.add_development_dependency "simplecov", [">= 0.18.1", "< 0.22.0"] 36 | end 37 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | 5 | require "rake/testtask" 6 | require "rdoc/task" 7 | require "rubocop/rake_task" 8 | 9 | gemspec = eval(File.read(Dir["*.gemspec"].first)) 10 | 11 | namespace :test do 12 | versions = Dir["gemfiles/*.gemfile"] 13 | .map { |gemfile_path| gemfile_path.split(%r{/|\.})[1] } 14 | 15 | versions.each do |version| 16 | desc "Test acts_as_paranoid against #{version}" 17 | task version do 18 | if ENV["RUBYOPT"] =~ %r{bundler/setup} 19 | raise "Do not run the test:#{version} task with bundle exec!" 20 | end 21 | 22 | sh "BUNDLE_GEMFILE='gemfiles/#{version}.gemfile' bundle install --quiet" 23 | sh "BUNDLE_GEMFILE='gemfiles/#{version}.gemfile' bundle exec rake -t test" 24 | end 25 | end 26 | 27 | desc "Run all tests for acts_as_paranoid" 28 | task all: versions 29 | end 30 | 31 | Rake::TestTask.new(:test) do |t| 32 | t.libs << "test" 33 | t.pattern = "test/test_*.rb" 34 | t.verbose = true 35 | end 36 | 37 | RuboCop::RakeTask.new 38 | 39 | desc "Generate documentation for the acts_as_paranoid plugin." 40 | Rake::RDocTask.new(:rdoc) do |rdoc| 41 | rdoc.rdoc_dir = "rdoc" 42 | rdoc.title = "ActsAsParanoid" 43 | rdoc.options << "--line-numbers" << "--inline-source" 44 | rdoc.rdoc_files.include("README") 45 | rdoc.rdoc_files.include("lib/**/*.rb") 46 | end 47 | 48 | desc "Install gem locally" 49 | task install: :build do 50 | system "gem install pkg/#{gemspec.name}-#{gemspec.version}" 51 | end 52 | 53 | desc "Clean automatically generated files" 54 | task :clean do 55 | FileUtils.rm_rf "pkg" 56 | end 57 | 58 | desc "Default: run unit tests" 59 | task default: "test:all" 60 | -------------------------------------------------------------------------------- /lib/acts_as_paranoid.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | require "acts_as_paranoid/core" 5 | require "acts_as_paranoid/associations" 6 | require "acts_as_paranoid/validations" 7 | require "acts_as_paranoid/relation" 8 | 9 | module ActsAsParanoid 10 | def paranoid? 11 | included_modules.include?(ActsAsParanoid::Core) 12 | end 13 | 14 | def validates_as_paranoid 15 | include ActsAsParanoid::Validations 16 | end 17 | 18 | def acts_as_paranoid(options = {}) 19 | if !options.is_a?(Hash) && !options.empty? 20 | raise ArgumentError, "Hash expected, got #{options.class.name}" 21 | end 22 | 23 | class_attribute :paranoid_configuration 24 | 25 | self.paranoid_configuration = { 26 | column: "deleted_at", 27 | column_type: "time", 28 | recover_dependent_associations: true, 29 | dependent_recovery_window: 2.minutes, 30 | double_tap_destroys_fully: true 31 | } 32 | if options[:column_type] == "string" 33 | paranoid_configuration.merge!(deleted_value: "deleted") 34 | end 35 | 36 | paranoid_configuration.merge!(options) # user options 37 | 38 | unless %w[time boolean string].include? paranoid_configuration[:column_type] 39 | raise ArgumentError, 40 | "'time', 'boolean' or 'string' expected for :column_type option," \ 41 | " got #{paranoid_configuration[:column_type]}" 42 | end 43 | 44 | return if paranoid? 45 | 46 | include ActsAsParanoid::Core 47 | 48 | # Magic! 49 | default_scope { where(paranoid_default_scope) } 50 | 51 | define_deleted_time_scopes if paranoid_column_type == :time 52 | end 53 | end 54 | 55 | # Extend ActiveRecord's functionality 56 | ActiveRecord::Base.extend ActsAsParanoid 57 | 58 | # Extend ActiveRecord::Base with paranoid associations 59 | ActiveRecord::Base.include ActsAsParanoid::Associations 60 | 61 | # Override ActiveRecord::Relation's behavior 62 | ActiveRecord::Relation.include ActsAsParanoid::Relation 63 | 64 | # Push the recover callback onto the activerecord callback list 65 | ActiveRecord::Callbacks::CALLBACKS.push(:before_recover, :after_recover) 66 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow will download a prebuilt Ruby version, install dependencies and 2 | # run tests with Rake 3 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 4 | 5 | name: Ruby 6 | 7 | on: 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | ruby: [2.5, 2.6, 2.7, "3.0", jruby-9.2] 20 | gemfile: [active_record_52, active_record_60, active_record_61] 21 | exclude: 22 | # Rails 5.2 does not support Ruby 3.0 23 | - ruby: "3.0" 24 | gemfile: active_record_52 25 | 26 | env: 27 | BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Set up Ruby 32 | uses: ruby/setup-ruby@v1 33 | with: 34 | ruby-version: ${{ matrix.ruby }} 35 | - name: Configure bundler 36 | run: | 37 | bundle config path vendor/bundle 38 | - name: Create bundler lockfile 39 | run: | 40 | bundle lock 41 | - uses: actions/cache@v2 42 | with: 43 | # NOTE: Bundler expands the path relative to the gemfile, not the 44 | # current directory. 45 | path: ./gemfiles/vendor/bundle 46 | key: bundled-gems-${{ runner.os }}-ruby-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ hashFiles( 'gemfiles/*.lock' ) }} 47 | restore-keys: | 48 | bundled-gems-${{ runner.os }}-ruby-${{ matrix.ruby }}-${{ matrix.gemfile }}- 49 | bundled-gems-${{ runner.os }}-ruby-${{ matrix.ruby }}- 50 | - name: Install gems 51 | run: | 52 | bundle install --jobs 4 53 | - name: Run tests 54 | run: bundle exec rake test 55 | 56 | rubocop: 57 | runs-on: ubuntu-latest 58 | 59 | steps: 60 | - uses: actions/checkout@v2 61 | - name: Set up Ruby 62 | uses: ruby/setup-ruby@v1 63 | with: 64 | ruby-version: 2.7 65 | bundler-cache: true 66 | - name: Run RuboCop 67 | run: bundle exec rubocop -P 68 | -------------------------------------------------------------------------------- /test/test_validations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ValidatesUniquenessTest < ParanoidBaseTest 6 | def test_should_include_deleted_by_default 7 | ParanoidTime.new(name: "paranoid").tap do |record| 8 | refute record.valid? 9 | ParanoidTime.first.destroy 10 | refute record.valid? 11 | ParanoidTime.only_deleted.first.destroy! 12 | assert record.valid? 13 | end 14 | end 15 | 16 | def test_should_validate_without_deleted 17 | ParanoidBoolean.new(name: "paranoid").tap do |record| 18 | refute record.valid? 19 | ParanoidBoolean.first.destroy 20 | assert record.valid? 21 | ParanoidBoolean.only_deleted.first.destroy! 22 | assert record.valid? 23 | end 24 | end 25 | 26 | def test_validate_serialized_attribute_without_deleted 27 | ParanoidWithSerializedColumn.create!(name: "ParanoidWithSerializedColumn #1", 28 | colors: %w[Cyan Maroon]) 29 | record = ParanoidWithSerializedColumn.new(name: "ParanoidWithSerializedColumn #2") 30 | record.colors = %w[Cyan Maroon] 31 | refute record.valid? 32 | 33 | record.colors = %w[Beige Turquoise] 34 | assert record.valid? 35 | end 36 | 37 | def test_updated_serialized_attribute_validated_without_deleted 38 | record = ParanoidWithSerializedColumn.create!(name: "ParanoidWithSerializedColumn #1", 39 | colors: %w[Cyan Maroon]) 40 | record.update!(colors: %w[Beige Turquoise]) 41 | assert record.valid? 42 | end 43 | 44 | def test_models_with_scoped_validations_can_be_multiply_deleted 45 | model_a = ParanoidWithScopedValidation.create(name: "Model A", category: "Category A") 46 | model_b = ParanoidWithScopedValidation.create(name: "Model B", category: "Category B") 47 | 48 | ParanoidWithScopedValidation.delete([model_a.id, model_b.id]) 49 | 50 | assert_paranoid_deletion(model_a) 51 | assert_paranoid_deletion(model_b) 52 | end 53 | 54 | def test_models_with_scoped_validations_can_be_multiply_destroyed 55 | model_a = ParanoidWithScopedValidation.create(name: "Model A", category: "Category A") 56 | model_b = ParanoidWithScopedValidation.create(name: "Model B", category: "Category B") 57 | 58 | ParanoidWithScopedValidation.destroy([model_a.id, model_b.id]) 59 | 60 | assert_paranoid_deletion(model_a) 61 | assert_paranoid_deletion(model_b) 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config --no-offense-counts --no-auto-gen-timestamp` 3 | # using RuboCop version 1.12.1. 4 | # The point is for the user to remove these configuration records 5 | # one by one as the offenses are removed from the code base. 6 | # Note that changes in the inspected code, or installation of new 7 | # versions of RuboCop, may require this file to be generated again. 8 | 9 | # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. 10 | Metrics/AbcSize: 11 | Max: 47 12 | 13 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 14 | # IgnoredMethods: refine 15 | Metrics/BlockLength: 16 | Max: 29 17 | 18 | # Configuration parameters: IgnoredMethods. 19 | Metrics/CyclomaticComplexity: 20 | Max: 10 21 | 22 | # Configuration parameters: CountComments, CountAsOne, ExcludedMethods, IgnoredMethods. 23 | Metrics/MethodLength: 24 | Max: 31 25 | 26 | # Configuration parameters: CountComments, CountAsOne. 27 | Metrics/ModuleLength: 28 | Max: 160 29 | 30 | # Configuration parameters: IgnoredMethods. 31 | Metrics/PerceivedComplexity: 32 | Max: 10 33 | 34 | Minitest/AssertionInLifecycleHook: 35 | Exclude: 36 | - 'test/test_default_scopes.rb' 37 | - 'test/test_relations.rb' 38 | 39 | Minitest/MultipleAssertions: 40 | Max: 16 41 | 42 | # Cop supports --auto-correct. 43 | Minitest/TestMethodName: 44 | Exclude: 45 | - 'test/test_core.rb' 46 | 47 | # Configuration parameters: EnforcedStyle, CheckMethodNames, CheckSymbols, AllowedIdentifiers. 48 | # SupportedStyles: snake_case, normalcase, non_integer 49 | # AllowedIdentifiers: capture3, iso8601, rfc1123_date, rfc822, rfc2822, rfc3339 50 | Naming/VariableNumber: 51 | Exclude: 52 | - 'test/test_core.rb' 53 | - 'test/test_relations.rb' 54 | 55 | Security/Eval: 56 | Exclude: 57 | - 'Rakefile' 58 | 59 | # Configuration parameters: AllowedConstants. 60 | Style/Documentation: 61 | Exclude: 62 | - 'spec/**/*' 63 | - 'test/**/*' 64 | - 'lib/acts_as_paranoid.rb' 65 | - 'lib/acts_as_paranoid/associations.rb' 66 | - 'lib/acts_as_paranoid/core.rb' 67 | - 'lib/acts_as_paranoid/relation.rb' 68 | - 'lib/acts_as_paranoid/validations.rb' 69 | 70 | # Cop supports --auto-correct. 71 | # Configuration parameters: EnforcedStyle, IgnoredMethods. 72 | # SupportedStyles: predicate, comparison 73 | Style/NumericPredicate: 74 | Exclude: 75 | - 'spec/**/*' 76 | - 'lib/acts_as_paranoid/associations.rb' 77 | -------------------------------------------------------------------------------- /test/test_default_scopes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class MultipleDefaultScopesTest < ActiveSupport::TestCase 6 | def setup 7 | ActiveRecord::Schema.define(version: 1) do 8 | create_table :paranoid_polygons do |t| 9 | t.integer :sides 10 | t.datetime :deleted_at 11 | 12 | timestamps t 13 | end 14 | end 15 | 16 | ParanoidPolygon.create! sides: 3 17 | ParanoidPolygon.create! sides: 3 18 | ParanoidPolygon.create! sides: 3 19 | ParanoidPolygon.create! sides: 8 20 | 21 | assert_equal 3, ParanoidPolygon.count 22 | assert_equal 4, ParanoidPolygon.unscoped.count 23 | end 24 | 25 | def teardown 26 | teardown_db 27 | end 28 | 29 | def test_only_deleted_with_deleted_with_multiple_default_scope 30 | 3.times { ParanoidPolygon.create! sides: 3 } 31 | ParanoidPolygon.create! sides: 8 32 | ParanoidPolygon.first.destroy 33 | assert_equal 1, ParanoidPolygon.only_deleted.count 34 | assert_equal 1, ParanoidPolygon.only_deleted.with_deleted.count 35 | end 36 | 37 | def test_with_deleted_only_deleted_with_multiple_default_scope 38 | 3.times { ParanoidPolygon.create! sides: 3 } 39 | ParanoidPolygon.create! sides: 8 40 | ParanoidPolygon.first.destroy 41 | assert_equal 1, ParanoidPolygon.only_deleted.count 42 | assert_equal 1, ParanoidPolygon.with_deleted.only_deleted.count 43 | end 44 | 45 | def test_fake_removal_with_multiple_default_scope 46 | ParanoidPolygon.first.destroy 47 | assert_equal 2, ParanoidPolygon.count 48 | assert_equal 3, ParanoidPolygon.with_deleted.count 49 | assert_equal 1, ParanoidPolygon.only_deleted.count 50 | assert_equal 4, ParanoidPolygon.unscoped.count 51 | 52 | ParanoidPolygon.destroy_all 53 | assert_equal 0, ParanoidPolygon.count 54 | assert_equal 3, ParanoidPolygon.with_deleted.count 55 | assert_equal 3, ParanoidPolygon.with_deleted.count 56 | assert_equal 4, ParanoidPolygon.unscoped.count 57 | end 58 | 59 | def test_real_removal_with_multiple_default_scope 60 | # two-step 61 | ParanoidPolygon.first.destroy 62 | ParanoidPolygon.only_deleted.first.destroy 63 | assert_equal 2, ParanoidPolygon.count 64 | assert_equal 2, ParanoidPolygon.with_deleted.count 65 | assert_equal 0, ParanoidPolygon.only_deleted.count 66 | assert_equal 3, ParanoidPolygon.unscoped.count 67 | 68 | ParanoidPolygon.first.destroy_fully! 69 | assert_equal 1, ParanoidPolygon.count 70 | assert_equal 1, ParanoidPolygon.with_deleted.count 71 | assert_equal 0, ParanoidPolygon.only_deleted.count 72 | assert_equal 2, ParanoidPolygon.unscoped.count 73 | 74 | ParanoidPolygon.delete_all! 75 | assert_equal 0, ParanoidPolygon.count 76 | assert_equal 0, ParanoidPolygon.with_deleted.count 77 | assert_equal 0, ParanoidPolygon.only_deleted.count 78 | assert_equal 1, ParanoidPolygon.unscoped.count 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to ActsAsParanoid 2 | 3 | We welcome all contributions to ActsAsParanoid. Below are some guidelines to 4 | help the process of handling issues and pull requests go smoothly. 5 | 6 | ## Issues 7 | 8 | When creating an issue, please try to provide as much information as possible. 9 | Also, please follow the guidelines below to make it easier for us to figure out 10 | what's going on. If you miss any of these points we will probably ask you to 11 | improve the ticket. 12 | 13 | - Include a clear title describing the problem 14 | - Describe what you are trying to achieve 15 | - Describe what you did, preferably including relevant code 16 | - Describe what you expected to happen 17 | - Describe what happened instead, possibly including relevant output 18 | - Use [code blocks](https://github.github.com/gfm/#fenced-code-blocks) to 19 | format any code and output in your ticket to make it readable. 20 | 21 | ## Pull requests 22 | 23 | If you have an idea for a particular feature, it's probably best to create a 24 | GitHub issue for it before trying to implement it yourself. That way, we can 25 | discuss the feature and whether it makes sense to include in ActsAsParanoid itself 26 | before putting in the work to implement it. 27 | 28 | If you want to send pull requests or patches, try to follow the instructions 29 | below. **If you get stuck, please make a pull request anyway and we'll try to 30 | help out.** 31 | 32 | - Make sure `rake test` runs without reporting any failures. 33 | - Add tests for your feature. Otherwise, we can't see if it works or if we 34 | break it later. 35 | - Make sure latest master merges cleanly with your branch. Things might 36 | have moved around since you forked. 37 | - Try not to include changes that are irrelevant to your feature in the 38 | same commit. 39 | - Keep an eye on the build results in GitHub Actions. If the build fails and it 40 | seems due to your changes, please update your pull request with a fix. 41 | 42 | ### The review process 43 | 44 | - We will try to review your pull request as soon as possible but we can make no 45 | guarantees. Feel free to ping us now and again. 46 | - We will probably ask you to rebase your branch on current master at some point 47 | during the review process. 48 | If you are unsure how to do this, 49 | [this in-depth guide](https://git-rebase.io/) should help out. 50 | - If you have any unclear commit messages, work-in-progress commits, or commits 51 | that just fix a mistake in a previous commits, we will ask you to clean up 52 | the history. 53 | Again, [the git-rebase guide](https://git-rebase.io/) should help out. 54 | - At the end of the review process we may still choose not to merge your pull 55 | request. For example, this could happen if we decide the proposed feature 56 | should not be part of ActsAsParanoid, or if the technical implementation does not 57 | match where we want to go with the architecture the project. 58 | - We will generally not merge any pull requests that make the build fail, unless 59 | it's very clearly not related to the changes in the pull request. 60 | -------------------------------------------------------------------------------- /test/test_relations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class RelationsTest < ActiveSupport::TestCase 6 | class ParanoidForest < ActiveRecord::Base 7 | acts_as_paranoid 8 | 9 | scope :rainforest, -> { where(rainforest: true) } 10 | 11 | has_many :paranoid_trees, dependent: :destroy 12 | end 13 | 14 | class ParanoidTree < ActiveRecord::Base 15 | acts_as_paranoid 16 | belongs_to :paranoid_forest 17 | validates_presence_of :name 18 | end 19 | 20 | class NotParanoidBowl < ActiveRecord::Base 21 | has_many :paranoid_chocolates, dependent: :destroy 22 | end 23 | 24 | class ParanoidChocolate < ActiveRecord::Base 25 | acts_as_paranoid 26 | belongs_to :not_paranoid_bowl 27 | validates_presence_of :name 28 | end 29 | 30 | def setup 31 | ActiveRecord::Schema.define(version: 1) do 32 | create_table :paranoid_forests do |t| 33 | t.string :name 34 | t.boolean :rainforest 35 | t.datetime :deleted_at 36 | 37 | timestamps t 38 | end 39 | 40 | create_table :paranoid_trees do |t| 41 | t.integer :paranoid_forest_id 42 | t.string :name 43 | t.datetime :deleted_at 44 | 45 | timestamps t 46 | end 47 | 48 | create_table :not_paranoid_bowls do |t| 49 | t.string :name 50 | 51 | timestamps t 52 | end 53 | 54 | create_table :paranoid_chocolates do |t| 55 | t.integer :not_paranoid_bowl_id 56 | t.string :name 57 | t.datetime :deleted_at 58 | 59 | timestamps t 60 | end 61 | end 62 | 63 | @paranoid_forest_1 = ParanoidForest.create! name: "ParanoidForest #1" 64 | @paranoid_forest_2 = ParanoidForest.create! name: "ParanoidForest #2", rainforest: true 65 | @paranoid_forest_3 = ParanoidForest.create! name: "ParanoidForest #3", rainforest: true 66 | 67 | assert_equal 3, ParanoidForest.count 68 | assert_equal 2, ParanoidForest.rainforest.count 69 | 70 | @paranoid_forest_1.paranoid_trees.create! name: "ParanoidTree #1" 71 | @paranoid_forest_1.paranoid_trees.create! name: "ParanoidTree #2" 72 | @paranoid_forest_2.paranoid_trees.create! name: "ParanoidTree #3" 73 | @paranoid_forest_2.paranoid_trees.create! name: "ParanoidTree #4" 74 | 75 | assert_equal 4, ParanoidTree.count 76 | end 77 | 78 | def teardown 79 | teardown_db 80 | end 81 | 82 | def test_filtering_with_scopes 83 | assert_equal 2, ParanoidForest.rainforest.with_deleted.count 84 | assert_equal 2, ParanoidForest.with_deleted.rainforest.count 85 | 86 | assert_equal 0, ParanoidForest.rainforest.only_deleted.count 87 | assert_equal 0, ParanoidForest.only_deleted.rainforest.count 88 | 89 | ParanoidForest.rainforest.first.destroy 90 | assert_equal 1, ParanoidForest.rainforest.count 91 | 92 | assert_equal 2, ParanoidForest.rainforest.with_deleted.count 93 | assert_equal 2, ParanoidForest.with_deleted.rainforest.count 94 | 95 | assert_equal 1, ParanoidForest.rainforest.only_deleted.count 96 | assert_equal 1, ParanoidForest.only_deleted.rainforest.count 97 | end 98 | 99 | def test_associations_filtered_by_with_deleted 100 | assert_equal 2, @paranoid_forest_1.paranoid_trees.with_deleted.count 101 | assert_equal 2, @paranoid_forest_2.paranoid_trees.with_deleted.count 102 | 103 | @paranoid_forest_1.paranoid_trees.first.destroy 104 | assert_equal 1, @paranoid_forest_1.paranoid_trees.count 105 | assert_equal 2, @paranoid_forest_1.paranoid_trees.with_deleted.count 106 | assert_equal 4, ParanoidTree.with_deleted.count 107 | 108 | @paranoid_forest_2.paranoid_trees.first.destroy 109 | assert_equal 1, @paranoid_forest_2.paranoid_trees.count 110 | assert_equal 2, @paranoid_forest_2.paranoid_trees.with_deleted.count 111 | assert_equal 4, ParanoidTree.with_deleted.count 112 | 113 | @paranoid_forest_1.paranoid_trees.first.destroy 114 | assert_equal 0, @paranoid_forest_1.paranoid_trees.count 115 | assert_equal 2, @paranoid_forest_1.paranoid_trees.with_deleted.count 116 | assert_equal 4, ParanoidTree.with_deleted.count 117 | end 118 | 119 | def test_associations_filtered_by_only_deleted 120 | assert_equal 0, @paranoid_forest_1.paranoid_trees.only_deleted.count 121 | assert_equal 0, @paranoid_forest_2.paranoid_trees.only_deleted.count 122 | 123 | @paranoid_forest_1.paranoid_trees.first.destroy 124 | assert_equal 1, @paranoid_forest_1.paranoid_trees.only_deleted.count 125 | assert_equal 1, ParanoidTree.only_deleted.count 126 | 127 | @paranoid_forest_2.paranoid_trees.first.destroy 128 | assert_equal 1, @paranoid_forest_2.paranoid_trees.only_deleted.count 129 | assert_equal 2, ParanoidTree.only_deleted.count 130 | 131 | @paranoid_forest_1.paranoid_trees.first.destroy 132 | assert_equal 2, @paranoid_forest_1.paranoid_trees.only_deleted.count 133 | assert_equal 3, ParanoidTree.only_deleted.count 134 | end 135 | 136 | def test_fake_removal_through_relation 137 | # destroy: through a relation. 138 | ParanoidForest.rainforest.destroy(@paranoid_forest_3.id) 139 | assert_equal 1, ParanoidForest.rainforest.count 140 | assert_equal 2, ParanoidForest.rainforest.with_deleted.count 141 | assert_equal 1, ParanoidForest.rainforest.only_deleted.count 142 | 143 | # destroy_all: through a relation 144 | @paranoid_forest_2.paranoid_trees.destroy_all 145 | assert_equal 0, @paranoid_forest_2.paranoid_trees.count 146 | assert_equal 2, @paranoid_forest_2.paranoid_trees.with_deleted.count 147 | end 148 | 149 | def test_fake_removal_through_has_many_relation_of_non_paranoid_model 150 | not_paranoid = NotParanoidBowl.create! name: "NotParanoid #1" 151 | not_paranoid.paranoid_chocolates.create! name: "ParanoidChocolate #1" 152 | not_paranoid.paranoid_chocolates.create! name: "ParanoidChocolate #2" 153 | 154 | not_paranoid.paranoid_chocolates.destroy_all 155 | assert_equal 0, not_paranoid.paranoid_chocolates.count 156 | assert_equal 2, not_paranoid.paranoid_chocolates.with_deleted.count 157 | end 158 | 159 | def test_real_removal_through_relation_with_destroy_fully 160 | ParanoidForest.rainforest.destroy_fully!(@paranoid_forest_3) 161 | assert_equal 1, ParanoidForest.rainforest.count 162 | assert_equal 1, ParanoidForest.rainforest.with_deleted.count 163 | assert_equal 0, ParanoidForest.rainforest.only_deleted.count 164 | end 165 | 166 | def test_two_step_real_removal_through_relation_with_destroy 167 | # destroy: two-step through a relation 168 | paranoid_tree = @paranoid_forest_1.paranoid_trees.first 169 | @paranoid_forest_1.paranoid_trees.destroy(paranoid_tree.id) 170 | @paranoid_forest_1.paranoid_trees.only_deleted.destroy(paranoid_tree.id) 171 | assert_equal 1, @paranoid_forest_1.paranoid_trees.count 172 | assert_equal 1, @paranoid_forest_1.paranoid_trees.with_deleted.count 173 | assert_equal 0, @paranoid_forest_1.paranoid_trees.only_deleted.count 174 | end 175 | 176 | def test_two_step_real_removal_through_relation_with_destroy_all 177 | # destroy_all: two-step through a relation 178 | @paranoid_forest_1.paranoid_trees.destroy_all 179 | @paranoid_forest_1.paranoid_trees.only_deleted.destroy_all 180 | assert_equal 0, @paranoid_forest_1.paranoid_trees.count 181 | assert_equal 0, @paranoid_forest_1.paranoid_trees.with_deleted.count 182 | assert_equal 0, @paranoid_forest_1.paranoid_trees.only_deleted.count 183 | end 184 | 185 | def test_real_removal_through_relation_with_delete_all_bang 186 | # delete_all!: through a relation 187 | @paranoid_forest_2.paranoid_trees.delete_all! 188 | assert_equal 0, @paranoid_forest_2.paranoid_trees.count 189 | assert_equal 0, @paranoid_forest_2.paranoid_trees.with_deleted.count 190 | assert_equal 0, @paranoid_forest_2.paranoid_trees.only_deleted.count 191 | end 192 | end 193 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | Notable changes to this project will be documented in this file. 4 | 5 | ## Upcoming 6 | 7 | * Do not set `paranoid_value` when destroying fully ([#238], by [Aymeric Le Dorze][aymeric-ledorze]) 8 | * Make helper methods for dependent associations private ([#239], by [Matijs van Zuijlen][mvz]) 9 | * Raise ActiveRecord::RecordNotDestroyed if destroy returns false ([#240], by [Hao Liu][leomayleomay]) 10 | * Make unscoping by `with_deleted` less blunt ([#241], by [Matijs van Zuijlen][mvz]) 11 | * Drop support for Ruby 2.4 ([#243] by [Matijs van Zuijlen][mvz]) 12 | 13 | ## 0.7.3 14 | 15 | ## Improvements 16 | 17 | * Fix deletion time scopes ([#212] by [Matijs van Zuijlen][mvz]) 18 | * Reload `has_one` associations after dependent recovery ([#214], 19 | by [Matijs van Zuijlen][mvz]) 20 | * Make dependent recovery work when parent is non-optional ([#227], 21 | by [Matijs van Zuijlen][mvz]) 22 | * Avoid querying nil `belongs_to` associations when recovering ([#219], 23 | by [Matijs van Zuijlen][mvz]) 24 | * On relations, deprecate `destroy!` in favour of `destroy_fully!` ([#222], 25 | by [Matijs van Zuijlen][mvz]) 26 | * Deprecate the undocumented `:recovery_value` setting. Calculate the correct 27 | value instead. ([#220], by [Matijs van Zuijlen][mvz]) 28 | 29 | ## Developer experience 30 | 31 | * Log ActiveRecord activity to a visible log during tests ([#218], 32 | by [Matijs van Zuijlen][mvz]) 33 | 34 | ## 0.7.2 35 | 36 | * Do not set boolean column to NULL on recovery if nulls are not allowed 37 | ([#193], by [Shodai Suzuki][soartec-lab]) 38 | * Add a CONTRIBUTING.md file ([#207], by [Matijs van Zuijlen][mvz]) 39 | 40 | ## 0.7.1 41 | 42 | * Support Rails 6.1 ([#191], by [Matijs van Zuijlen][mvz]) 43 | * Support `belongs_to` with both `:touch` and `:counter_cache` options ([#208], 44 | by [Matijs van Zuijlen][mvz] with [Paul Druziak][pauldruziak]) 45 | * Support Ruby 3.0 ([#209], by [Matijs van Zuijlen][mvz]) 46 | 47 | ## 0.7.0 48 | 49 | ### Breaking changes 50 | 51 | * Support Rails 5.2+ only ([#126], by [Daniel Rice][danielricecodes]) 52 | * Update set of supported rubies to 2.4-2.7 ([#144], [#173] by [Matijs van Zuijlen][mvz]) 53 | 54 | ### Improvements 55 | 56 | * Handle `with_deleted` association option as a scope ([#147], by [Matijs van Zuijlen][mvz]) 57 | * Simplify validation override ([#158], by [Matijs van Zuijlen][mvz]) 58 | * Use correct unscope syntax so unscope works on Rails Edge ([#160], 59 | by [Matijs van Zuijlen][mvz]) 60 | * Fix ruby 2.7 keyword argument deprecation warning ([#161], by [Jon Riddle][wtfspm]) 61 | 62 | ### Documentation 63 | 64 | * Document save after destroy behavior ([#146], by [Matijs van Zuijlen][mvz]) 65 | * Update version number instructions for installing gem ([#164], 66 | by [Kevin McAlear][kevinmcalear]) 67 | * Add example with `destroyed_fully?` and `deleted_fully?` to the readme ([#170], 68 | by [Kiril Mitov][thebravoman]) 69 | 70 | ### Internal 71 | 72 | * Improve code quality using RuboCop ([#148], [#152], [#159], [#163], [#171] and [#173], 73 | by [Matijs van Zuijlen][mvz]) 74 | * Measure code coverage using SimpleCov ([#150] and [#175] by [Matijs van Zuijlen][mvz]) 75 | * Silence warnings emitted during tests ([#156], by [Matijs van Zuijlen][mvz]) 76 | * Make rake tasks more robust and intuitive ([#157], by [Matijs van Zuijlen][mvz]) 77 | 78 | ## 0.6.3 79 | 80 | * Update Travis CI configuration ([#137], by [Matijs van Zuijlen][mvz]) 81 | * Add predicate to check if record was soft deleted or hard deleted ([#136], 82 | by [Aymeric Le Dorze][aymeric-ledorze]) 83 | * Add support for recover! method ([#75], by [vinoth][avinoth]) 84 | * Fix a record being dirty after destroying it ([#135], by 85 | [Aymeric Le Dorze][aymeric-ledorze]) 86 | 87 | ## 0.6.2 88 | 89 | * Prevent recovery of non-deleted records 90 | ([#133], by [Mary Beliveau][marycodes2] and [Valerie Woolard][valeriecodes]) 91 | * Allow model to set `table_name` after `acts_as_paranoid` macro 92 | ([#131], by [Alex Wheeler][AlexWheeler]) 93 | * Make counter cache work with a custom column name and with optional 94 | associations ([#123], by [Ned Campion][nedcampion]) 95 | 96 | ## 0.6.1 97 | 98 | * Add support for Rails 6 ([#124], by [Daniel Rice][danielricecodes], 99 | [Josh Bryant][jbryant92], and [Romain Alexandre][RomainAlexandre]) 100 | * Add support for incrementing and decrementing counter cache columns on 101 | associated objects ([#119], by [Dimitar Lukanov][shadydealer]) 102 | * Add `:double_tap_destroys_fully` option, with default `true` ([#116], 103 | by [Michael Riviera][ri4a]) 104 | * Officially support Ruby 2.6 ([#114], by [Matijs van Zuijlen][mvz]) 105 | 106 | ## 0.6.0 and earlier 107 | 108 | (To be added) 109 | 110 | 111 | 112 | [AlexWheeler]: https://github.com/AlexWheeler 113 | [RomainAlexandre]: https://github.com/RomainAlexandre 114 | [avinoth]: https://github.com/avinoth 115 | [aymeric-ledorze]: https://github.com/aymeric-ledorze 116 | [danielricecodes]: https://github.com/danielricecodes 117 | [jbryant92]: https://github.com/jbryant92 118 | [kevinmcalear]: https://github.com/kevinmcalear 119 | [leomayleomay]: https://github.com/leomayleomay 120 | [marycodes2]: https://github.com/marycodes2 121 | [mvz]: https://github.com/mvz 122 | [nedcampion]: https://github.com/nedcampion 123 | [ri4a]: https://github.com/ri4a 124 | [pauldruziak]: https://github.com/pauldruziak 125 | [shadydealer]: https://github.com/shadydealer 126 | [soartec-lab]: https://github.com/soartec-lab 127 | [thebravoman]: https://github.com/thebravoman 128 | [valeriecodes]: https://github.com/valeriecodes 129 | [wtfspm]: https://github.com/wtfspm 130 | 131 | 132 | 133 | [#243]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/243 134 | [#241]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/241 135 | [#240]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/240 136 | [#239]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/239 137 | [#238]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/238 138 | [#227]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/227 139 | [#222]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/222 140 | [#220]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/220 141 | [#219]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/219 142 | [#218]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/218 143 | [#214]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/214 144 | [#212]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/212 145 | [#209]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/209 146 | [#208]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/208 147 | [#207]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/207 148 | [#193]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/193 149 | [#191]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/191 150 | [#175]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/175 151 | [#173]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/173 152 | [#171]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/171 153 | [#170]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/170 154 | [#164]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/164 155 | [#163]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/163 156 | [#161]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/161 157 | [#160]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/160 158 | [#159]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/159 159 | [#158]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/158 160 | [#157]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/157 161 | [#156]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/156 162 | [#152]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/152 163 | [#150]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/150 164 | [#148]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/148 165 | [#147]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/147 166 | [#146]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/146 167 | [#144]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/144 168 | [#137]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/137 169 | [#136]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/136 170 | [#135]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/135 171 | [#133]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/133 172 | [#131]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/131 173 | [#126]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/126 174 | [#124]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/124 175 | [#123]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/123 176 | [#119]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/119 177 | [#116]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/116 178 | [#114]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/114 179 | [#75]: https://github.com/ActsAsParanoid/acts_as_paranoid/pull/75 180 | -------------------------------------------------------------------------------- /lib/acts_as_paranoid/core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActsAsParanoid 4 | module Core 5 | def self.included(base) 6 | base.extend ClassMethods 7 | end 8 | 9 | module ClassMethods 10 | def self.extended(base) 11 | base.define_callbacks :recover 12 | end 13 | 14 | def before_recover(method) 15 | set_callback :recover, :before, method 16 | end 17 | 18 | def after_recover(method) 19 | set_callback :recover, :after, method 20 | end 21 | 22 | def with_deleted 23 | without_paranoid_default_scope 24 | end 25 | 26 | def only_deleted 27 | if string_type_with_deleted_value? 28 | without_paranoid_default_scope 29 | .where(paranoid_column_reference => paranoid_configuration[:deleted_value]) 30 | elsif boolean_type_not_nullable? 31 | without_paranoid_default_scope.where(paranoid_column_reference => true) 32 | else 33 | without_paranoid_default_scope.where.not(paranoid_column_reference => nil) 34 | end 35 | end 36 | 37 | def delete_all!(conditions = nil) 38 | without_paranoid_default_scope.delete_all!(conditions) 39 | end 40 | 41 | def delete_all(conditions = nil) 42 | where(conditions) 43 | .update_all(["#{paranoid_configuration[:column]} = ?", delete_now_value]) 44 | end 45 | 46 | def paranoid_default_scope 47 | if string_type_with_deleted_value? 48 | all.table[paranoid_column].eq(nil) 49 | .or(all.table[paranoid_column].not_eq(paranoid_configuration[:deleted_value])) 50 | elsif boolean_type_not_nullable? 51 | all.table[paranoid_column].eq(false) 52 | else 53 | all.table[paranoid_column].eq(nil) 54 | end 55 | end 56 | 57 | def string_type_with_deleted_value? 58 | paranoid_column_type == :string && !paranoid_configuration[:deleted_value].nil? 59 | end 60 | 61 | def boolean_type_not_nullable? 62 | paranoid_column_type == :boolean && !paranoid_configuration[:allow_nulls] 63 | end 64 | 65 | def paranoid_column 66 | paranoid_configuration[:column].to_sym 67 | end 68 | 69 | def paranoid_column_type 70 | paranoid_configuration[:column_type].to_sym 71 | end 72 | 73 | def paranoid_column_reference 74 | "#{table_name}.#{paranoid_column}" 75 | end 76 | 77 | def dependent_destroy_associations 78 | reflect_on_all_associations.select do |a| 79 | [:destroy].include?(a.options[:dependent]) 80 | end 81 | end 82 | 83 | def dependent_delete_associations 84 | reflect_on_all_associations.select do |a| 85 | [:delete_all].include?(a.options[:dependent]) 86 | end 87 | 88 | def delete_now_value 89 | case paranoid_configuration[:column_type] 90 | when "time" then Time.now 91 | when "boolean" then true 92 | when "string" then paranoid_configuration[:deleted_value] 93 | end 94 | end 95 | 96 | def recovery_value 97 | if boolean_type_not_nullable? 98 | false 99 | else 100 | nil 101 | end 102 | end 103 | 104 | protected 105 | 106 | def define_deleted_time_scopes 107 | scope :deleted_inside_time_window, lambda { |time, window| 108 | deleted_after_time((time - window)).deleted_before_time((time + window)) 109 | } 110 | 111 | scope :deleted_after_time, lambda { |time| 112 | only_deleted 113 | .where("#{table_name}.#{paranoid_column} > ?", time) 114 | } 115 | scope :deleted_before_time, lambda { |time| 116 | only_deleted 117 | .where("#{table_name}.#{paranoid_column} < ?", time) 118 | } 119 | end 120 | 121 | def without_paranoid_default_scope 122 | scope = all 123 | 124 | # unscope avoids applying the default scope when using this scope for associations 125 | scope = scope.unscope(where: paranoid_column) 126 | 127 | paranoid_where_clause = 128 | ActiveRecord::Relation::WhereClause.new([paranoid_default_scope]) 129 | 130 | scope.where_clause = all.where_clause - paranoid_where_clause 131 | 132 | scope 133 | end 134 | end 135 | 136 | def persisted? 137 | !(new_record? || @destroyed) 138 | end 139 | 140 | def paranoid_value 141 | send(self.class.paranoid_column) 142 | end 143 | 144 | # Straight from ActiveRecord 5.1! 145 | def delete 146 | self.class.delete(id) if persisted? 147 | stale_paranoid_value 148 | freeze 149 | end 150 | 151 | def destroy_fully! 152 | with_transaction_returning_status do 153 | run_callbacks :destroy do 154 | destroy_dependent_associations! 155 | 156 | if persisted? 157 | # Handle composite keys, otherwise we would just use 158 | # `self.class.primary_key.to_sym => self.id`. 159 | self.class 160 | .delete_all!([Array(self.class.primary_key), Array(id)].transpose.to_h) 161 | decrement_counters_on_associations 162 | end 163 | 164 | @destroyed = true 165 | freeze 166 | end 167 | end 168 | end 169 | 170 | def destroy! 171 | destroy || raise( 172 | ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self) 173 | ) 174 | end 175 | 176 | def destroy 177 | if !deleted? 178 | with_transaction_returning_status do 179 | run_callbacks :destroy do 180 | if persisted? 181 | # Handle composite keys, otherwise we would just use 182 | # `self.class.primary_key.to_sym => self.id`. 183 | self.class 184 | .delete_all([Array(self.class.primary_key), Array(id)].transpose.to_h) 185 | decrement_counters_on_associations 186 | end 187 | 188 | @_trigger_destroy_callback = true 189 | 190 | stale_paranoid_value 191 | self 192 | end 193 | end 194 | elsif paranoid_configuration[:double_tap_destroys_fully] 195 | destroy_fully! 196 | end 197 | end 198 | 199 | def recover(options = {}) 200 | return if !deleted? 201 | 202 | options = { 203 | recursive: self.class.paranoid_configuration[:recover_dependent_associations], 204 | recovery_window: self.class.paranoid_configuration[:dependent_recovery_window], 205 | raise_error: false 206 | }.merge(options) 207 | 208 | self.class.transaction do 209 | run_callbacks :recover do 210 | increment_counters_on_associations 211 | deleted_value = paranoid_value 212 | self.paranoid_value = self.class.recovery_value 213 | result = if options[:raise_error] 214 | save! 215 | else 216 | save 217 | end 218 | recover_dependent_associations(deleted_value, options) if options[:recursive] 219 | result 220 | end 221 | end 222 | end 223 | 224 | def recover!(options = {}) 225 | options[:raise_error] = true 226 | 227 | recover(options) 228 | end 229 | 230 | def deleted? 231 | return true if @destroyed 232 | 233 | if self.class.string_type_with_deleted_value? 234 | paranoid_value == paranoid_configuration[:deleted_value] 235 | elsif self.class.boolean_type_not_nullable? 236 | paranoid_value == true 237 | else 238 | !paranoid_value.nil? 239 | end 240 | end 241 | 242 | alias destroyed? deleted? 243 | 244 | def deleted_fully? 245 | @destroyed 246 | end 247 | 248 | alias destroyed_fully? deleted_fully? 249 | 250 | private 251 | 252 | def recover_dependent_associations(deleted_value, options) 253 | self.class.dependent_associations.each do |reflection| 254 | recover_dependent_association(reflection, deleted_value, options) 255 | end 256 | end 257 | 258 | def destroy_dependent_associations! 259 | self.class.dependent_destroy_associations.each do |reflection| 260 | assoc = association(reflection.name) 261 | next unless (klass = assoc.klass).paranoid? 262 | 263 | klass 264 | .only_deleted.merge(get_association_scope(assoc)) 265 | .each(&:destroy!) 266 | end 267 | 268 | self.class.dependent_delete_associations.each do |reflection| 269 | assoc = association(reflection.name) 270 | next unless (klass = assoc.klass).paranoid? 271 | 272 | klass 273 | .only_deleted.merge(get_association_scope(assoc)) 274 | .each(&:delete) 275 | end 276 | 277 | end 278 | 279 | def recover_dependent_association(reflection, deleted_value, options) 280 | assoc = association(reflection.name) 281 | return unless (klass = assoc.klass).paranoid? 282 | 283 | if reflection.belongs_to? && attributes[reflection.association_foreign_key].nil? 284 | return 285 | end 286 | 287 | scope = klass.only_deleted.merge(get_association_scope(assoc)) 288 | 289 | # We can only recover by window if both parent and dependant have a 290 | # paranoid column type of :time. 291 | if self.class.paranoid_column_type == :time && klass.paranoid_column_type == :time 292 | scope = scope.deleted_inside_time_window(deleted_value, options[:recovery_window]) 293 | end 294 | 295 | recovered = false 296 | scope.each do |object| 297 | object.recover(options) 298 | recovered = true 299 | end 300 | 301 | assoc.reload if recovered && reflection.has_one? && assoc.loaded? 302 | end 303 | 304 | def get_association_scope(dependent_association) 305 | ActiveRecord::Associations::AssociationScope.scope(dependent_association) 306 | end 307 | 308 | def paranoid_value=(value) 309 | write_attribute(self.class.paranoid_column, value) 310 | end 311 | 312 | def update_counters_on_associations(method_sym) 313 | each_counter_cached_association_reflection do |assoc_reflection| 314 | reflection_options = assoc_reflection.options 315 | next unless reflection_options[:counter_cache] 316 | 317 | associated_object = send(assoc_reflection.name) 318 | next unless associated_object 319 | 320 | counter_cache_column = assoc_reflection.counter_cache_column 321 | associated_object.class.send(method_sym, counter_cache_column, 322 | associated_object.id) 323 | associated_object.touch if reflection_options[:touch] 324 | end 325 | end 326 | 327 | def each_counter_cached_association_reflection 328 | _reflections.each do |_name, reflection| 329 | yield reflection if reflection.belongs_to? && reflection.counter_cache_column 330 | end 331 | end 332 | 333 | def increment_counters_on_associations 334 | update_counters_on_associations :increment_counter 335 | end 336 | 337 | def decrement_counters_on_associations 338 | update_counters_on_associations :decrement_counter 339 | end 340 | 341 | def stale_paranoid_value 342 | self.paranoid_value = self.class.delete_now_value 343 | clear_attribute_changes([self.class.paranoid_column]) 344 | end 345 | end 346 | end 347 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActsAsParanoid 2 | 3 | [![Build Status](https://travis-ci.org/ActsAsParanoid/acts_as_paranoid.png?branch=master)](https://travis-ci.org/ActsAsParanoid/acts_as_paranoid) 4 | 5 | A Rails plugin to add soft delete. 6 | 7 | This gem can be used to hide records instead of deleting them, making them 8 | recoverable later. 9 | 10 | ## Support 11 | 12 | **This branch targets Rails 5.2+ and Ruby 2.5+ only** 13 | 14 | If you're working with Rails 5.1 and earlier, or with Ruby 2.4 or earlier, 15 | please switch to the corresponding branch or require an older version of the 16 | `acts_as_paranoid` gem. 17 | 18 | ### Known issues 19 | 20 | * Using `acts_as_paranoid` and ActiveStorage on the same model 21 | [leads to a SystemStackError](https://github.com/ActsAsParanoid/acts_as_paranoid/issues/103). 22 | * You cannot directly create a model in a deleted state, or update a model 23 | after it's been deleted. 24 | 25 | ## Usage 26 | 27 | #### Install gem: 28 | 29 | ```ruby 30 | gem 'acts_as_paranoid' 31 | ``` 32 | 33 | ```shell 34 | bundle install 35 | ``` 36 | 37 | #### Create migration 38 | 39 | ```shell 40 | bin/rails generate migration AddDeletedAtToParanoiac deleted_at:datetime:index 41 | ``` 42 | 43 | #### Enable ActsAsParanoid 44 | 45 | ```ruby 46 | class Paranoiac < ActiveRecord::Base 47 | acts_as_paranoid 48 | end 49 | ``` 50 | 51 | By default, ActsAsParanoid assumes a record's *deletion* is stored in a 52 | `datetime` column called `deleted_at`. 53 | 54 | ### Options 55 | 56 | If you are using a different column name and type to store a record's 57 | *deletion*, you can specify them as follows: 58 | 59 | - `column: 'deleted'` 60 | - `column_type: 'boolean'` 61 | 62 | While *column* can be anything (as long as it exists in your database), *type* 63 | is restricted to: 64 | 65 | - `boolean` 66 | - `time` or 67 | - `string` 68 | 69 | Note that the `time` type corresponds to the database column type `datetime` 70 | in your Rails migrations and schema. 71 | 72 | If your column type is a `string`, you can also specify which value to use when 73 | marking an object as deleted by passing `:deleted_value` (default is 74 | "deleted"). Any records with a non-matching value in this column will be 75 | treated normally, i.e., as not deleted. 76 | 77 | If your column type is a `boolean`, it is possible to specify `allow_nulls` 78 | option which is `true` by default. When set to `false`, entities that have 79 | `false` value in this column will be considered not deleted, and those which 80 | have `true` will be considered deleted. When `true` everything that has a 81 | not-null value will be considered deleted. 82 | 83 | ### Filtering 84 | 85 | If a record is deleted by ActsAsParanoid, it won't be retrieved when accessing 86 | the database. 87 | 88 | So, `Paranoiac.all` will **not** include the **deleted records**. 89 | 90 | When you want to access them, you have 2 choices: 91 | 92 | ```ruby 93 | Paranoiac.only_deleted # retrieves only the deleted records 94 | Paranoiac.with_deleted # retrieves all records, deleted or not 95 | ``` 96 | 97 | When using the default `column_type` of `'time'`, the following extra scopes 98 | are provided: 99 | 100 | ```ruby 101 | time = Time.now 102 | 103 | Paranoiac.deleted_after_time(time) 104 | Paranoiac.deleted_before_time(time) 105 | 106 | # Or roll it all up and get a nice window: 107 | Paranoiac.deleted_inside_time_window(time, 2.minutes) 108 | ``` 109 | 110 | ### Real deletion 111 | 112 | In order to really delete a record, just use: 113 | 114 | ```ruby 115 | paranoiac.destroy_fully! 116 | Paranoiac.delete_all!(conditions) 117 | ``` 118 | 119 | **NOTE:** The `.destroy!` method is still usable, but equivalent to `.destroy`. 120 | It just hides the object. 121 | 122 | Alternatively you can permanently delete a record by calling `destroy` or 123 | `delete_all` on the object **twice**. 124 | 125 | If a record was already deleted (hidden by `ActsAsParanoid`) and you delete it 126 | again, it will be removed from the database. 127 | 128 | Take this example: 129 | 130 | ```ruby 131 | p = Paranoiac.first 132 | 133 | # does NOT delete the first record, just hides it 134 | p.destroy 135 | 136 | # deletes the first record from the database 137 | Paranoiac.only_deleted.where(id: p.id).first.destroy 138 | ``` 139 | 140 | This behaviour can be disabled by setting the configuration option. In a future 141 | version, `false` will be the default setting. 142 | 143 | - `double_tap_destroys_fully: false` 144 | 145 | ### Recovery 146 | 147 | Recovery is easy. Just invoke `recover` on it, like this: 148 | 149 | ```ruby 150 | Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover 151 | ``` 152 | 153 | All associations marked as `dependent: :destroy` are also recursively recovered. 154 | 155 | If you would like to disable this behavior, you can call `recover` with the 156 | `recursive` option: 157 | 158 | ```ruby 159 | Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover(recursive: false) 160 | ``` 161 | 162 | If you would like to change this default behavior for one model, you can use 163 | the `recover_dependent_associations` option 164 | 165 | ```ruby 166 | class Paranoiac < ActiveRecord::Base 167 | acts_as_paranoid recover_dependent_associations: false 168 | end 169 | ``` 170 | 171 | By default, dependent records will be recovered if they were deleted within 2 172 | minutes of the object upon which they depend. 173 | 174 | This restores the objects to the state before the recursive deletion without 175 | restoring other objects that were deleted earlier. 176 | 177 | The behavior is only available when both parent and dependant are using 178 | timestamp fields to mark deletion, which is the default behavior. 179 | 180 | This window can be changed with the `dependent_recovery_window` option: 181 | 182 | ```ruby 183 | class Paranoiac < ActiveRecord::Base 184 | acts_as_paranoid 185 | has_many :paranoids, dependent: :destroy 186 | end 187 | 188 | class Paranoid < ActiveRecord::Base 189 | belongs_to :paranoic 190 | 191 | # Paranoid objects will be recovered alongside Paranoic objects 192 | # if they were deleted within 10 minutes of the Paranoic object 193 | acts_as_paranoid dependent_recovery_window: 10.minutes 194 | end 195 | ``` 196 | 197 | or in the recover statement 198 | 199 | ```ruby 200 | Paranoiac.only_deleted.where("name = ?", "not dead yet").first 201 | .recover(recovery_window: 30.seconds) 202 | ``` 203 | 204 | ### recover! 205 | 206 | You can invoke `recover!` if you wish to raise an error if the recovery fails. 207 | The error generally stems from ActiveRecord. 208 | 209 | ```ruby 210 | Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover! 211 | # => ActiveRecord::RecordInvalid: Validation failed: Name already exists 212 | ``` 213 | 214 | Optionally, you may also raise the error by passing `raise_error: true` to the 215 | `recover` method. This behaves the same as `recover!`. 216 | 217 | ```ruby 218 | Paranoiac.only_deleted.where("name = ?", "not dead yet").first.recover(raise_error: true) 219 | ``` 220 | 221 | ### Validation 222 | 223 | ActiveRecord's built-in uniqueness validation does not account for records 224 | deleted by ActsAsParanoid. If you want to check for uniqueness among 225 | non-deleted records only, use the macro `validates_as_paranoid` in your model. 226 | Then, instead of using `validates_uniqueness_of`, use 227 | `validates_uniqueness_of_without_deleted`. This will keep deleted records from 228 | counting against the uniqueness check. 229 | 230 | ```ruby 231 | class Paranoiac < ActiveRecord::Base 232 | acts_as_paranoid 233 | validates_as_paranoid 234 | validates_uniqueness_of_without_deleted :name 235 | end 236 | 237 | p1 = Paranoiac.create(name: 'foo') 238 | p1.destroy 239 | 240 | p2 = Paranoiac.new(name: 'foo') 241 | p2.valid? #=> true 242 | p2.save 243 | 244 | p1.recover #=> fails validation! 245 | ``` 246 | 247 | ### Status 248 | 249 | A paranoid object could be deleted or destroyed fully. 250 | 251 | You can check if the object is deleted with the `deleted?` helper 252 | 253 | ```ruby 254 | Paranoiac.create(name: 'foo').destroy 255 | Paranoiac.with_deleted.first.deleted? #=> true 256 | ``` 257 | 258 | After the first call to `.destroy` the object is `deleted?`. 259 | 260 | You can check if the object is fully destroyed with `destroyed_fully?` or `deleted_fully?`. 261 | 262 | ```ruby 263 | Paranoiac.create(name: 'foo').destroy 264 | Paranoiac.with_deleted.first.deleted? #=> true 265 | Paranoiac.with_deleted.first.destroyed_fully? #=> false 266 | p1 = Paranoiac.with_deleted.first 267 | p1.destroy # this fully destroys the object 268 | p1.destroyed_fully? #=> true 269 | p1.deleted_fully? #=> true 270 | ``` 271 | 272 | ### Scopes 273 | 274 | As you've probably guessed, `with_deleted` and `only_deleted` are scopes. You 275 | can, however, chain them freely with other scopes you might have. 276 | 277 | For example: 278 | 279 | ```ruby 280 | Paranoiac.pretty.with_deleted 281 | ``` 282 | 283 | This is exactly the same as: 284 | 285 | ```ruby 286 | Paranoiac.with_deleted.pretty 287 | ``` 288 | 289 | You can work freely with scopes and it will just work: 290 | 291 | ```ruby 292 | class Paranoiac < ActiveRecord::Base 293 | acts_as_paranoid 294 | scope :pretty, where(pretty: true) 295 | end 296 | 297 | Paranoiac.create(pretty: true) 298 | 299 | Paranoiac.pretty.count #=> 1 300 | Paranoiac.only_deleted.count #=> 0 301 | Paranoiac.pretty.only_deleted.count #=> 0 302 | 303 | Paranoiac.first.destroy 304 | 305 | Paranoiac.pretty.count #=> 0 306 | Paranoiac.only_deleted.count #=> 1 307 | Paranoiac.pretty.only_deleted.count #=> 1 308 | ``` 309 | 310 | ### Associations 311 | 312 | Associations are also supported. 313 | 314 | From the simplest behaviors you'd expect to more nifty things like the ones 315 | mentioned previously or the usage of the `:with_deleted` option with 316 | `belongs_to` 317 | 318 | ```ruby 319 | class Parent < ActiveRecord::Base 320 | has_many :children, class_name: "ParanoiacChild" 321 | end 322 | 323 | class ParanoiacChild < ActiveRecord::Base 324 | acts_as_paranoid 325 | belongs_to :parent 326 | 327 | # You may need to provide a foreign_key like this 328 | belongs_to :parent_including_deleted, class_name: "Parent", 329 | foreign_key: 'parent_id', with_deleted: true 330 | end 331 | 332 | parent = Parent.first 333 | child = parent.children.create 334 | parent.destroy 335 | 336 | child.parent #=> nil 337 | child.parent_including_deleted #=> Parent (it works!) 338 | ``` 339 | 340 | ### Callbacks 341 | 342 | There are couple of callbacks that you may use when dealing with deletion and 343 | recovery of objects. There is `before_recover` and `after_recover` which will 344 | be triggered before and after the recovery of an object respectively. 345 | 346 | Default ActiveRecord callbacks such as `before_destroy` and `after_destroy` will 347 | be triggered around `.destroy!` and `.destroy_fully!`. 348 | 349 | ```ruby 350 | class Paranoiac < ActiveRecord::Base 351 | acts_as_paranoid 352 | 353 | before_recover :set_counts 354 | after_recover :update_logs 355 | end 356 | ``` 357 | 358 | ## Caveats 359 | 360 | Watch out for these caveats: 361 | 362 | - You cannot use scopes named `with_deleted` and `only_deleted` 363 | - You cannot use scopes named `deleted_inside_time_window`, 364 | `deleted_before_time`, `deleted_after_time` **if** your paranoid column's 365 | type is `time` 366 | - You cannot name association `*_with_deleted` 367 | - `unscoped` will return all records, deleted or not 368 | 369 | # Acknowledgements 370 | 371 | * To [Rick Olson](https://github.com/technoweenie) for creating `acts_as_paranoid` 372 | * To [cheerfulstoic](https://github.com/cheerfulstoic) for adding recursive recovery 373 | * To [Jonathan Vaught](https://github.com/gravelpup) for adding paranoid validations 374 | * To [Geoffrey Hichborn](https://github.com/phene) for improving the overral code quality and adding support for after_commit 375 | * To [flah00](https://github.com/flah00) for adding support for STI-based associations (with :dependent) 376 | * To [vikramdhillon](https://github.com/vikramdhillon) for the idea and initial implementation of support for string column type 377 | * To [Craig Walker](https://github.com/softcraft-development) for Rails 3.1 support and fixing various pending issues 378 | * To [Charles G.](https://github.com/chuckg) for Rails 3.2 support and for making a desperately needed global code refactoring 379 | * To [Gonçalo Silva](https://github.com/goncalossilva) for supporting this gem prior to v0.4.3 380 | * To [Jean Boussier](https://github.com/byroot) for initial Rails 4.0.0 support 381 | * To [Matijs van Zuijlen](https://github.com/mvz) for Rails 4.1 and 4.2 support 382 | * To [Andrey Ponomarenko](https://github.com/sjke) for Rails 5 support 383 | * To [Daniel Rice](https://github.com/danielricecodes), [Josh Bryant](https://github.com/jbryant92), and [Romain Alexandre](https://github.com/RomainAlexandre) for Rails 6.0 support. 384 | 385 | See `LICENSE`. 386 | -------------------------------------------------------------------------------- /test/test_associations.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class AssociationsTest < ParanoidBaseTest 6 | def test_removal_with_destroy_associations 7 | paranoid_company = ParanoidDestroyCompany.create! name: "ParanoidDestroyCompany #1" 8 | paranoid_company.paranoid_products.create! name: "ParanoidProduct #1" 9 | 10 | assert_equal 1, ParanoidDestroyCompany.count 11 | assert_equal 1, ParanoidProduct.count 12 | 13 | ParanoidDestroyCompany.first.destroy 14 | assert_equal 0, ParanoidDestroyCompany.count 15 | assert_equal 0, ParanoidProduct.count 16 | assert_equal 1, ParanoidDestroyCompany.with_deleted.count 17 | assert_equal 1, ParanoidProduct.with_deleted.count 18 | 19 | ParanoidDestroyCompany.with_deleted.first.destroy 20 | assert_equal 0, ParanoidDestroyCompany.count 21 | assert_equal 0, ParanoidProduct.count 22 | assert_equal 0, ParanoidDestroyCompany.with_deleted.count 23 | assert_equal 0, ParanoidProduct.with_deleted.count 24 | end 25 | 26 | def test_removal_with_delete_all_associations 27 | paranoid_company = ParanoidDeleteCompany.create! name: "ParanoidDestroyCompany #1" 28 | paranoid_company.paranoid_products.create! name: "ParanoidProduct #2" 29 | 30 | assert_equal 1, ParanoidDeleteCompany.count 31 | assert_equal 1, ParanoidProduct.count 32 | 33 | ParanoidDeleteCompany.first.destroy 34 | assert_equal 0, ParanoidDeleteCompany.count 35 | assert_equal 0, ParanoidProduct.count 36 | assert_equal 1, ParanoidDeleteCompany.with_deleted.count 37 | assert_equal 1, ParanoidProduct.with_deleted.count 38 | 39 | ParanoidDeleteCompany.with_deleted.first.destroy 40 | assert_equal 0, ParanoidDeleteCompany.count 41 | assert_equal 0, ParanoidProduct.count 42 | assert_equal 0, ParanoidDeleteCompany.with_deleted.count 43 | assert_equal 0, ParanoidProduct.with_deleted.count 44 | end 45 | 46 | def test_belongs_to_with_scope_option 47 | paranoid_has_many_dependant = ParanoidHasManyDependant.new 48 | 49 | expected_includes_values = ParanoidTime.includes(:not_paranoid).includes_values 50 | includes_values = paranoid_has_many_dependant 51 | .association(:paranoid_time_with_scope).scope.includes_values 52 | 53 | assert_equal expected_includes_values, includes_values 54 | 55 | paranoid_time = ParanoidTime.create(name: "not-hello") 56 | paranoid_has_many_dependant.paranoid_time = paranoid_time 57 | paranoid_has_many_dependant.save! 58 | 59 | assert_nil paranoid_has_many_dependant.paranoid_time_with_scope 60 | 61 | paranoid_time.update(name: "hello") 62 | 63 | paranoid_has_many_dependant.reload 64 | 65 | assert_equal paranoid_time, paranoid_has_many_dependant.paranoid_time_with_scope 66 | 67 | paranoid_time.destroy 68 | 69 | paranoid_has_many_dependant.reload 70 | 71 | assert_nil paranoid_has_many_dependant.paranoid_time_with_scope 72 | end 73 | 74 | def test_belongs_to_with_scope_and_deleted_option 75 | paranoid_has_many_dependant = ParanoidHasManyDependant.new 76 | includes_values = ParanoidTime.includes(:not_paranoid).includes_values 77 | 78 | assert_equal includes_values, paranoid_has_many_dependant 79 | .association(:paranoid_time_with_scope_with_deleted).scope.includes_values 80 | 81 | paranoid_time = ParanoidTime.create(name: "not-hello") 82 | paranoid_has_many_dependant.paranoid_time = paranoid_time 83 | paranoid_has_many_dependant.save! 84 | 85 | assert_nil paranoid_has_many_dependant.paranoid_time_with_scope_with_deleted 86 | 87 | paranoid_time.update(name: "hello") 88 | paranoid_has_many_dependant.reload 89 | 90 | assert_equal paranoid_time, paranoid_has_many_dependant 91 | .paranoid_time_with_scope_with_deleted 92 | 93 | paranoid_time.destroy 94 | paranoid_has_many_dependant.reload 95 | 96 | assert_equal paranoid_time, paranoid_has_many_dependant 97 | .paranoid_time_with_scope_with_deleted 98 | end 99 | 100 | def test_belongs_to_with_deleted 101 | paranoid_time = ParanoidTime.first 102 | paranoid_has_many_dependant = paranoid_time.paranoid_has_many_dependants 103 | .create(name: "dependant!") 104 | 105 | assert_equal paranoid_time, paranoid_has_many_dependant.paranoid_time 106 | assert_equal paranoid_time, paranoid_has_many_dependant.paranoid_time_with_deleted 107 | 108 | paranoid_time.destroy 109 | paranoid_has_many_dependant.reload 110 | 111 | assert_nil paranoid_has_many_dependant.paranoid_time 112 | assert_equal paranoid_time, paranoid_has_many_dependant.paranoid_time_with_deleted 113 | end 114 | 115 | def test_belongs_to_polymorphic_with_deleted 116 | paranoid_time = ParanoidTime.first 117 | paranoid_has_many_dependant = ParanoidHasManyDependant 118 | .create!(name: "dependant!", paranoid_time_polymorphic_with_deleted: paranoid_time) 119 | 120 | assert_equal paranoid_time, paranoid_has_many_dependant.paranoid_time 121 | assert_equal paranoid_time, paranoid_has_many_dependant 122 | .paranoid_time_polymorphic_with_deleted 123 | 124 | paranoid_time.destroy 125 | 126 | assert_nil paranoid_has_many_dependant.reload.paranoid_time 127 | assert_equal paranoid_time, paranoid_has_many_dependant 128 | .reload.paranoid_time_polymorphic_with_deleted 129 | end 130 | 131 | def test_belongs_to_nil_polymorphic_with_deleted 132 | paranoid_time = ParanoidTime.first 133 | paranoid_has_many_dependant = 134 | ParanoidHasManyDependant.create!(name: "dependant!", 135 | paranoid_time_polymorphic_with_deleted: nil) 136 | 137 | assert_nil paranoid_has_many_dependant.paranoid_time 138 | assert_nil paranoid_has_many_dependant.paranoid_time_polymorphic_with_deleted 139 | 140 | paranoid_time.destroy 141 | 142 | assert_nil paranoid_has_many_dependant.reload.paranoid_time 143 | assert_nil paranoid_has_many_dependant.reload.paranoid_time_polymorphic_with_deleted 144 | end 145 | 146 | def test_belongs_to_options 147 | paranoid_time = ParanoidHasManyDependant.reflections 148 | .with_indifferent_access[:paranoid_time] 149 | assert_equal :belongs_to, paranoid_time.macro 150 | assert_nil paranoid_time.options[:with_deleted] 151 | end 152 | 153 | def test_belongs_to_with_deleted_options 154 | paranoid_time_with_deleted = 155 | ParanoidHasManyDependant.reflections 156 | .with_indifferent_access[:paranoid_time_with_deleted] 157 | assert_equal :belongs_to, paranoid_time_with_deleted.macro 158 | assert paranoid_time_with_deleted.options[:with_deleted] 159 | end 160 | 161 | def test_belongs_to_polymorphic_with_deleted_options 162 | paranoid_time_polymorphic_with_deleted = ParanoidHasManyDependant.reflections 163 | .with_indifferent_access[:paranoid_time_polymorphic_with_deleted] 164 | assert_equal :belongs_to, paranoid_time_polymorphic_with_deleted.macro 165 | assert paranoid_time_polymorphic_with_deleted.options[:with_deleted] 166 | end 167 | 168 | def test_only_find_associated_records_when_finding_with_paranoid_deleted 169 | parent = ParanoidBelongsDependant.create 170 | child = ParanoidHasManyDependant.create 171 | parent.paranoid_has_many_dependants << child 172 | 173 | unrelated_parent = ParanoidBelongsDependant.create 174 | unrelated_child = ParanoidHasManyDependant.create 175 | unrelated_parent.paranoid_has_many_dependants << unrelated_child 176 | 177 | child.destroy 178 | assert_paranoid_deletion(child) 179 | 180 | parent.reload 181 | 182 | assert_empty parent.paranoid_has_many_dependants.to_a 183 | assert_equal [child], parent.paranoid_has_many_dependants.with_deleted.to_a 184 | end 185 | 186 | def test_join_with_model_with_deleted 187 | obj = ParanoidHasManyDependant.create(paranoid_time: ParanoidTime.create) 188 | assert_not_nil obj.paranoid_time 189 | assert_not_nil obj.paranoid_time_with_deleted 190 | 191 | obj.paranoid_time.destroy 192 | obj.reload 193 | 194 | assert_nil obj.paranoid_time 195 | assert_not_nil obj.paranoid_time_with_deleted 196 | 197 | # Note that obj is destroyed because of dependent: :destroy in ParanoidTime 198 | assert obj.destroyed? 199 | 200 | assert_empty ParanoidHasManyDependant.with_deleted.joins(:paranoid_time) 201 | assert_equal [obj], 202 | ParanoidHasManyDependant.with_deleted.joins(:paranoid_time_with_deleted) 203 | end 204 | 205 | def test_includes_with_deleted 206 | paranoid_time = ParanoidTime.first 207 | paranoid_time.paranoid_has_many_dependants.create(name: "dependant!") 208 | 209 | paranoid_time.destroy 210 | 211 | ParanoidHasManyDependant.with_deleted 212 | .includes(:paranoid_time_with_deleted).each do |hasmany| 213 | assert_not_nil hasmany.paranoid_time_with_deleted 214 | end 215 | end 216 | 217 | def test_includes_with_deleted_with_polymorphic_parent 218 | not_paranoid_parent = NotParanoidHasManyAsParent.create(name: "not paranoid parent") 219 | paranoid_parent = ParanoidHasManyAsParent.create(name: "paranoid parent") 220 | ParanoidBelongsToPolymorphic.create(name: "belongs_to", parent: not_paranoid_parent) 221 | ParanoidBelongsToPolymorphic.create(name: "belongs_to", parent: paranoid_parent) 222 | 223 | paranoid_parent.destroy 224 | 225 | ParanoidBelongsToPolymorphic.with_deleted.includes(:parent).each do |hasmany| 226 | assert_not_nil hasmany.parent 227 | end 228 | end 229 | 230 | def test_cannot_find_a_paranoid_deleted_many_many_association 231 | left = ParanoidManyManyParentLeft.create 232 | right = ParanoidManyManyParentRight.create 233 | left.paranoid_many_many_parent_rights << right 234 | 235 | left.paranoid_many_many_parent_rights.delete(right) 236 | 237 | left.reload 238 | 239 | assert_empty left.paranoid_many_many_children, "Linking objects not deleted" 240 | assert_empty left.paranoid_many_many_parent_rights, 241 | "Associated objects not unlinked" 242 | assert_equal right, ParanoidManyManyParentRight.find(right.id), 243 | "Associated object deleted" 244 | end 245 | 246 | def test_cannot_find_a_paranoid_destroyed_many_many_association 247 | left = ParanoidManyManyParentLeft.create 248 | right = ParanoidManyManyParentRight.create 249 | left.paranoid_many_many_parent_rights << right 250 | 251 | left.paranoid_many_many_parent_rights.destroy(right) 252 | 253 | left.reload 254 | 255 | assert_empty left.paranoid_many_many_children, "Linking objects not deleted" 256 | assert_empty left.paranoid_many_many_parent_rights, 257 | "Associated objects not unlinked" 258 | assert_equal right, ParanoidManyManyParentRight.find(right.id), 259 | "Associated object deleted" 260 | end 261 | 262 | def test_cannot_find_a_has_many_through_object_when_its_linking_object_is_soft_destroyed 263 | left = ParanoidManyManyParentLeft.create 264 | right = ParanoidManyManyParentRight.create 265 | left.paranoid_many_many_parent_rights << right 266 | 267 | child = left.paranoid_many_many_children.first 268 | 269 | child.destroy 270 | 271 | left.reload 272 | 273 | assert_empty left.paranoid_many_many_parent_rights, "Associated objects not deleted" 274 | end 275 | 276 | def test_cannot_find_a_paranoid_deleted_model 277 | model = ParanoidBelongsDependant.create 278 | model.destroy 279 | 280 | assert_raises ActiveRecord::RecordNotFound do 281 | ParanoidBelongsDependant.find(model.id) 282 | end 283 | end 284 | 285 | def test_bidirectional_has_many_through_association_clear_is_paranoid 286 | left = ParanoidManyManyParentLeft.create 287 | right = ParanoidManyManyParentRight.create 288 | left.paranoid_many_many_parent_rights << right 289 | 290 | child = left.paranoid_many_many_children.first 291 | assert_equal left, child.paranoid_many_many_parent_left, 292 | "Child's left parent is incorrect" 293 | assert_equal right, child.paranoid_many_many_parent_right, 294 | "Child's right parent is incorrect" 295 | 296 | left.paranoid_many_many_parent_rights.clear 297 | 298 | assert_paranoid_deletion(child) 299 | end 300 | 301 | def test_bidirectional_has_many_through_association_destroy_is_paranoid 302 | left = ParanoidManyManyParentLeft.create 303 | right = ParanoidManyManyParentRight.create 304 | left.paranoid_many_many_parent_rights << right 305 | 306 | child = left.paranoid_many_many_children.first 307 | assert_equal left, child.paranoid_many_many_parent_left, 308 | "Child's left parent is incorrect" 309 | assert_equal right, child.paranoid_many_many_parent_right, 310 | "Child's right parent is incorrect" 311 | 312 | left.paranoid_many_many_parent_rights.destroy(right) 313 | 314 | assert_paranoid_deletion(child) 315 | end 316 | 317 | def test_bidirectional_has_many_through_association_delete_is_paranoid 318 | left = ParanoidManyManyParentLeft.create 319 | right = ParanoidManyManyParentRight.create 320 | left.paranoid_many_many_parent_rights << right 321 | 322 | child = left.paranoid_many_many_children.first 323 | assert_equal left, child.paranoid_many_many_parent_left, 324 | "Child's left parent is incorrect" 325 | assert_equal right, child.paranoid_many_many_parent_right, 326 | "Child's right parent is incorrect" 327 | 328 | left.paranoid_many_many_parent_rights.delete(right) 329 | 330 | assert_paranoid_deletion(child) 331 | end 332 | 333 | def test_belongs_to_on_normal_model_is_paranoid 334 | not_paranoid = HasOneNotParanoid.create 335 | not_paranoid.paranoid_time = ParanoidTime.create 336 | 337 | assert not_paranoid.save 338 | assert_not_nil not_paranoid.paranoid_time 339 | end 340 | 341 | def test_double_belongs_to_with_deleted 342 | not_paranoid = DoubleHasOneNotParanoid.create 343 | not_paranoid.paranoid_time = ParanoidTime.create 344 | 345 | assert not_paranoid.save 346 | assert_not_nil not_paranoid.paranoid_time 347 | end 348 | 349 | def test_mass_assignment_of_paranoid_column_disabled 350 | assert_raises ActiveRecord::RecordNotSaved do 351 | ParanoidTime.create! name: "Foo", deleted_at: Time.now 352 | end 353 | end 354 | end 355 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler" 4 | begin 5 | Bundler.load 6 | rescue Bundler::BundlerError => e 7 | warn e.message 8 | warn "Run `bundle install` to install missing gems" 9 | exit e.status_code 10 | end 11 | 12 | if RUBY_ENGINE == "jruby" 13 | # Workaround for issue in I18n/JRuby combo. 14 | # See https://github.com/jruby/jruby/issues/6547 and 15 | # https://github.com/ruby-i18n/i18n/issues/555 16 | require "i18n/backend" 17 | require "i18n/backend/simple" 18 | end 19 | 20 | require "simplecov" 21 | SimpleCov.start do 22 | enable_coverage :branch 23 | end 24 | 25 | require "acts_as_paranoid" 26 | require "minitest/autorun" 27 | require "minitest/focus" 28 | 29 | # Silence deprecation halfway through the test 30 | I18n.enforce_available_locales = true 31 | 32 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 33 | ActiveRecord::Schema.verbose = false 34 | 35 | log_dir = File.expand_path("../log/", __dir__) 36 | FileUtils.mkdir_p log_dir 37 | file_path = File.join(log_dir, "test.log") 38 | ActiveRecord::Base.logger = Logger.new(file_path) 39 | 40 | # rubocop:disable Metrics/AbcSize 41 | def setup_db 42 | ActiveRecord::Schema.define(version: 1) do # rubocop:disable Metrics/BlockLength 43 | create_table :paranoid_times do |t| 44 | t.string :name 45 | t.datetime :deleted_at 46 | t.integer :paranoid_belongs_dependant_id 47 | t.integer :not_paranoid_id 48 | 49 | timestamps t 50 | end 51 | 52 | create_table :paranoid_booleans do |t| 53 | t.string :name 54 | t.boolean :is_deleted 55 | t.integer :paranoid_time_id 56 | t.integer :paranoid_with_counter_caches_count 57 | t.integer :paranoid_with_touch_and_counter_caches_count 58 | t.integer :custom_counter_cache 59 | timestamps t 60 | end 61 | 62 | create_table :paranoid_strings do |t| 63 | t.string :name 64 | t.string :deleted 65 | end 66 | 67 | create_table :not_paranoids do |t| 68 | t.string :name 69 | t.integer :paranoid_time_id 70 | 71 | timestamps t 72 | end 73 | 74 | create_table :has_one_not_paranoids do |t| 75 | t.string :name 76 | t.integer :paranoid_time_id 77 | 78 | timestamps t 79 | end 80 | 81 | create_table :paranoid_has_many_dependants do |t| 82 | t.string :name 83 | t.datetime :deleted_at 84 | t.integer :paranoid_time_id 85 | t.string :paranoid_time_polymorphic_with_deleted_type 86 | t.integer :paranoid_belongs_dependant_id 87 | 88 | timestamps t 89 | end 90 | 91 | create_table :paranoid_belongs_dependants do |t| 92 | t.string :name 93 | t.datetime :deleted_at 94 | 95 | timestamps t 96 | end 97 | 98 | create_table :paranoid_has_one_dependants do |t| 99 | t.string :name 100 | t.datetime :deleted_at 101 | t.integer :paranoid_boolean_id 102 | 103 | timestamps t 104 | end 105 | 106 | create_table :paranoid_with_callbacks do |t| 107 | t.string :name 108 | t.datetime :deleted_at 109 | 110 | timestamps t 111 | end 112 | 113 | create_table :paranoid_destroy_companies do |t| 114 | t.string :name 115 | t.datetime :deleted_at 116 | 117 | timestamps t 118 | end 119 | 120 | create_table :paranoid_delete_companies do |t| 121 | t.string :name 122 | t.datetime :deleted_at 123 | 124 | timestamps t 125 | end 126 | 127 | create_table :paranoid_products do |t| 128 | t.integer :paranoid_destroy_company_id 129 | t.integer :paranoid_delete_company_id 130 | t.string :name 131 | t.datetime :deleted_at 132 | 133 | timestamps t 134 | end 135 | 136 | create_table :super_paranoids do |t| 137 | t.string :type 138 | t.references :has_many_inherited_super_paranoidz, 139 | index: { name: "index__sp_id_on_has_many_isp" } 140 | t.datetime :deleted_at 141 | 142 | timestamps t 143 | end 144 | 145 | create_table :has_many_inherited_super_paranoidzs do |t| 146 | t.references :super_paranoidz, index: { name: "index_has_many_isp_on_sp_id" } 147 | t.datetime :deleted_at 148 | 149 | timestamps t 150 | end 151 | 152 | create_table :paranoid_many_many_parent_lefts do |t| 153 | t.string :name 154 | timestamps t 155 | end 156 | 157 | create_table :paranoid_many_many_parent_rights do |t| 158 | t.string :name 159 | timestamps t 160 | end 161 | 162 | create_table :paranoid_many_many_children do |t| 163 | t.integer :paranoid_many_many_parent_left_id 164 | t.integer :paranoid_many_many_parent_right_id 165 | t.datetime :deleted_at 166 | timestamps t 167 | end 168 | 169 | create_table :paranoid_with_scoped_validations do |t| 170 | t.string :name 171 | t.string :category 172 | t.datetime :deleted_at 173 | timestamps t 174 | end 175 | 176 | create_table :paranoid_polygons do |t| 177 | t.integer :sides 178 | t.datetime :deleted_at 179 | 180 | timestamps t 181 | end 182 | 183 | create_table :paranoid_androids do |t| 184 | t.datetime :deleted_at 185 | end 186 | 187 | create_table :paranoid_sections do |t| 188 | t.integer :paranoid_time_id 189 | t.integer :paranoid_thing_id 190 | t.string :paranoid_thing_type 191 | t.datetime :deleted_at 192 | end 193 | 194 | create_table :paranoid_boolean_not_nullables do |t| 195 | t.string :name 196 | t.boolean :deleted, :boolean, null: false, default: false 197 | end 198 | 199 | create_table :paranoid_belongs_to_polymorphics do |t| 200 | t.string :name 201 | t.string :parent_type 202 | t.integer :parent_id 203 | t.datetime :deleted_at 204 | 205 | timestamps t 206 | end 207 | 208 | create_table :not_paranoid_has_many_as_parents do |t| 209 | t.string :name 210 | 211 | timestamps t 212 | end 213 | 214 | create_table :paranoid_has_many_as_parents do |t| 215 | t.string :name 216 | t.datetime :deleted_at 217 | 218 | timestamps t 219 | end 220 | 221 | create_table :paranoid_no_double_tap_destroys_fullies do |t| 222 | t.datetime :deleted_at 223 | end 224 | 225 | create_table :paranoid_with_counter_caches do |t| 226 | t.string :name 227 | t.datetime :deleted_at 228 | t.integer :paranoid_boolean_id 229 | 230 | timestamps t 231 | end 232 | 233 | create_table :paranoid_with_serialized_columns do |t| 234 | t.string :name 235 | t.datetime :deleted_at 236 | t.string :colors 237 | 238 | timestamps t 239 | end 240 | end 241 | end 242 | 243 | # rubocop:enable Metrics/AbcSize 244 | def timestamps(table) 245 | table.column :created_at, :timestamp, null: false 246 | table.column :updated_at, :timestamp, null: false 247 | end 248 | 249 | def teardown_db 250 | ActiveRecord::Base.connection.data_sources.each do |table| 251 | ActiveRecord::Base.connection.drop_table(table) 252 | end 253 | end 254 | 255 | class ParanoidTime < ActiveRecord::Base 256 | acts_as_paranoid 257 | 258 | validates_uniqueness_of :name 259 | 260 | has_many :paranoid_has_many_dependants, dependent: :destroy 261 | has_many :paranoid_booleans, dependent: :destroy 262 | has_many :not_paranoids, dependent: :delete_all 263 | has_many :paranoid_sections, dependent: :destroy 264 | 265 | has_one :has_one_not_paranoid, dependent: :destroy 266 | 267 | belongs_to :not_paranoid, dependent: :destroy 268 | 269 | attr_accessor :destroyable 270 | 271 | before_destroy :ensure_destroyable 272 | 273 | protected 274 | 275 | def ensure_destroyable 276 | return if destroyable.nil? 277 | 278 | throw(:abort) unless destroyable 279 | end 280 | end 281 | 282 | class ParanoidBoolean < ActiveRecord::Base 283 | acts_as_paranoid column_type: "boolean", column: "is_deleted" 284 | validates_as_paranoid 285 | validates_uniqueness_of_without_deleted :name 286 | 287 | belongs_to :paranoid_time 288 | has_one :paranoid_has_one_dependant, dependent: :destroy 289 | has_many :paranoid_with_counter_cache, dependent: :destroy 290 | has_many :paranoid_with_custom_counter_cache, dependent: :destroy 291 | has_many :paranoid_with_touch_and_counter_cache, dependent: :destroy 292 | has_many :paranoid_with_touch, dependent: :destroy 293 | end 294 | 295 | class ParanoidString < ActiveRecord::Base 296 | acts_as_paranoid column_type: "string", column: "deleted", deleted_value: "dead" 297 | end 298 | 299 | class NotParanoid < ActiveRecord::Base 300 | has_many :paranoid_times 301 | end 302 | 303 | class ParanoidNoDoubleTapDestroysFully < ActiveRecord::Base 304 | acts_as_paranoid double_tap_destroys_fully: false 305 | end 306 | 307 | class HasOneNotParanoid < ActiveRecord::Base 308 | belongs_to :paranoid_time, with_deleted: true 309 | end 310 | 311 | class DoubleHasOneNotParanoid < HasOneNotParanoid 312 | belongs_to :paranoid_time, with_deleted: true 313 | begin 314 | verbose = $VERBOSE 315 | $VERBOSE = false 316 | belongs_to :paranoid_time, with_deleted: true 317 | ensure 318 | $VERBOSE = verbose 319 | end 320 | end 321 | 322 | class ParanoidWithCounterCache < ActiveRecord::Base 323 | acts_as_paranoid 324 | belongs_to :paranoid_boolean, counter_cache: true 325 | end 326 | 327 | class ParanoidWithCustomCounterCache < ActiveRecord::Base 328 | self.table_name = "paranoid_with_counter_caches" 329 | 330 | acts_as_paranoid 331 | belongs_to :paranoid_boolean, counter_cache: :custom_counter_cache 332 | end 333 | 334 | class ParanoidWithCounterCacheOnOptionalBelognsTo < ActiveRecord::Base 335 | self.table_name = "paranoid_with_counter_caches" 336 | 337 | acts_as_paranoid 338 | belongs_to :paranoid_boolean, counter_cache: true, optional: true 339 | end 340 | 341 | class ParanoidWithTouch < ActiveRecord::Base 342 | self.table_name = "paranoid_with_counter_caches" 343 | acts_as_paranoid 344 | belongs_to :paranoid_boolean, touch: true 345 | end 346 | 347 | class ParanoidWithTouchAndCounterCache < ActiveRecord::Base 348 | self.table_name = "paranoid_with_counter_caches" 349 | acts_as_paranoid 350 | belongs_to :paranoid_boolean, touch: true, counter_cache: true 351 | end 352 | 353 | class ParanoidHasManyDependant < ActiveRecord::Base 354 | acts_as_paranoid 355 | belongs_to :paranoid_time 356 | belongs_to :paranoid_time_with_scope, 357 | -> { where(name: "hello").includes(:not_paranoid) }, 358 | class_name: "ParanoidTime", foreign_key: :paranoid_time_id 359 | belongs_to :paranoid_time_with_deleted, class_name: "ParanoidTime", 360 | foreign_key: :paranoid_time_id, with_deleted: true 361 | belongs_to :paranoid_time_with_scope_with_deleted, 362 | -> { where(name: "hello").includes(:not_paranoid) }, 363 | class_name: "ParanoidTime", foreign_key: :paranoid_time_id, with_deleted: true 364 | belongs_to :paranoid_time_polymorphic_with_deleted, class_name: "ParanoidTime", 365 | foreign_key: :paranoid_time_id, 366 | polymorphic: true, with_deleted: true 367 | 368 | belongs_to :paranoid_belongs_dependant, dependent: :destroy 369 | end 370 | 371 | class ParanoidBelongsDependant < ActiveRecord::Base 372 | acts_as_paranoid 373 | 374 | has_many :paranoid_has_many_dependants 375 | end 376 | 377 | class ParanoidHasOneDependant < ActiveRecord::Base 378 | acts_as_paranoid 379 | 380 | belongs_to :paranoid_boolean 381 | end 382 | 383 | class ParanoidWithCallback < ActiveRecord::Base 384 | acts_as_paranoid 385 | 386 | attr_accessor :called_before_destroy, :called_after_destroy, 387 | :called_after_commit_on_destroy, :called_before_recover, 388 | :called_after_recover 389 | 390 | before_destroy :call_me_before_destroy 391 | after_destroy :call_me_after_destroy 392 | 393 | after_commit :call_me_after_commit_on_destroy, on: :destroy 394 | 395 | before_recover :call_me_before_recover 396 | after_recover :call_me_after_recover 397 | 398 | def initialize(*attrs) 399 | @called_before_destroy = @called_after_destroy = @called_after_commit_on_destroy = false 400 | super(*attrs) 401 | end 402 | 403 | def call_me_before_destroy 404 | @called_before_destroy = true 405 | end 406 | 407 | def call_me_after_destroy 408 | @called_after_destroy = true 409 | end 410 | 411 | def call_me_after_commit_on_destroy 412 | @called_after_commit_on_destroy = true 413 | end 414 | 415 | def call_me_before_recover 416 | @called_before_recover = true 417 | end 418 | 419 | def call_me_after_recover 420 | @called_after_recover = true 421 | end 422 | end 423 | 424 | class ParanoidDestroyCompany < ActiveRecord::Base 425 | acts_as_paranoid 426 | validates :name, presence: true 427 | has_many :paranoid_products, dependent: :destroy 428 | end 429 | 430 | class ParanoidDeleteCompany < ActiveRecord::Base 431 | acts_as_paranoid 432 | validates :name, presence: true 433 | has_many :paranoid_products, dependent: :delete_all 434 | end 435 | 436 | class ParanoidProduct < ActiveRecord::Base 437 | acts_as_paranoid 438 | belongs_to :paranoid_destroy_company 439 | belongs_to :paranoid_delete_company 440 | validates_presence_of :name 441 | end 442 | 443 | class SuperParanoid < ActiveRecord::Base 444 | acts_as_paranoid 445 | belongs_to :has_many_inherited_super_paranoidz 446 | end 447 | 448 | class HasManyInheritedSuperParanoidz < ActiveRecord::Base 449 | has_many :super_paranoidz, class_name: "InheritedParanoid", dependent: :destroy 450 | end 451 | 452 | class InheritedParanoid < SuperParanoid 453 | acts_as_paranoid 454 | end 455 | 456 | class ParanoidManyManyParentLeft < ActiveRecord::Base 457 | has_many :paranoid_many_many_children 458 | has_many :paranoid_many_many_parent_rights, through: :paranoid_many_many_children 459 | end 460 | 461 | class ParanoidManyManyParentRight < ActiveRecord::Base 462 | has_many :paranoid_many_many_children 463 | has_many :paranoid_many_many_parent_lefts, through: :paranoid_many_many_children 464 | end 465 | 466 | class ParanoidManyManyChild < ActiveRecord::Base 467 | acts_as_paranoid 468 | belongs_to :paranoid_many_many_parent_left 469 | belongs_to :paranoid_many_many_parent_right 470 | end 471 | 472 | class ParanoidWithScopedValidation < ActiveRecord::Base 473 | acts_as_paranoid 474 | validates_uniqueness_of :name, scope: :category 475 | end 476 | 477 | class ParanoidBelongsToPolymorphic < ActiveRecord::Base 478 | acts_as_paranoid 479 | belongs_to :parent, polymorphic: true, with_deleted: true 480 | end 481 | 482 | class NotParanoidHasManyAsParent < ActiveRecord::Base 483 | has_many :paranoid_belongs_to_polymorphics, as: :parent, dependent: :destroy 484 | end 485 | 486 | class ParanoidHasManyAsParent < ActiveRecord::Base 487 | acts_as_paranoid 488 | has_many :paranoid_belongs_to_polymorphics, as: :parent, dependent: :destroy 489 | end 490 | 491 | class ParanoidBaseTest < ActiveSupport::TestCase 492 | def setup 493 | setup_db 494 | 495 | ["paranoid", "really paranoid", "extremely paranoid"].each do |name| 496 | ParanoidTime.create! name: name 497 | ParanoidBoolean.create! name: name 498 | end 499 | 500 | ParanoidString.create! name: "strings can be paranoid" 501 | NotParanoid.create! name: "no paranoid goals" 502 | ParanoidWithCallback.create! name: "paranoid with callbacks" 503 | end 504 | 505 | def teardown 506 | teardown_db 507 | end 508 | 509 | def assert_paranoid_deletion(model) 510 | row = find_row(model) 511 | assert_not_nil row, "#{model.class} entirely deleted" 512 | assert_not_nil row["deleted_at"], "Deleted at not set" 513 | end 514 | 515 | def assert_non_paranoid_deletion(model) 516 | row = find_row(model) 517 | assert_nil row, "#{model.class} still exists" 518 | end 519 | 520 | def find_row(model) 521 | sql = "select deleted_at from #{model.class.table_name} where id = #{model.id}" 522 | # puts sql here if you want to debug 523 | model.class.connection.select_one(sql) 524 | end 525 | end 526 | 527 | class ParanoidPolygon < ActiveRecord::Base 528 | acts_as_paranoid 529 | default_scope { where("sides = ?", 3) } 530 | end 531 | 532 | class ParanoidAndroid < ActiveRecord::Base 533 | acts_as_paranoid 534 | end 535 | 536 | class ParanoidSection < ActiveRecord::Base 537 | acts_as_paranoid 538 | belongs_to :paranoid_time 539 | belongs_to :paranoid_thing, polymorphic: true, dependent: :destroy 540 | end 541 | 542 | class ParanoidBooleanNotNullable < ActiveRecord::Base 543 | acts_as_paranoid column: "deleted", column_type: "boolean", allow_nulls: false 544 | end 545 | 546 | class ParanoidWithExplicitTableNameAfterMacro < ActiveRecord::Base 547 | acts_as_paranoid 548 | self.table_name = "explicit_table" 549 | end 550 | 551 | class ParanoidWithSerializedColumn < ActiveRecord::Base 552 | acts_as_paranoid 553 | validates_as_paranoid 554 | 555 | serialize :colors, Array 556 | 557 | validates_uniqueness_of_without_deleted :colors 558 | end 559 | -------------------------------------------------------------------------------- /test/test_core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "test_helper" 4 | 5 | class ParanoidTest < ParanoidBaseTest 6 | def test_paranoid? 7 | refute NotParanoid.paranoid? 8 | assert_raise(NoMethodError) { NotParanoid.delete_all! } 9 | assert_raise(NoMethodError) { NotParanoid.with_deleted } 10 | assert_raise(NoMethodError) { NotParanoid.only_deleted } 11 | 12 | assert ParanoidTime.paranoid? 13 | end 14 | 15 | def test_scope_inclusion_with_time_column_type 16 | assert_respond_to ParanoidTime, :deleted_inside_time_window 17 | assert_respond_to ParanoidTime, :deleted_before_time 18 | assert_respond_to ParanoidTime, :deleted_after_time 19 | 20 | refute_respond_to ParanoidBoolean, :deleted_inside_time_window 21 | refute_respond_to ParanoidBoolean, :deleted_before_time 22 | refute_respond_to ParanoidBoolean, :deleted_after_time 23 | end 24 | 25 | def test_fake_removal 26 | assert_equal 3, ParanoidTime.count 27 | assert_equal 3, ParanoidBoolean.count 28 | assert_equal 1, ParanoidString.count 29 | 30 | ParanoidTime.first.destroy 31 | ParanoidBoolean.delete_all("name = 'paranoid' OR name = 'really paranoid'") 32 | ParanoidString.first.destroy 33 | assert_equal 2, ParanoidTime.count 34 | assert_equal 1, ParanoidBoolean.count 35 | assert_equal 0, ParanoidString.count 36 | assert_equal 1, ParanoidTime.only_deleted.count 37 | assert_equal 2, ParanoidBoolean.only_deleted.count 38 | assert_equal 1, ParanoidString.only_deleted.count 39 | assert_equal 3, ParanoidTime.with_deleted.count 40 | assert_equal 3, ParanoidBoolean.with_deleted.count 41 | assert_equal 1, ParanoidString.with_deleted.count 42 | end 43 | 44 | def test_real_removal 45 | ParanoidTime.first.destroy_fully! 46 | ParanoidBoolean.delete_all!("name = 'extremely paranoid' OR name = 'really paranoid'") 47 | ParanoidString.first.destroy_fully! 48 | assert_equal 2, ParanoidTime.count 49 | assert_equal 1, ParanoidBoolean.count 50 | assert_equal 0, ParanoidString.count 51 | assert_equal 2, ParanoidTime.with_deleted.count 52 | assert_equal 1, ParanoidBoolean.with_deleted.count 53 | assert_equal 0, ParanoidString.with_deleted.count 54 | assert_equal 0, ParanoidTime.only_deleted.count 55 | assert_equal 0, ParanoidBoolean.only_deleted.count 56 | assert_equal 0, ParanoidString.only_deleted.count 57 | 58 | ParanoidTime.first.destroy 59 | ParanoidTime.only_deleted.first.destroy 60 | assert_equal 0, ParanoidTime.only_deleted.count 61 | 62 | ParanoidTime.delete_all! 63 | assert_empty ParanoidTime.all 64 | assert_empty ParanoidTime.with_deleted 65 | end 66 | 67 | def test_non_persisted_destroy 68 | pt = ParanoidTime.new 69 | assert_nil pt.paranoid_value 70 | pt.destroy 71 | assert_not_nil pt.paranoid_value 72 | end 73 | 74 | def test_non_persisted_delete 75 | pt = ParanoidTime.new 76 | assert_nil pt.paranoid_value 77 | pt.delete 78 | assert_not_nil pt.paranoid_value 79 | end 80 | 81 | def test_non_persisted_destroy! 82 | pt = ParanoidTime.new 83 | assert_nil pt.paranoid_value 84 | pt.destroy! 85 | assert_not_nil pt.paranoid_value 86 | end 87 | 88 | def test_halted_destroy 89 | pt = ParanoidTime.create!(name: "john", destroyable: false) 90 | 91 | assert_raises ActiveRecord::RecordNotDestroyed do 92 | pt.destroy! 93 | end 94 | end 95 | 96 | def test_non_persisted_destroy_fully! 97 | pt = ParanoidTime.new 98 | assert_nil pt.paranoid_value 99 | pt.destroy_fully! 100 | assert_nil pt.paranoid_value 101 | end 102 | 103 | def test_removal_not_persisted 104 | assert ParanoidTime.new.destroy 105 | end 106 | 107 | def test_recovery 108 | assert_equal 3, ParanoidBoolean.count 109 | ParanoidBoolean.first.destroy 110 | assert_equal 2, ParanoidBoolean.count 111 | ParanoidBoolean.only_deleted.first.recover 112 | assert_equal 3, ParanoidBoolean.count 113 | 114 | assert_equal 1, ParanoidString.count 115 | ParanoidString.first.destroy 116 | assert_equal 0, ParanoidString.count 117 | ParanoidString.with_deleted.first.recover 118 | assert_equal 1, ParanoidString.count 119 | end 120 | 121 | def test_recovery! 122 | ParanoidBoolean.first.destroy 123 | ParanoidBoolean.create(name: "paranoid") 124 | 125 | assert_raise do 126 | ParanoidBoolean.only_deleted.first.recover! 127 | end 128 | end 129 | 130 | def test_recover_has_one_association 131 | parent = ParanoidBoolean.create(name: "parent") 132 | child = parent.create_paranoid_has_one_dependant(name: "child") 133 | 134 | parent.destroy 135 | assert parent.paranoid_has_one_dependant.destroyed? 136 | 137 | parent.recover 138 | refute parent.paranoid_has_one_dependant.destroyed? 139 | 140 | child.reload 141 | refute child.destroyed? 142 | end 143 | 144 | def test_recover_has_many_association 145 | parent = ParanoidTime.create(name: "parent") 146 | child = parent.paranoid_has_many_dependants.create(name: "child") 147 | 148 | parent.destroy 149 | assert child.destroyed? 150 | 151 | parent.recover 152 | assert_equal 1, parent.paranoid_has_many_dependants.count 153 | 154 | child.reload 155 | refute child.destroyed? 156 | end 157 | 158 | # Rails does not allow saving deleted records 159 | def test_no_save_after_destroy 160 | paranoid = ParanoidString.first 161 | paranoid.destroy 162 | paranoid.name = "Let's update!" 163 | 164 | assert_not paranoid.save 165 | assert_raises ActiveRecord::RecordNotSaved do 166 | paranoid.save! 167 | end 168 | end 169 | 170 | def test_scope_chaining 171 | assert_equal 3, ParanoidBoolean.unscoped.with_deleted.count 172 | assert_equal 0, ParanoidBoolean.unscoped.only_deleted.count 173 | assert_equal 0, ParanoidBoolean.with_deleted.only_deleted.count 174 | assert_equal 3, ParanoidBoolean.with_deleted.with_deleted.count 175 | end 176 | 177 | def test_only_deleted_with_deleted_with_boolean_paranoid_column 178 | ParanoidBoolean.first.destroy 179 | assert_equal 1, ParanoidBoolean.only_deleted.count 180 | assert_equal 1, ParanoidBoolean.only_deleted.with_deleted.count 181 | end 182 | 183 | def test_with_deleted_only_deleted_with_boolean_paranoid_column 184 | ParanoidBoolean.first.destroy 185 | assert_equal 1, ParanoidBoolean.only_deleted.count 186 | assert_equal 1, ParanoidBoolean.with_deleted.only_deleted.count 187 | end 188 | 189 | def test_only_deleted_with_deleted_with_datetime_paranoid_column 190 | ParanoidTime.first.destroy 191 | assert_equal 1, ParanoidTime.only_deleted.count 192 | assert_equal 1, ParanoidTime.only_deleted.with_deleted.count 193 | end 194 | 195 | def test_with_deleted_only_deleted_with_datetime_paranoid_column 196 | ParanoidTime.first.destroy 197 | assert_equal 1, ParanoidTime.only_deleted.count 198 | assert_equal 1, ParanoidTime.with_deleted.only_deleted.count 199 | end 200 | 201 | def setup_recursive_tests 202 | @paranoid_time_object = ParanoidTime.first 203 | 204 | # Create one extra ParanoidHasManyDependant record so that we can validate 205 | # the correct dependants are recovered. 206 | ParanoidTime.where("id <> ?", @paranoid_time_object.id).first 207 | .paranoid_has_many_dependants.create(name: "should not be recovered").destroy 208 | 209 | @paranoid_boolean_count = ParanoidBoolean.count 210 | 211 | assert_equal 0, ParanoidHasManyDependant.count 212 | assert_equal 0, ParanoidBelongsDependant.count 213 | assert_equal 1, NotParanoid.count 214 | 215 | (1..3).each do |i| 216 | has_many_object = @paranoid_time_object.paranoid_has_many_dependants 217 | .create(name: "has_many_#{i}") 218 | has_many_object.create_paranoid_belongs_dependant(name: "belongs_to_#{i}") 219 | has_many_object.save 220 | 221 | paranoid_boolean = @paranoid_time_object.paranoid_booleans 222 | .create(name: "boolean_#{i}") 223 | paranoid_boolean.create_paranoid_has_one_dependant(name: "has_one_#{i}") 224 | paranoid_boolean.save 225 | 226 | @paranoid_time_object.not_paranoids.create(name: "not_paranoid_a#{i}") 227 | end 228 | 229 | @paranoid_time_object.create_not_paranoid(name: "not_paranoid_belongs_to") 230 | @paranoid_time_object.create_has_one_not_paranoid(name: "has_one_not_paranoid") 231 | 232 | assert_equal 3, ParanoidTime.count 233 | assert_equal 3, ParanoidHasManyDependant.count 234 | assert_equal 3, ParanoidBelongsDependant.count 235 | assert_equal @paranoid_boolean_count + 3, ParanoidBoolean.count 236 | assert_equal 3, ParanoidHasOneDependant.count 237 | assert_equal 5, NotParanoid.count 238 | assert_equal 1, HasOneNotParanoid.count 239 | end 240 | 241 | def test_recursive_fake_removal 242 | setup_recursive_tests 243 | 244 | @paranoid_time_object.destroy 245 | 246 | assert_equal 2, ParanoidTime.count 247 | assert_equal 0, ParanoidHasManyDependant.count 248 | assert_equal 0, ParanoidBelongsDependant.count 249 | assert_equal @paranoid_boolean_count, ParanoidBoolean.count 250 | assert_equal 0, ParanoidHasOneDependant.count 251 | assert_equal 1, NotParanoid.count 252 | assert_equal 0, HasOneNotParanoid.count 253 | 254 | assert_equal 3, ParanoidTime.with_deleted.count 255 | assert_equal 4, ParanoidHasManyDependant.with_deleted.count 256 | assert_equal 3, ParanoidBelongsDependant.with_deleted.count 257 | assert_equal @paranoid_boolean_count + 3, ParanoidBoolean.with_deleted.count 258 | assert_equal 3, ParanoidHasOneDependant.with_deleted.count 259 | end 260 | 261 | def test_recursive_real_removal 262 | setup_recursive_tests 263 | 264 | @paranoid_time_object.destroy_fully! 265 | 266 | assert_equal 0, ParanoidTime.only_deleted.count 267 | assert_equal 1, ParanoidHasManyDependant.only_deleted.count 268 | assert_equal 0, ParanoidBelongsDependant.only_deleted.count 269 | assert_equal 0, ParanoidBoolean.only_deleted.count 270 | assert_equal 0, ParanoidHasOneDependant.only_deleted.count 271 | assert_equal 1, NotParanoid.count 272 | assert_equal 0, HasOneNotParanoid.count 273 | end 274 | 275 | def test_recursive_recovery 276 | setup_recursive_tests 277 | 278 | @paranoid_time_object.destroy 279 | @paranoid_time_object.reload 280 | 281 | @paranoid_time_object.recover(recursive: true) 282 | 283 | assert_equal 3, ParanoidTime.count 284 | assert_equal 3, ParanoidHasManyDependant.count 285 | assert_equal 3, ParanoidBelongsDependant.count 286 | assert_equal @paranoid_boolean_count + 3, ParanoidBoolean.count 287 | assert_equal 3, ParanoidHasOneDependant.count 288 | assert_equal 1, NotParanoid.count 289 | assert_equal 0, HasOneNotParanoid.count 290 | end 291 | 292 | def test_recursive_recovery_dependant_window 293 | setup_recursive_tests 294 | 295 | # Stop the following from recovering: 296 | # - ParanoidHasManyDependant and its ParanoidBelongsDependant 297 | # - A single ParanoidBelongsDependant, but not its parent 298 | Time.stub :now, 2.days.ago do 299 | @paranoid_time_object.paranoid_has_many_dependants.first.destroy 300 | end 301 | Time.stub :now, 1.hour.ago do 302 | @paranoid_time_object.paranoid_has_many_dependants 303 | .last.paranoid_belongs_dependant 304 | .destroy 305 | end 306 | @paranoid_time_object.destroy 307 | @paranoid_time_object.reload 308 | 309 | @paranoid_time_object.recover(recursive: true) 310 | 311 | assert_equal 3, ParanoidTime.count 312 | assert_equal 2, ParanoidHasManyDependant.count 313 | assert_equal 1, ParanoidBelongsDependant.count 314 | assert_equal @paranoid_boolean_count + 3, ParanoidBoolean.count 315 | assert_equal 3, ParanoidHasOneDependant.count 316 | assert_equal 1, NotParanoid.count 317 | assert_equal 0, HasOneNotParanoid.count 318 | end 319 | 320 | def test_recursive_recovery_for_belongs_to_polymorphic 321 | child_1 = ParanoidAndroid.create 322 | section_1 = ParanoidSection.create(paranoid_thing: child_1) 323 | 324 | child_2 = ParanoidPolygon.create(sides: 3) 325 | section_2 = ParanoidSection.create(paranoid_thing: child_2) 326 | 327 | assert_equal section_1.paranoid_thing, child_1 328 | assert_equal section_1.paranoid_thing.class, ParanoidAndroid 329 | assert_equal section_2.paranoid_thing, child_2 330 | assert_equal section_2.paranoid_thing.class, ParanoidPolygon 331 | 332 | parent = ParanoidTime.create(name: "paranoid_parent") 333 | parent.paranoid_sections << section_1 334 | parent.paranoid_sections << section_2 335 | 336 | assert_equal 4, ParanoidTime.count 337 | assert_equal 2, ParanoidSection.count 338 | assert_equal 1, ParanoidAndroid.count 339 | assert_equal 1, ParanoidPolygon.count 340 | 341 | parent.destroy 342 | 343 | assert_equal 3, ParanoidTime.count 344 | assert_equal 0, ParanoidSection.count 345 | assert_equal 0, ParanoidAndroid.count 346 | assert_equal 0, ParanoidPolygon.count 347 | 348 | parent.reload 349 | parent.recover 350 | 351 | assert_equal 4, ParanoidTime.count 352 | assert_equal 2, ParanoidSection.count 353 | assert_equal 1, ParanoidAndroid.count 354 | assert_equal 1, ParanoidPolygon.count 355 | end 356 | 357 | def test_non_recursive_recovery 358 | setup_recursive_tests 359 | 360 | @paranoid_time_object.destroy 361 | @paranoid_time_object.reload 362 | 363 | @paranoid_time_object.recover(recursive: false) 364 | 365 | assert_equal 3, ParanoidTime.count 366 | assert_equal 0, ParanoidHasManyDependant.count 367 | assert_equal 0, ParanoidBelongsDependant.count 368 | assert_equal @paranoid_boolean_count, ParanoidBoolean.count 369 | assert_equal 0, ParanoidHasOneDependant.count 370 | assert_equal 1, NotParanoid.count 371 | assert_equal 0, HasOneNotParanoid.count 372 | end 373 | 374 | def test_dirty 375 | pt = ParanoidTime.create 376 | pt.destroy 377 | assert_not pt.changed? 378 | end 379 | 380 | def test_delete_dirty 381 | pt = ParanoidTime.create 382 | pt.delete 383 | assert_not pt.changed? 384 | end 385 | 386 | def test_destroy_fully_dirty 387 | pt = ParanoidTime.create 388 | pt.destroy_fully! 389 | assert_not pt.changed? 390 | end 391 | 392 | def test_deleted? 393 | ParanoidTime.first.destroy 394 | assert ParanoidTime.with_deleted.first.deleted? 395 | 396 | ParanoidString.first.destroy 397 | assert ParanoidString.with_deleted.first.deleted? 398 | end 399 | 400 | def test_delete_deleted? 401 | ParanoidTime.first.delete 402 | assert ParanoidTime.with_deleted.first.deleted? 403 | 404 | ParanoidString.first.delete 405 | assert ParanoidString.with_deleted.first.deleted? 406 | end 407 | 408 | def test_destroy_fully_deleted? 409 | object = ParanoidTime.first 410 | object.destroy_fully! 411 | assert object.deleted? 412 | 413 | object = ParanoidString.first 414 | object.destroy_fully! 415 | assert object.deleted? 416 | end 417 | 418 | def test_deleted_fully? 419 | ParanoidTime.first.destroy 420 | assert_not ParanoidTime.with_deleted.first.deleted_fully? 421 | 422 | ParanoidString.first.destroy 423 | assert ParanoidString.with_deleted.first.deleted? 424 | end 425 | 426 | def test_delete_deleted_fully? 427 | ParanoidTime.first.delete 428 | assert_not ParanoidTime.with_deleted.first.deleted_fully? 429 | end 430 | 431 | def test_destroy_fully_deleted_fully? 432 | object = ParanoidTime.first 433 | object.destroy_fully! 434 | assert object.deleted_fully? 435 | end 436 | 437 | def test_paranoid_destroy_callbacks 438 | @paranoid_with_callback = ParanoidWithCallback.first 439 | ParanoidWithCallback.transaction do 440 | @paranoid_with_callback.destroy 441 | end 442 | 443 | assert @paranoid_with_callback.called_before_destroy 444 | assert @paranoid_with_callback.called_after_destroy 445 | assert @paranoid_with_callback.called_after_commit_on_destroy 446 | end 447 | 448 | def test_hard_destroy_callbacks 449 | @paranoid_with_callback = ParanoidWithCallback.first 450 | 451 | ParanoidWithCallback.transaction do 452 | @paranoid_with_callback.destroy! 453 | end 454 | 455 | assert @paranoid_with_callback.called_before_destroy 456 | assert @paranoid_with_callback.called_after_destroy 457 | assert @paranoid_with_callback.called_after_commit_on_destroy 458 | end 459 | 460 | def test_recovery_callbacks 461 | @paranoid_with_callback = ParanoidWithCallback.first 462 | 463 | ParanoidWithCallback.transaction do 464 | @paranoid_with_callback.destroy 465 | 466 | assert_nil @paranoid_with_callback.called_before_recover 467 | assert_nil @paranoid_with_callback.called_after_recover 468 | 469 | @paranoid_with_callback.recover 470 | end 471 | 472 | assert @paranoid_with_callback.called_before_recover 473 | assert @paranoid_with_callback.called_after_recover 474 | end 475 | 476 | def test_recovery_callbacks_without_destroy 477 | @paranoid_with_callback = ParanoidWithCallback.first 478 | @paranoid_with_callback.recover 479 | 480 | assert_nil @paranoid_with_callback.called_before_recover 481 | assert_nil @paranoid_with_callback.called_after_recover 482 | end 483 | 484 | def test_delete_by_multiple_id_is_paranoid 485 | model_a = ParanoidBelongsDependant.create 486 | model_b = ParanoidBelongsDependant.create 487 | ParanoidBelongsDependant.delete([model_a.id, model_b.id]) 488 | 489 | assert_paranoid_deletion(model_a) 490 | assert_paranoid_deletion(model_b) 491 | end 492 | 493 | def test_destroy_by_multiple_id_is_paranoid 494 | model_a = ParanoidBelongsDependant.create 495 | model_b = ParanoidBelongsDependant.create 496 | ParanoidBelongsDependant.destroy([model_a.id, model_b.id]) 497 | 498 | assert_paranoid_deletion(model_a) 499 | assert_paranoid_deletion(model_b) 500 | end 501 | 502 | def test_delete_by_single_id_is_paranoid 503 | model = ParanoidBelongsDependant.create 504 | ParanoidBelongsDependant.delete(model.id) 505 | 506 | assert_paranoid_deletion(model) 507 | end 508 | 509 | def test_destroy_by_single_id_is_paranoid 510 | model = ParanoidBelongsDependant.create 511 | ParanoidBelongsDependant.destroy(model.id) 512 | 513 | assert_paranoid_deletion(model) 514 | end 515 | 516 | def test_instance_delete_is_paranoid 517 | model = ParanoidBelongsDependant.create 518 | model.delete 519 | 520 | assert_paranoid_deletion(model) 521 | end 522 | 523 | def test_instance_destroy_is_paranoid 524 | model = ParanoidBelongsDependant.create 525 | model.destroy 526 | 527 | assert_paranoid_deletion(model) 528 | end 529 | 530 | # Test string type columns that don't have a nil value when not deleted (Y/N for example) 531 | def test_string_type_with_no_nil_value_before_destroy 532 | ps = ParanoidString.create!(deleted: "not dead") 533 | assert_equal 1, ParanoidString.where(id: ps).count 534 | end 535 | 536 | def test_string_type_with_no_nil_value_after_destroy 537 | ps = ParanoidString.create!(deleted: "not dead") 538 | ps.destroy 539 | assert_equal 0, ParanoidString.where(id: ps).count 540 | end 541 | 542 | def test_string_type_with_no_nil_value_before_destroy_with_deleted 543 | ps = ParanoidString.create!(deleted: "not dead") 544 | assert_equal 1, ParanoidString.with_deleted.where(id: ps).count 545 | end 546 | 547 | def test_string_type_with_no_nil_value_after_destroy_with_deleted 548 | ps = ParanoidString.create!(deleted: "not dead") 549 | ps.destroy 550 | assert_equal 1, ParanoidString.with_deleted.where(id: ps).count 551 | end 552 | 553 | def test_string_type_with_no_nil_value_before_destroy_only_deleted 554 | ps = ParanoidString.create!(deleted: "not dead") 555 | assert_equal 0, ParanoidString.only_deleted.where(id: ps).count 556 | end 557 | 558 | def test_string_type_with_no_nil_value_after_destroy_only_deleted 559 | ps = ParanoidString.create!(deleted: "not dead") 560 | ps.destroy 561 | assert_equal 1, ParanoidString.only_deleted.where(id: ps).count 562 | end 563 | 564 | def test_string_type_with_no_nil_value_after_destroyed_twice 565 | ps = ParanoidString.create!(deleted: "not dead") 566 | 2.times { ps.destroy } 567 | assert_equal 0, ParanoidString.with_deleted.where(id: ps).count 568 | end 569 | 570 | # Test boolean type columns, that are not nullable 571 | def test_boolean_type_with_no_nil_value_before_destroy 572 | ps = ParanoidBooleanNotNullable.create! 573 | assert_equal 1, ParanoidBooleanNotNullable.where(id: ps).count 574 | end 575 | 576 | def test_boolean_type_with_no_nil_value_after_destroy 577 | ps = ParanoidBooleanNotNullable.create! 578 | ps.destroy 579 | assert_equal 0, ParanoidBooleanNotNullable.where(id: ps).count 580 | end 581 | 582 | def test_boolean_type_with_no_nil_value_before_destroy_with_deleted 583 | ps = ParanoidBooleanNotNullable.create! 584 | assert_equal 1, ParanoidBooleanNotNullable.with_deleted.where(id: ps).count 585 | end 586 | 587 | def test_boolean_type_with_no_nil_value_after_destroy_with_deleted 588 | ps = ParanoidBooleanNotNullable.create! 589 | ps.destroy 590 | assert_equal 1, ParanoidBooleanNotNullable.with_deleted.where(id: ps).count 591 | end 592 | 593 | def test_boolean_type_with_no_nil_value_before_destroy_only_deleted 594 | ps = ParanoidBooleanNotNullable.create! 595 | assert_equal 0, ParanoidBooleanNotNullable.only_deleted.where(id: ps).count 596 | end 597 | 598 | def test_boolean_type_with_no_nil_value_after_destroy_only_deleted 599 | ps = ParanoidBooleanNotNullable.create! 600 | ps.destroy 601 | assert_equal 1, ParanoidBooleanNotNullable.only_deleted.where(id: ps).count 602 | end 603 | 604 | def test_boolean_type_with_no_nil_value_after_destroyed_twice 605 | ps = ParanoidBooleanNotNullable.create! 606 | 2.times { ps.destroy } 607 | assert_equal 0, ParanoidBooleanNotNullable.with_deleted.where(id: ps).count 608 | end 609 | 610 | def test_boolean_type_with_no_nil_value_after_recover 611 | ps = ParanoidBooleanNotNullable.create! 612 | ps.destroy 613 | assert_equal 1, ParanoidBooleanNotNullable.only_deleted.where(id: ps).count 614 | 615 | ps.recover 616 | assert_equal 1, ParanoidBooleanNotNullable.where(id: ps).count 617 | end 618 | 619 | def test_boolean_type_with_no_nil_value_after_recover! 620 | ps = ParanoidBooleanNotNullable.create! 621 | ps.destroy 622 | assert_equal 1, ParanoidBooleanNotNullable.only_deleted.where(id: ps).count 623 | 624 | ps.recover! 625 | assert_equal 1, ParanoidBooleanNotNullable.where(id: ps).count 626 | end 627 | 628 | def test_no_double_tap_destroys_fully 629 | ps = ParanoidNoDoubleTapDestroysFully.create! 630 | 2.times { ps.destroy } 631 | assert_equal 1, ParanoidNoDoubleTapDestroysFully.with_deleted.where(id: ps).count 632 | end 633 | 634 | def test_decrement_counters_without_touch 635 | paranoid_boolean = ParanoidBoolean.create! 636 | paranoid_with_counter_cache = ParanoidWithCounterCache 637 | .create!(paranoid_boolean: paranoid_boolean) 638 | 639 | assert_equal 1, paranoid_boolean.paranoid_with_counter_caches_count 640 | updated_at = paranoid_boolean.reload.updated_at 641 | 642 | paranoid_with_counter_cache.destroy 643 | 644 | assert_equal 0, paranoid_boolean.reload.paranoid_with_counter_caches_count 645 | assert_equal updated_at, paranoid_boolean.reload.updated_at 646 | end 647 | 648 | def test_decrement_custom_counters 649 | paranoid_boolean = ParanoidBoolean.create! 650 | paranoid_with_custom_counter_cache = ParanoidWithCustomCounterCache 651 | .create!(paranoid_boolean: paranoid_boolean) 652 | 653 | assert_equal 1, paranoid_boolean.custom_counter_cache 654 | 655 | paranoid_with_custom_counter_cache.destroy 656 | 657 | assert_equal 0, paranoid_boolean.reload.custom_counter_cache 658 | end 659 | 660 | def test_decrement_counters_with_touch 661 | paranoid_boolean = ParanoidBoolean.create! 662 | paranoid_with_counter_cache = ParanoidWithTouchAndCounterCache 663 | .create!(paranoid_boolean: paranoid_boolean) 664 | 665 | assert_equal 1, paranoid_boolean.paranoid_with_touch_and_counter_caches_count 666 | updated_at = paranoid_boolean.reload.updated_at 667 | 668 | paranoid_with_counter_cache.destroy 669 | 670 | assert_equal 0, paranoid_boolean.reload.paranoid_with_touch_and_counter_caches_count 671 | assert_not_equal updated_at, paranoid_boolean.reload.updated_at 672 | end 673 | 674 | def test_touch_belongs_to 675 | paranoid_boolean = ParanoidBoolean.create! 676 | paranoid_with_counter_cache = ParanoidWithTouch 677 | .create!(paranoid_boolean: paranoid_boolean) 678 | 679 | updated_at = paranoid_boolean.reload.updated_at 680 | 681 | paranoid_with_counter_cache.destroy 682 | 683 | assert_not_equal updated_at, paranoid_boolean.reload.updated_at 684 | end 685 | 686 | def test_destroy_with_optional_belongs_to_and_counter_cache 687 | ps = ParanoidWithCounterCacheOnOptionalBelognsTo.create! 688 | ps.destroy 689 | assert_equal 1, ParanoidWithCounterCacheOnOptionalBelognsTo.only_deleted 690 | .where(id: ps).count 691 | end 692 | 693 | def test_hard_destroy_decrement_counters 694 | paranoid_boolean = ParanoidBoolean.create! 695 | paranoid_with_counter_cache = ParanoidWithCounterCache 696 | .create!(paranoid_boolean: paranoid_boolean) 697 | 698 | assert_equal 1, paranoid_boolean.paranoid_with_counter_caches_count 699 | 700 | paranoid_with_counter_cache.destroy_fully! 701 | 702 | assert_equal 0, paranoid_boolean.reload.paranoid_with_counter_caches_count 703 | end 704 | 705 | def test_hard_destroy_decrement_custom_counters 706 | paranoid_boolean = ParanoidBoolean.create! 707 | paranoid_with_custom_counter_cache = ParanoidWithCustomCounterCache 708 | .create!(paranoid_boolean: paranoid_boolean) 709 | 710 | assert_equal 1, paranoid_boolean.custom_counter_cache 711 | 712 | paranoid_with_custom_counter_cache.destroy_fully! 713 | 714 | assert_equal 0, paranoid_boolean.reload.custom_counter_cache 715 | end 716 | 717 | def test_increment_counters 718 | paranoid_boolean = ParanoidBoolean.create! 719 | paranoid_with_counter_cache = ParanoidWithCounterCache 720 | .create!(paranoid_boolean: paranoid_boolean) 721 | 722 | assert_equal 1, paranoid_boolean.paranoid_with_counter_caches_count 723 | 724 | paranoid_with_counter_cache.destroy 725 | 726 | assert_equal 0, paranoid_boolean.reload.paranoid_with_counter_caches_count 727 | 728 | paranoid_with_counter_cache.recover 729 | 730 | assert_equal 1, paranoid_boolean.reload.paranoid_with_counter_caches_count 731 | end 732 | 733 | def test_increment_custom_counters 734 | paranoid_boolean = ParanoidBoolean.create! 735 | paranoid_with_custom_counter_cache = ParanoidWithCustomCounterCache 736 | .create!(paranoid_boolean: paranoid_boolean) 737 | 738 | assert_equal 1, paranoid_boolean.custom_counter_cache 739 | 740 | paranoid_with_custom_counter_cache.destroy 741 | 742 | assert_equal 0, paranoid_boolean.reload.custom_counter_cache 743 | 744 | paranoid_with_custom_counter_cache.recover 745 | 746 | assert_equal 1, paranoid_boolean.reload.custom_counter_cache 747 | end 748 | 749 | def test_explicitly_setting_table_name_after_acts_as_paranoid_macro 750 | assert_equal "explicit_table.deleted_at", ParanoidWithExplicitTableNameAfterMacro 751 | .paranoid_column_reference 752 | end 753 | 754 | def test_deleted_after_time 755 | ParanoidTime.first.destroy 756 | assert_equal 0, ParanoidTime.deleted_after_time(1.hour.from_now).count 757 | assert_equal 1, ParanoidTime.deleted_after_time(1.hour.ago).count 758 | end 759 | 760 | def test_deleted_before_time 761 | ParanoidTime.first.destroy 762 | assert_equal 1, ParanoidTime.deleted_before_time(1.hour.from_now).count 763 | assert_equal 0, ParanoidTime.deleted_before_time(1.hour.ago).count 764 | end 765 | 766 | def test_deleted_inside_time_window 767 | ParanoidTime.first.destroy 768 | assert_equal 1, ParanoidTime.deleted_inside_time_window(1.minute.ago, 2.minutes).count 769 | assert_equal 1, 770 | ParanoidTime.deleted_inside_time_window(1.minute.from_now, 2.minutes).count 771 | assert_equal 0, ParanoidTime.deleted_inside_time_window(3.minutes.ago, 1.minute).count 772 | assert_equal 0, 773 | ParanoidTime.deleted_inside_time_window(3.minutes.from_now, 1.minute).count 774 | end 775 | end 776 | --------------------------------------------------------------------------------