diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..44ca221f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. +# This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake +# For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby + +name: build + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + rails: ["~> 7.2.0", "~> 7.1.0", "~> 7.0.0", "~> 6.1.0"] + ruby: ["3.3","3.2", "3.1", "3.0", "2.7"] + exclude: + - rails: "~> 7.2.0" + ruby: "3.0" + - rails: "~> 7.2.0" + ruby: "2.7" + - rails: "edge" + ruby: "3.0" + - rails: "edge" + ruby: "2.7" + + + + env: + RAILS: ${{ matrix.rails }} + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby }} + bundler-cache: true + - run: bundle exec rake diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index a7e9ae35..00000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -sudo: false -language: ruby -cache: bundler -rvm: - - 2.2.7 - - 2.3.4 - - 2.4.1 - - jruby-9.1.6.0 - -env: - matrix: - - RAILS='~> 4.2.0' - - RAILS='~> 5.0.0' - - RAILS='~> 5.1.0' - -matrix: - allow_failures: - - env: RAILS='~> 4.2.0' - rvm: jruby-9.1.6.0 - - env: RAILS='~> 5.0.0' - rvm: jruby-9.1.6.0 - - env: RAILS='~> 5.1.0' - rvm: jruby-9.1.6.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index d5425ea0..ffb76447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,118 @@ # paranoia Changelog +## 3.0.0 - August 13, 2024 + +_Tagged as 3.0 as Ruby + Rails version constraints have been modernised._ + +- [#564](https://github.com/rubysherpas/paranoia/pull/564) Support Rails edge +- [#563](https://github.com/rubysherpas/paranoia/pull/563) Support Rails 7.2 + +## 2.6.4 - July 20, 2024 + +* [#554](https://github.com/rubysherpas/paranoia/pull/554) Support prebuilt counter cache association list (#554) + [Joé Dupuis](https://github.com/JoeDupuis) +* [#551](https://github.com/rubysherpas/paranoia/pull/551) Fix: restore has_one with scope (#551) + [Paweł Charyło](https://github.com/zygzagZ) +* [#555](https://github.com/rubysherpas/paranoia/pull/555) 📝 Add Yard documentation for Paranoia::Query (#555) + [Clément Prod'homme](https://github.com/cprodhomme) + +## 2.6.3 - Oct 12, 2023 + +* [#548](https://github.com/rubysherpas/paranoia/pull/548) Add support for [Rails 7.1](https://github.com/rails/rails/releases/tag/v7.1.0) (#548) + [Indyarocks](https://github.com/indyarocks) + +## 2.6.2 - Jun 6, 2023 + +* [#441](https://github.com/rubysherpas/paranoia/pull/441) Recursive restore with has_many/one through assocs (#441) + [Emil Ong](https://github.com/emilong) + +## 2.6.1 - Nov 16, 2022 + +* [#535](https://github.com/rubysherpas/paranoia/pull/535) Allow to skip updating paranoia_destroy_attributes for records while really_destroy! + [Anton Bogdanov](https://github.com/kortirso) + +## 2.6.0 - Mar 23, 2022 + +* [#512](https://github.com/rubysherpas/paranoia/pull/512) Quote table names; Mysql 8 has keywords that might match table names which cause an exception. +* [#476](https://github.com/rubysherpas/paranoia/pull/476) Fix syntax error in documentation. +* [#485](https://github.com/rubysherpas/paranoia/pull/485) Rollback transaction if destroy aborted. +* [#522](https://github.com/rubysherpas/paranoia/pull/522) Add failing tests for association with abort on destroy. +* [#513](https://github.com/rubysherpas/paranoia/pull/513) Fix create callback called on destroy. + +## 2.5.3 + +* [#532](https://github.com/rubysherpas/paranoia/pull/532) Fix: correct bug when sentinel_value is not a timestamp + [Hassanin Ahmed](https://github.com/sas1ni69) +* [#531](https://github.com/rubysherpas/paranoia/pull/531) Added test case to reproduce bug introduce in v2.5.1 + [Sherif Elkassaby](https://github.com/sherif-nedap) +* [#529](https://github.com/rubysherpas/paranoia/pull/529) Fix: Do not define a RSpec matcher when RSpec isn't present + [Sebastian Welther](https://github.com/swelther) + +## 2.5.2 + +* [#526](https://github.com/rubysherpas/paranoia/pull/526) Do not include tests files in packaged gem + + [Jason Fleetwood-Boldt](https://github.com/jasonfb) +* [#492](https://github.com/rubysherpas/paranoia/pull/492) Warn if acts_as_paranoid is called more than once on the same model + + [Ignatius Reza](https://github.com/ignatiusreza) + +## 2.5.1 + +* [#481](https://github.com/rubysherpas/paranoia/pull/481) Replaces hard coded `deleted_at` with `paranoia_column`. + + [Hassanin Ahmed](https://github.com/sas1ni69) + +## 2.5.0 + + * [#516](https://github.com/rubysherpas/paranoia/pull/516) Add support for ActiveRecord 7.0, drop support for EOL Ruby < 2.5 and Rails < 5.1 + adding support for Rails 7 + + [Mathieu Jobin](https://github.com/mathieujobin) + * [#515](https://github.com/rubysherpas/paranoia/pull/515) Switch from Travis CI to GitHub Actions + + [Shinichi Maeshima](https://github.com/willnet) + +## 2.4.3 + +* [#503](https://github.com/rubysherpas/paranoia/pull/503) Bump activerecord dependency for Rails 6.1 + + [Jörg Schiller](https://github.com/joergschiller) + +* [#483](https://github.com/rubysherpas/paranoia/pull/483) Update JRuby version to 9.2.8.0 + remove EOL Ruby 2.2 + + [Uwe Kubosch](https://github.com/donv) + +* [#482](https://github.com/rubysherpas/paranoia/pull/482) Fix after_commit for Rails 6 + + [Ashwin Hegde](https://github.com/hashwin) + +## 2.4.2 + +* [#470](https://github.com/rubysherpas/paranoia/pull/470) Add support for ActiveRecord 6.0 + + [Anton Kolodii](https://github.com/iggant), [Jared Norman](https://github.com/jarednorman) + +## 2.4.1 + +* [#435](https://github.com/rubysherpas/paranoia/pull/435) Monkeypatch activerecord relations to work with rails 5.2.0 + + [Bartosz Bonisławski (@bbonislawski)](https://github.com/bbonislawski) + +## 2.4.0 + +* [#423](https://github.com/rubysherpas/paranoia/pull/423) Add `paranoia_destroy` and `paranoia_delete` aliases + + [John Hawthorn (@jhawthorn)](https://github.com/jhawthorn) + +* [#408](https://github.com/rubysherpas/paranoia/pull/408) Fix instance variable `@_disable_counter_cache` not initialized warning. + + [Akira Matsuda (@amatsuda)](https://github.com/amatsuda) + +* [#412](https://github.com/rubysherpas/paranoia/pull/412) Fix `really_destroy!` behavior with `sentinel_value` + + [Steve Rice (@steverice)](https://github.com/steverice) + ## 2.3.1 * [#397](https://github.com/rubysherpas/paranoia/pull/397) Bump active record max version to support 5.1 final diff --git a/Gemfile b/Gemfile index 3687175c..66ecd287 100644 --- a/Gemfile +++ b/Gemfile @@ -1,20 +1,32 @@ source 'https://rubygems.org' -gem 'sqlite3', platforms: [:ruby] +sqlite = ENV['SQLITE_VERSION'] + +if sqlite + gem 'sqlite3', sqlite, platforms: [:ruby] +else + gem 'sqlite3', '~> 1.4', platforms: [:ruby] +end platforms :jruby do - gem 'activerecord-jdbcsqlite3-adapter', github: 'jruby/activerecord-jdbc-adapter', branch: 'rails-5' + gem 'activerecord-jdbcsqlite3-adapter' end -platforms :rbx do - gem 'rubysl', '~> 2.0' - gem 'rubysl-test-unit' - gem 'rubinius-developer_tools' +if RUBY_ENGINE == 'rbx' + platforms :rbx do + gem 'rubinius-developer_tools' + gem 'rubysl', '~> 2.0' + gem 'rubysl-test-unit' + end end -rails = ENV['RAILS'] || '~> 4.2.0' +rails = ENV['RAILS'] || '~> 6.0.4' -gem 'rails', rails +if rails == 'edge' + gem 'rails', github: 'rails/rails' +else + gem 'rails', rails +end # Specify your gem's dependencies in paranoia.gemspec gemspec diff --git a/README.md b/README.md index 50fb4357..766f8b37 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,12 @@ +[![Gem Version](https://badge.fury.io/rb/paranoia.svg)](https://badge.fury.io/rb/paranoia) +[![build](https://github.com/rubysherpas/paranoia/actions/workflows/build.yml/badge.svg)](https://github.com/rubysherpas/paranoia/actions/workflows/build.yml) + +**Notice:** + +`paranoia` has some surprising behaviour (like overriding ActiveRecord's `delete` and `destroy`) and is not recommended for new projects. See [`discard`'s README](https://github.com/jhawthorn/discard#why-not-paranoia-or-acts_as_paranoid) for more details. + +Paranoia will continue to accept bug fixes and support new versions of Rails but isn't accepting new features. + # Paranoia Paranoia is a re-implementation of [acts\_as\_paranoid](http://github.com/ActsAsParanoid/acts_as_paranoid) for Rails 3/4/5, using much, much, much less code. @@ -94,6 +103,14 @@ If you really want it gone *gone*, call `really_destroy!`: # => client ``` +If you need skip updating timestamps for deleting records, call `really_destroy!(update_destroy_attributes: false)`. +When we call `really_destroy!(update_destroy_attributes: false)` on the parent `client`, then each child `email` will also have `really_destroy!(update_destroy_attributes: false)` called. + +``` ruby +>> client.really_destroy!(update_destroy_attributes: false) +# => client +``` + If you want to use a column other than `deleted_at`, you can pass it as an option: ``` ruby @@ -184,11 +201,24 @@ client.restore(:recursive => true) If you want to restore a record and only those dependently destroyed associated records that were deleted within 2 minutes of the object upon which they depend: ``` ruby -Client.restore(id, :recursive => true. :recovery_window => 2.minutes) +Client.restore(id, :recursive => true, :recovery_window => 2.minutes) # or client.restore(:recursive => true, :recovery_window => 2.minutes) ``` +If you want to trigger an after_commit callback when restoring a record: + +``` ruby +class Client < ActiveRecord::Base + acts_as_paranoid after_restore_commit: true + + after_commit :commit_called, on: :restore + # or + after_restore_commit :commit_called + ... +end +``` + Note that by default paranoia will not prevent that a soft destroyed object can't be associated with another object of a different model. A Rails validator is provided should you require this functionality: ``` ruby @@ -331,6 +361,21 @@ end # => NoMethodError: undefined method `with_deleted' for # ``` +#### delete_all: + +The gem supports `delete_all` method, however it is disabled by default, to enable it add this in your `environment` file + +``` ruby +Paranoia.delete_all_enabled = true +``` +alternatively, you can enable/disable it for specific models as follow: + +``` ruby +class User < ActiveRecord::Base + acts_as_paranoid(delete_all_enabled: true) +end +``` + ## Acts As Paranoid Migration You can replace the older `acts_as_paranoid` methods as follows: diff --git a/lib/paranoia.rb b/lib/paranoia.rb index a2298953..93bf9730 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -1,25 +1,26 @@ require 'active_record' unless defined? ActiveRecord -module Paranoia - @@default_sentinel_value = nil +if [ActiveRecord::VERSION::MAJOR, ActiveRecord::VERSION::MINOR] == [5, 2] || + ActiveRecord::VERSION::MAJOR > 5 + require 'paranoia/active_record_5_2' +end - # Change default_sentinel_value in a rails initializer - def self.default_sentinel_value=(val) - @@default_sentinel_value = val - end +module Paranoia - def self.default_sentinel_value - @@default_sentinel_value + class << self + # Change default values in a rails initializer + attr_accessor :default_sentinel_value, + :delete_all_enabled end def self.included(klazz) klazz.extend Query - klazz.extend Callbacks end module Query def paranoid? ; true ; end + # If you want to find all records, even those which are deleted def with_deleted if ActiveRecord::VERSION::STRING >= "4.1" return unscope where: paranoia_column @@ -27,6 +28,7 @@ def with_deleted all.tap { |x| x.default_scoped = false } end + # If you want to find only the deleted records def only_deleted if paranoia_sentinel_value.nil? return with_deleted.where.not(paranoia_column => paranoia_sentinel_value) @@ -36,11 +38,12 @@ def only_deleted # these will not match != sentinel value because "NULL != value" is # NULL under the sql standard # Scoping with the table_name is mandatory to avoid ambiguous errors when joining tables. - scoped_quoted_paranoia_column = "#{self.table_name}.#{connection.quote_column_name(paranoia_column)}" + scoped_quoted_paranoia_column = "#{connection.quote_table_name(self.table_name)}.#{connection.quote_column_name(paranoia_column)}" with_deleted.where("#{scoped_quoted_paranoia_column} IS NULL OR #{scoped_quoted_paranoia_column} != ?", paranoia_sentinel_value) end alias_method :deleted, :only_deleted + # If you want to restore a record def restore(id_or_ids, opts = {}) ids = Array(id_or_ids).flatten any_object_instead_of_id = ids.any? { |id| ActiveRecord::Base === id } @@ -51,33 +54,23 @@ def restore(id_or_ids, opts = {}) end ids.map { |id| only_deleted.find(id).restore!(opts) } end - end - module Callbacks - def self.extended(klazz) - [:restore, :real_destroy].each do |callback_name| - klazz.define_callbacks callback_name - - klazz.define_singleton_method("before_#{callback_name}") do |*args, &block| - set_callback(callback_name, :before, *args, &block) - end - - klazz.define_singleton_method("around_#{callback_name}") do |*args, &block| - set_callback(callback_name, :around, *args, &block) - end + def paranoia_destroy_attributes + { + paranoia_column => current_time_from_proper_timezone + }.merge(timestamp_attributes_with_current_time) + end - klazz.define_singleton_method("after_#{callback_name}") do |*args, &block| - set_callback(callback_name, :after, *args, &block) - end - end + def timestamp_attributes_with_current_time + timestamp_attributes_for_update_in_model.each_with_object({}) { |attr,hash| hash[attr] = current_time_from_proper_timezone } end end - def destroy - transaction do - run_callbacks(:destroy) do - @_disable_counter_cache = deleted? - result = delete + def paranoia_destroy + with_transaction_returning_status do + result = run_callbacks(:destroy) do + @_disable_counter_cache = paranoia_destroyed? + result = paranoia_delete next result unless result && ActiveRecord::VERSION::STRING >= '4.2' each_counter_cached_associations do |association| foreign_key = association.reflection.foreign_key.to_sym @@ -85,13 +78,35 @@ def destroy next unless send(association.reflection.name) association.decrement_counters end + @_trigger_destroy_callback = true @_disable_counter_cache = false result end + raise ActiveRecord::Rollback, "Not destroyed" unless paranoia_destroyed? + result + end || false + end + alias_method :destroy, :paranoia_destroy + + def paranoia_destroy! + paranoia_destroy || + raise(ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self)) + end + + def trigger_transactional_callbacks? + super || @_trigger_destroy_callback && paranoia_destroyed? || + @_trigger_restore_callback && !paranoia_destroyed? + end + + def transaction_include_any_action?(actions) + super || actions.any? do |action| + if action == :restore + paranoia_after_restore_commit && @_trigger_restore_callback + end end end - def delete + def paranoia_delete raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? if persisted? # if a transaction exists, add the record so that after_commit @@ -103,6 +118,7 @@ def delete end self end + alias_method :delete, :paranoia_delete def restore!(opts = {}) self.class.transaction do @@ -112,8 +128,12 @@ def restore!(opts = {}) # This only happened on Rails versions earlier than 4.1. noop_if_frozen = ActiveRecord.version < Gem::Version.new("4.1") if within_recovery_window?(recovery_window_range) && ((noop_if_frozen && !@attributes.frozen?) || !noop_if_frozen) - @_disable_counter_cache = !deleted? + @_disable_counter_cache = !paranoia_destroyed? write_attribute paranoia_column, paranoia_sentinel_value + if paranoia_after_restore_commit + @_trigger_restore_callback = true + add_to_transaction + end update_columns(paranoia_restore_attributes) each_counter_cached_associations do |association| if send(association.reflection.name) @@ -127,29 +147,33 @@ def restore!(opts = {}) end self + ensure + if paranoia_after_restore_commit + @_trigger_restore_callback = false + end end alias :restore :restore! def get_recovery_window_range(opts) return opts[:recovery_window_range] if opts[:recovery_window_range] return unless opts[:recovery_window] - (deleted_at - opts[:recovery_window]..deleted_at + opts[:recovery_window]) + (deletion_time - opts[:recovery_window]..deletion_time + opts[:recovery_window]) end def within_recovery_window?(recovery_window_range) return true unless recovery_window_range - recovery_window_range.cover?(deleted_at) + recovery_window_range.cover?(deletion_time) end def paranoia_destroyed? - send(paranoia_column) != paranoia_sentinel_value + paranoia_column_value != paranoia_sentinel_value end alias :deleted? :paranoia_destroyed? - def really_destroy! - transaction do + def really_destroy!(update_destroy_attributes: true) + with_transaction_returning_status do run_callbacks(:real_destroy) do - @_disable_counter_cache = deleted? + @_disable_counter_cache = paranoia_destroyed? dependent_reflections = self.class.reflections.select do |name, reflection| reflection.options[:dependent] == :destroy end @@ -160,12 +184,14 @@ def really_destroy! # .paranoid? will work for both instances and classes next unless association_data && association_data.paranoid? if reflection.collection? - next association_data.with_deleted.each(&:really_destroy!) + next association_data.with_deleted.find_each { |record| + record.really_destroy!(update_destroy_attributes: update_destroy_attributes) + } end - association_data.really_destroy! + association_data.really_destroy!(update_destroy_attributes: update_destroy_attributes) end end - write_attribute(paranoia_column, current_time_from_proper_timezone) + update_columns(paranoia_destroy_attributes) if update_destroy_attributes destroy_without_paranoia end end @@ -173,24 +199,43 @@ def really_destroy! private + def counter_cache_disabled? + defined?(@_disable_counter_cache) && @_disable_counter_cache + end + + def counter_cached_association_names + return [] if counter_cache_disabled? + super + end + def each_counter_cached_associations - !(defined?(@_disable_counter_cache) && @_disable_counter_cache) ? super : [] + return [] if counter_cache_disabled? + + if defined?(super) + super + else + counter_cached_association_names.each do |name| + yield association(name) + end + end end def paranoia_restore_attributes { paranoia_column => paranoia_sentinel_value - }.merge(timestamp_attributes_with_current_time) + }.merge(self.class.timestamp_attributes_with_current_time) end - def paranoia_destroy_attributes - { - paranoia_column => current_time_from_proper_timezone - }.merge(timestamp_attributes_with_current_time) - end + delegate :paranoia_destroy_attributes, to: 'self.class' + + def paranoia_find_has_one_target(association) + association_foreign_key = association.options[:through].present? ? association.klass.primary_key : association.foreign_key + association_find_conditions = { association_foreign_key => self.id } + association_find_conditions[association.type] = self.class.name if association.type - def timestamp_attributes_with_current_time - timestamp_attributes_for_update_in_model.each_with_object({}) { |attr,hash| hash[attr] = current_time_from_proper_timezone } + scope = association.klass.only_deleted.where(association_find_conditions) + scope = scope.merge(association.scope) if association.scope + scope.first end # restore associated records that have been soft deleted when @@ -216,40 +261,70 @@ def restore_associated_records(recovery_window_range = nil) end if association_data.nil? && association.macro.to_s == "has_one" - association_class_name = association.klass.name - association_foreign_key = association.foreign_key - - if association.type - association_polymorphic_type = association.type - association_find_conditions = { association_polymorphic_type => self.class.name.to_s, association_foreign_key => self.id } - else - association_find_conditions = { association_foreign_key => self.id } - end - - association_class = association_class_name.constantize - if association_class.paranoid? - association_class.only_deleted.where(association_find_conditions).first + if association.klass.paranoid? + paranoia_find_has_one_target(association) .try!(:restore, recursive: true, :recovery_window_range => recovery_window_range) end end end - clear_association_cache if destroyed_associations.present? + if ActiveRecord.version.to_s > '7' + # Method deleted in https://github.com/rails/rails/commit/dd5886d00a2d5f31ccf504c391aad93deb014eb8 + @association_cache.clear if persisted? && destroyed_associations.present? + else + clear_association_cache if destroyed_associations.present? + end + end +end + +module ActiveRecord + module Transactions + module RestoreSupport + def self.included(base) + base::ACTIONS << :restore unless base::ACTIONS.include?(:restore) + end + end + + module ClassMethods + def after_restore_commit(*args, &block) + set_options_for_callbacks!(args, on: :restore) + set_callback(:commit, :after, *args, &block) + end + end + end +end + +module Paranoia::Relation + def paranoia_delete_all + update_all(klass.paranoia_destroy_attributes) end + + alias_method :delete_all, :paranoia_delete_all end ActiveSupport.on_load(:active_record) do class ActiveRecord::Base def self.acts_as_paranoid(options={}) + if included_modules.include?(Paranoia) + puts "[WARN] #{self.name} is calling acts_as_paranoid more than once!" + + return + end + + define_model_callbacks :restore, :real_destroy + alias_method :really_destroyed?, :destroyed? alias_method :really_delete, :delete alias_method :destroy_without_paranoia, :destroy + class << self; delegate :really_delete_all, to: :all end include Paranoia - class_attribute :paranoia_column, :paranoia_sentinel_value + class_attribute :paranoia_column, :paranoia_sentinel_value, :paranoia_after_restore_commit, + :delete_all_enabled self.paranoia_column = (options[:column] || :deleted_at).to_s self.paranoia_sentinel_value = options.fetch(:sentinel_value) { Paranoia.default_sentinel_value } + self.paranoia_after_restore_commit = options.fetch(:after_restore_commit) { false } def self.paranoia_scope where(paranoia_column => paranoia_sentinel_value) end @@ -265,6 +340,20 @@ class << self; alias_method :without_deleted, :paranoia_scope end after_restore { self.class.notify_observers(:after_restore, self) if self.class.respond_to?(:notify_observers) } + + if paranoia_after_restore_commit + ActiveRecord::Transactions.send(:include, ActiveRecord::Transactions::RestoreSupport) + end + + self.delete_all_enabled = options[:delete_all_enabled] || Paranoia.delete_all_enabled + + if self.delete_all_enabled + "#{self}::ActiveRecord_Relation".constantize.class_eval do + alias_method :really_delete_all, :delete_all + + include Paranoia::Relation + end + end end # Please do not use this method in production. @@ -287,9 +376,17 @@ def paranoia_column self.class.paranoia_column end + def paranoia_column_value + send(paranoia_column) + end + def paranoia_sentinel_value self.class.paranoia_sentinel_value end + + def deletion_time + paranoia_column_value.acts_like?(:time) ? paranoia_column_value : deleted_at + end end end @@ -313,12 +410,12 @@ def build_relation(klass, *args) class UniquenessValidator < ActiveModel::EachValidator prepend UniquenessParanoiaValidator end - + class AssociationNotSoftDestroyedValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) # if association is soft destroyed, add an error - if value.present? && value.deleted? - record.errors[attribute] << 'has been soft-deleted' + if value.present? && value.paranoia_destroyed? + record.errors.add(attribute, 'has been soft-deleted') end end end diff --git a/lib/paranoia/active_record_5_2.rb b/lib/paranoia/active_record_5_2.rb new file mode 100644 index 00000000..c9c5463f --- /dev/null +++ b/lib/paranoia/active_record_5_2.rb @@ -0,0 +1,41 @@ +module HandleParanoiaDestroyedInBelongsToAssociation + def handle_dependency + return unless load_target + + case options[:dependent] + when :destroy + target.destroy + if target.respond_to?(:paranoia_destroyed?) + raise ActiveRecord::Rollback unless target.paranoia_destroyed? + else + raise ActiveRecord::Rollback unless target.destroyed? + end + else + target.send(options[:dependent]) + end + end +end + +module HandleParanoiaDestroyedInHasOneAssociation + def delete(method = options[:dependent]) + if load_target + case method + when :delete + target.delete + when :destroy + target.destroyed_by_association = reflection + target.destroy + if target.respond_to?(:paranoia_destroyed?) + throw(:abort) unless target.paranoia_destroyed? + else + throw(:abort) unless target.destroyed? + end + when :nullify + target.update_columns(reflection.foreign_key => nil) if target.persisted? + end + end + end +end + +ActiveRecord::Associations::BelongsToAssociation.prepend HandleParanoiaDestroyedInBelongsToAssociation +ActiveRecord::Associations::HasOneAssociation.prepend HandleParanoiaDestroyedInHasOneAssociation diff --git a/lib/paranoia/rspec.rb b/lib/paranoia/rspec.rb index e2c30209..edb621f3 100644 --- a/lib/paranoia/rspec.rb +++ b/lib/paranoia/rspec.rb @@ -1,23 +1,26 @@ -require 'rspec/expectations' +if defined?(RSpec) + require 'rspec/expectations' -# Validate the subject's class did call "acts_as_paranoid" -RSpec::Matchers.define :act_as_paranoid do - match { |subject| subject.class.ancestors.include?(Paranoia) } + # Validate the subject's class did call "acts_as_paranoid" + RSpec::Matchers.define :act_as_paranoid do + match { |subject| subject.class.ancestors.include?(Paranoia) } - failure_message_proc = lambda do - "expected #{subject.class} to use `acts_as_paranoid`" - end + failure_message_proc = lambda do + "expected #{subject.class} to use `acts_as_paranoid`" + end - failure_message_when_negated_proc = lambda do - "expected #{subject.class} not to use `acts_as_paranoid`" - end + failure_message_when_negated_proc = lambda do + "expected #{subject.class} not to use `acts_as_paranoid`" + end - if respond_to?(:failure_message_when_negated) - failure_message(&failure_message_proc) - failure_message_when_negated(&failure_message_when_negated_proc) - else - # RSpec 2 compatibility: - failure_message_for_should(&failure_message_proc) - failure_message_for_should_not(&failure_message_when_negated_proc) + if respond_to?(:failure_message_when_negated) + failure_message(&failure_message_proc) + failure_message_when_negated(&failure_message_when_negated_proc) + else + # RSpec 2 compatibility: + failure_message_for_should(&failure_message_proc) + failure_message_for_should_not(&failure_message_when_negated_proc) + end end + end diff --git a/lib/paranoia/version.rb b/lib/paranoia/version.rb index 6349baaa..16735463 100644 --- a/lib/paranoia/version.rb +++ b/lib/paranoia/version.rb @@ -1,3 +1,3 @@ module Paranoia - VERSION = '2.3.1'.freeze + VERSION = '3.0.0'.freeze end diff --git a/paranoia.gemspec b/paranoia.gemspec index fe88af52..9baf38e6 100644 --- a/paranoia.gemspec +++ b/paranoia.gemspec @@ -11,7 +11,7 @@ Gem::Specification.new do |s| s.license = 'MIT' s.summary = "Paranoia is a re-implementation of acts_as_paranoid for Rails 3, 4, and 5, using much, much, much less code." s.description = <<-DSC - Paranoia is a re-implementation of acts_as_paranoid for Rails 3, 4, and 5, + Paranoia is a re-implementation of acts_as_paranoid for Rails 5, 6, and 7, using much, much, much less code. You would use either plugin / gem if you wished that when you called destroy on an Active Record object that it didn't actually destroy it, but just "hid" the record. Paranoia does this @@ -22,14 +22,19 @@ Gem::Specification.new do |s| s.required_rubygems_version = ">= 1.3.6" - s.required_ruby_version = '>= 2.0' + s.required_ruby_version = '>= 2.7' - s.add_dependency 'activerecord', '>= 4.0', '< 5.2' + s.add_dependency 'activerecord', '>= 6', '< 8.1' s.add_development_dependency "bundler", ">= 1.0.0" s.add_development_dependency "rake" - s.files = `git ls-files`.split("\n") + + s.files = Dir.chdir(File.expand_path('..', __FILE__)) do + files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)}) } + files + end + s.executables = `git ls-files`.split("\n").map{|f| f =~ /^bin\/(.*)/ ? $1 : nil}.compact s.require_path = 'lib' end diff --git a/test/paranoia_test.rb b/test/paranoia_test.rb index 8c4c2d3f..e11dc416 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -3,7 +3,11 @@ require 'minitest/autorun' require 'paranoia' -test_framework = defined?(MiniTest::Test) ? MiniTest::Test : MiniTest::Unit::TestCase +test_framework = defined?(Minitest::Test) ? Minitest::Test : Minitest::Unit::TestCase + +if ActiveRecord::Base.respond_to?(:raise_in_transactional_callbacks=) + ActiveRecord::Base.raise_in_transactional_callbacks = true +end def connect! ActiveRecord::Base.establish_connection :adapter => 'sqlite3', database: ':memory:' @@ -26,7 +30,13 @@ def setup! 'featureful_models' => 'deleted_at DATETIME, name VARCHAR(32)', 'plain_models' => 'deleted_at DATETIME', 'callback_models' => 'deleted_at DATETIME', + 'after_commit_on_restore_callback_models' => 'deleted_at DATETIME', + 'after_restore_commit_callback_models' => 'deleted_at DATETIME', + 'after_commit_callback_restore_enabled_models' => 'deleted_at DATETIME', + 'after_other_commit_callback_restore_enabled_models' => 'deleted_at DATETIME', + 'after_commit_callback_models' => 'deleted_at DATETIME', 'fail_callback_models' => 'deleted_at DATETIME', + 'association_with_abort_models' => 'deleted_at DATETIME', 'related_models' => 'parent_model_id INTEGER, parent_model_with_counter_cache_column_id INTEGER, deleted_at DATETIME', 'asplode_models' => 'parent_model_id INTEGER, deleted_at DATETIME', 'employers' => 'name VARCHAR(32), deleted_at DATETIME', @@ -39,11 +49,16 @@ def setup! 'namespaced_paranoid_has_ones' => 'deleted_at DATETIME, paranoid_belongs_tos_id INTEGER', 'namespaced_paranoid_belongs_tos' => 'deleted_at DATETIME, paranoid_has_one_id INTEGER', 'unparanoid_unique_models' => 'name VARCHAR(32), paranoid_with_unparanoids_id INTEGER', - 'active_column_models' => 'deleted_at DATETIME, active BOOLEAN', + 'active_column_models' => 'paranoid_model_id INTEGER, deleted_at DATETIME, active BOOLEAN', 'active_column_model_with_uniqueness_validations' => 'name VARCHAR(32), deleted_at DATETIME, active BOOLEAN', 'paranoid_model_with_belongs_to_active_column_model_with_has_many_relationships' => 'name VARCHAR(32), deleted_at DATETIME, active BOOLEAN, active_column_model_with_has_many_relationship_id INTEGER', - 'active_column_model_with_has_many_relationships' => 'name VARCHAR(32), deleted_at DATETIME, active BOOLEAN', - 'without_default_scope_models' => 'deleted_at DATETIME' + 'active_column_model_with_has_many_relationships' => 'name VARCHAR(32), deleted_at DATETIME, active BOOLEAN', + 'without_default_scope_models' => 'deleted_at DATETIME', + 'paranoid_has_through_restore_parents' => 'deleted_at DATETIME', + 'empty_paranoid_models' => 'deleted_at DATETIME', + 'paranoid_has_one_throughs' => 'paranoid_has_through_restore_parent_id INTEGER NOT NULL, empty_paranoid_model_id INTEGER NOT NULL, deleted_at DATETIME', + 'paranoid_has_many_throughs' => 'paranoid_has_through_restore_parent_id INTEGER NOT NULL, empty_paranoid_model_id INTEGER NOT NULL, deleted_at DATETIME', + 'paranoid_has_one_with_scopes' => 'deleted_at DATETIME, kind STRING, paranoid_has_one_with_scope_id INTEGER', }.each do |table_name, columns_as_sql_string| ActiveRecord::Base.connection.execute "CREATE TABLE #{table_name} (id INTEGER NOT NULL PRIMARY KEY, #{columns_as_sql_string})" end @@ -79,6 +94,17 @@ def test_paranoid_model_class_is_paranoid assert_equal true, ParanoidModel.paranoid? end + def test_doubly_paranoid_model_class_is_warned + assert_output(/DoublyParanoidModel is calling acts_as_paranoid more than once!/) do + DoublyParanoidModel.acts_as_paranoid + end + + refute_equal( + DoublyParanoidModel.instance_method(:destroy).source_location, + DoublyParanoidModel.instance_method(:destroy_without_paranoia).source_location + ) + end + def test_plain_models_are_not_paranoid assert_equal false, PlainModel.new.paranoid? end @@ -127,6 +153,64 @@ def test_destroy_behavior_for_plain_models_callbacks assert model.instance_variable_get(:@after_commit_callback_called) end + def test_destroy_behavior_for_association_with_abort + model = AssociationWithAbortModel.new + model.related_models.build + model.save + + assert_equal model.reload.related_models.count, 1 + + model = AssociationWithAbortModel.find(model.id) + return_value = model.destroy + + assert_equal return_value, false + assert_equal model.reload.related_models.count, 1 + end + + def test_destroy_bang_behavior_for_association_with_abort + model = AssociationWithAbortModel.new + model.related_models.build + model.save + + assert_equal model.reload.related_models.count, 1 + + model = AssociationWithAbortModel.find(model.id) + assert_raises ActiveRecord::RecordNotDestroyed do + model.destroy! + end + + assert_equal model.reload.related_models.count, 1 + end + + def test_destroy_behavior_for_freshly_loaded_plain_models_callbacks + model = CallbackModel.new + model.save + + model = CallbackModel.find(model.id) + model.destroy + + assert_nil model.instance_variable_get(:@update_callback_called) + assert_nil model.instance_variable_get(:@save_callback_called) + assert_nil model.instance_variable_get(:@validate_called) + + assert model.instance_variable_get(:@destroy_callback_called) + assert model.instance_variable_get(:@after_destroy_callback_called) + assert model.instance_variable_get(:@after_commit_callback_called) + end + + def test_destroy_behavior_for_freshly_saved_models_after_commit_callbacks + model = AfterCommitCallbackModel.create! + + assert_equal 1, model.after_create_commit_called_times + assert_equal 0, model.after_destroy_commit_called_times + + # clear the counters, but do not reload from DB + model.remove_called_variables + + model.destroy + assert_equal 0, model.after_create_commit_called_times + assert_equal 1, model.after_destroy_commit_called_times + end def test_delete_behavior_for_plain_models_callbacks model = CallbackModel.new @@ -185,11 +269,11 @@ def test_scoping_behavior_for_paranoid_models p2 = ParanoidModel.create(:parent_model => parent2) p1.destroy p2.destroy - + assert_equal 0, parent1.paranoid_models.count assert_equal 1, parent1.paranoid_models.only_deleted.count - assert_equal 2, ParanoidModel.only_deleted.joins(:parent_model).count + assert_equal 2, ParanoidModel.only_deleted.joins(:parent_model).count assert_equal 1, parent1.paranoid_models.deleted.count assert_equal 0, parent1.paranoid_models.without_deleted.count p3 = ParanoidModel.create(:parent_model => parent1) @@ -198,11 +282,36 @@ def test_scoping_behavior_for_paranoid_models assert_equal [p1,p3], parent1.paranoid_models.with_deleted end + def test_paranoid_model_has_many_active_column_model + parent1 = ParentModel.create + p1 = ParanoidModel.create(:parent_model => parent1) + acm1 = ActiveColumnModel.create(paranoid_model: p1) + + assert_nil p1.reload.deleted_at + assert_equal 1, p1.active_column_models.count + assert_equal true, acm1.active + assert_nil acm1.deleted_at + + p1.destroy + + assert p1.reload.deleted_at != nil + assert_equal 0, p1.active_column_models.count + assert_nil acm1.reload.active + assert acm1.reload.deleted_at != nil + + p1.restore(recursive: true, recovery_window: 10.minutes) + + assert_nil p1.reload.deleted_at + assert_equal 1, p1.active_column_models.count + assert_equal true, acm1.reload.active + assert_nil acm1.reload.deleted_at + end + def test_only_deleted_with_joins c1 = ActiveColumnModelWithHasManyRelationship.create(name: 'Jacky') c2 = ActiveColumnModelWithHasManyRelationship.create(name: 'Thomas') p1 = ParanoidModelWithBelongsToActiveColumnModelWithHasManyRelationship.create(name: 'Hello', active_column_model_with_has_many_relationship: c1) - + c1.destroy assert_equal 1, ActiveColumnModelWithHasManyRelationship.count assert_equal 1, ActiveColumnModelWithHasManyRelationship.only_deleted.count @@ -226,6 +335,22 @@ def test_destroy_behavior_for_custom_column_models assert_equal 1, model.class.deleted.count end + def test_destroy_behavior_for_custom_column_models_with_recovery_options + model = CustomColumnModel.new + model.save! + + assert_nil model.destroyed_at + + model.destroy + + assert_equal false, model.destroyed_at.nil? + assert model.paranoia_destroyed? + + model.restore!(recovery_window: 2.minutes) + + assert_equal 1, model.class.count + end + def test_default_sentinel_value assert_nil ParanoidModel.paranoia_sentinel_value end @@ -271,14 +396,22 @@ def test_active_column_model_with_uniqueness_validation_still_works_on_non_delet end def test_sentinel_value_for_custom_sentinel_models + time_zero = if ActiveRecord::VERSION::MAJOR < 6 + Time.new(0) + elsif ActiveRecord::VERSION::MAJOR == 6 && ActiveRecord::VERSION::MINOR < 1 + Time.new(0) + else + DateTime.new(0) + end + model = CustomSentinelModel.new assert_equal 0, model.class.count model.save! - assert_equal DateTime.new(0), model.deleted_at + assert_equal time_zero, model.deleted_at assert_equal 1, model.class.count model.destroy - assert DateTime.new(0) != model.deleted_at + assert time_zero != model.deleted_at assert model.paranoia_destroyed? assert_equal 0, model.class.count @@ -287,7 +420,7 @@ def test_sentinel_value_for_custom_sentinel_models assert_equal 1, model.class.deleted.count model.restore - assert_equal DateTime.new(0), model.deleted_at + assert_equal time_zero, model.deleted_at assert !model.destroyed? assert_equal 1, model.class.count @@ -497,6 +630,100 @@ def test_restore_behavior_for_callbacks model.reload assert model.instance_variable_get(:@restore_callback_called) + assert_nil model.instance_variable_get(:@after_commit_callback_called) + end + + def test_after_commit_on_restore + model = AfterCommitOnRestoreCallbackModel.new + model.save + id = model.id + model.destroy + + assert model.paranoia_destroyed? + + model = AfterCommitOnRestoreCallbackModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + assert model.instance_variable_get(:@after_restore_callback_called) + assert model.instance_variable_get(:@after_restore_commit_callback_called) + end + + def test_after_restore_commit + model = AfterRestoreCommitCallbackModel.new + model.save + id = model.id + model.destroy + + assert model.paranoia_destroyed? + + model = AfterRestoreCommitCallbackModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + assert model.instance_variable_get(:@after_restore_callback_called) + assert model.instance_variable_get(:@after_restore_commit_callback_called) + end + + def test_after_restore_commit_once + model = AfterRestoreCommitCallbackModel.new + model.save + id = model.id + model.destroy + + assert model.paranoia_destroyed? + assert model.instance_variable_get(:@after_destroy_commit_callback_called) + + model.remove_called_variables + model = AfterRestoreCommitCallbackModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + assert model.instance_variable_get(:@after_restore_callback_called) + assert model.instance_variable_get(:@after_restore_commit_callback_called) + assert_nil model.instance_variable_get(:@after_destroy_commit_callback_called) + + model.remove_called_variables + model.destroy + assert model.instance_variable_get(:@after_destroy_commit_callback_called) + assert_nil model.instance_variable_get(:@after_restore_commit_callback_called) + end + + def test_after_commit_restore_enabled + model = AfterCommitCallbackRestoreEnabledModel.new + model.save + id = model.id + model.destroy + + assert model.paranoia_destroyed? + + model = AfterCommitCallbackRestoreEnabledModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + assert model.instance_variable_get(:@after_restore_callback_called) + assert model.instance_variable_get(:@after_commit_callback_called) + end + + def test_not_call_after_other_commit_restore_enabled + model = AfterOtherCommitCallbackRestoreEnabledModel.new + model.save + id = model.id + model.destroy + + assert model.paranoia_destroyed? + + model = AfterOtherCommitCallbackRestoreEnabledModel.only_deleted.find(id) + model.restore! + model.reload + + assert model.instance_variable_get(:@restore_callback_called) + assert model.instance_variable_get(:@after_restore_callback_called) + assert_nil model.instance_variable_get(:@after_other_commit_callback_called) end def test_really_destroy @@ -519,6 +746,16 @@ def test_real_destroy_dependent_destroy refute NonParanoidModel.unscoped.exists?(child3.id) end + def test_not_destroy_child_if_abort_destroy + parent = ParentModel.create + child = parent.very_related_models.create + parent.destroy_unavailable = true + parent.destroy + + assert_nil parent.reload.deleted_at, "Parent must be not deleted" + assert_nil child.reload.deleted_at, "Child must be not deleted" + end + def test_real_destroy_dependent_destroy_after_normal_destroy parent = ParentModel.create child = parent.very_related_models.create @@ -546,6 +783,14 @@ def test_really_destroy_behavior_for_callbacks assert model.instance_variable_get(:@real_destroy_callback_called) end + def test_really_destroy_behavior_for_active_column_model + model = ActiveColumnModel.new + model.save + model.really_destroy! + + refute ParanoidModel.unscoped.exists?(model.id) + end + def test_really_delete model = ParanoidModel.new model.save @@ -921,6 +1166,40 @@ def test_restore_recursive_on_polymorphic_has_one_association assert_equal 1, polymorphic.class.count end + def test_recursive_restore_with_has_through_associations + parent = ParanoidHasThroughRestoreParent.create + one = EmptyParanoidModel.create + ParanoidHasOneThrough.create( + :paranoid_has_through_restore_parent => parent, + :empty_paranoid_model => one, + ) + many = Array.new(3) do + many = EmptyParanoidModel.create + ParanoidHasManyThrough.create( + :paranoid_has_through_restore_parent => parent, + :empty_paranoid_model => many, + ) + + many + end + + assert_equal true, parent.empty_paranoid_model.present? + assert_equal 3, parent.empty_paranoid_models.count + + parent.destroy + + assert_equal true, parent.empty_paranoid_model.reload.deleted? + assert_equal 0, parent.empty_paranoid_models.count + + parent = ParanoidHasThroughRestoreParent.with_deleted.first + parent.restore(recursive: true) + + assert_equal false, parent.empty_paranoid_model.deleted? + assert_equal one, parent.empty_paranoid_model + assert_equal 3, parent.empty_paranoid_models.count + assert_equal many, parent.empty_paranoid_models + end + # Ensure that we're checking parent_type when restoring def test_missing_restore_recursive_on_polymorphic_has_one_association parent = ParentModel.create @@ -1043,6 +1322,102 @@ def test_counter_cache_column_on_restore end end + def test_has_one_with_scope_missed + parent = ParanoidHasOneWithScope.create + gamma = ParanoidHasOneWithScope.create(kind: :gamma, paranoid_has_one_with_scope: parent) # this has to be first + alpha = ParanoidHasOneWithScope.create(kind: :alpha, paranoid_has_one_with_scope: parent) + beta = ParanoidHasOneWithScope.create(kind: :beta, paranoid_has_one_with_scope: parent) + + parent.destroy + assert !gamma.reload.destroyed? + gamma.destroy + assert_equal 0, ParanoidHasOneWithScope.count # all destroyed + parent.reload # we unload associations + parent.restore(recursive: true) + + assert_equal "alpha", parent.alpha&.kind, "record was not restored" + assert_equal "beta", parent.beta&.kind, "record was not restored" + assert_nil parent.gamma, "record was incorrectly restored" + end + + def test_has_one_with_scope_not_restored + parent = ParanoidHasOneWithScope.create + gamma = ParanoidHasOneWithScope.create(kind: :gamma, paranoid_has_one_with_scope: parent) + parent.destroy + assert_equal 1, ParanoidHasOneWithScope.count # gamma not deleted + gamma.destroy + parent.reload # we unload associations + parent.restore(recursive: true) + + assert gamma.reload.deleted?, "the record was incorrectly restored" + assert_equal 1, ParanoidHasOneWithScope.count # gamma deleted + end + + def test_delete_all_disabled_by_default + assert_nil ParanoidModel.delete_all_enabled + + (0...3).each{ ParanoidModel.create } + assert_equal 3, ParanoidModel.count + ParanoidModel.delete_all + assert_equal 0, ParanoidModel.count + assert_equal 0, ParanoidModel.unscoped.count + end + + def test_delete_all_called_on_class + assert Employee.delete_all_enabled + + (0...3).each{ Employee.create } + assert_equal 3, Employee.count + Employee.delete_all + assert_equal 0, Employee.count + assert_equal 3, Employee.unscoped.count + end + + def test_delete_all_called_on_relation + assert Employee.delete_all_enabled + + (0...3).each{ Employee.create } + assert_equal 3, Employee.count + Employee.where(id: 1).delete_all + assert_equal 2, Employee.count + assert_equal 3, Employee.unscoped.count + end + + def test_really_delete_all_called_on_class + assert Employee.delete_all_enabled + + (0...3).each{ Employee.create } + assert_equal 3, Employee.count + Employee.really_delete_all + assert_equal 0, Employee.count + assert_equal 0, Employee.unscoped.count + end + + def test_delete_all_called_on_relation + assert Employee.delete_all_enabled + + (0...3).each{ Employee.create } + assert_equal 3, Employee.count + Employee.where(id: 1).really_delete_all + assert_equal 2, Employee.count + assert_equal 2, Employee.unscoped.count + end + + def test_update_has_many_through_relation_delete_associations + employer = Employer.create + employee1 = Employee.create + employee2 = Employee.create + job = Job.create :employer => employer, :employee => employee1 + + assert_equal 1, employer.jobs.count + assert_equal 1, employer.jobs.with_deleted.count + + employer.update(employee_ids: [employee2.id]) + + assert_equal 1, employer.jobs.count + assert_equal 2, employer.jobs.with_deleted.count + end + private def get_featureful_model FeaturefulModel.new(:name => "not empty") @@ -1052,7 +1427,15 @@ def get_featureful_model # Helper classes class ParanoidModel < ActiveRecord::Base + acts_as_paranoid belongs_to :parent_model + + has_many :active_column_models, dependent: :destroy + +end + +class DoublyParanoidModel < ActiveRecord::Base + self.table_name = 'plain_models' acts_as_paranoid end @@ -1109,15 +1492,107 @@ def remove_called_variables end end +class AfterCommitOnRestoreCallbackModel < ActiveRecord::Base + acts_as_paranoid after_restore_commit: true + before_restore { |model| model.instance_variable_set :@restore_callback_called, true } + after_restore { |model| model.instance_variable_set :@after_restore_callback_called, true } + after_commit :set_after_restore_commit_called, on: :restore + + def set_after_restore_commit_called + @after_restore_commit_callback_called = true + end +end + +class AfterRestoreCommitCallbackModel < ActiveRecord::Base + acts_as_paranoid after_restore_commit: true + before_restore { |model| model.instance_variable_set :@restore_callback_called, true } + after_restore { |model| model.instance_variable_set :@after_restore_callback_called, true } + after_restore_commit { |model| model.instance_variable_set :@after_restore_commit_callback_called, true } + after_destroy_commit { |model| model.instance_variable_set :@after_destroy_commit_callback_called, true } + + def remove_called_variables + instance_variables.each {|name| (name.to_s.end_with?('_called')) ? remove_instance_variable(name) : nil} + end +end + +class AfterCommitCallbackRestoreEnabledModel < ActiveRecord::Base + acts_as_paranoid after_restore_commit: true + before_restore { |model| model.instance_variable_set :@restore_callback_called, true } + after_restore { |model| model.instance_variable_set :@after_restore_callback_called, true } + after_commit { |model| model.instance_variable_set :@after_commit_callback_called, true } +end + +class AfterOtherCommitCallbackRestoreEnabledModel < ActiveRecord::Base + acts_as_paranoid after_restore_commit: true + before_restore { |model| model.instance_variable_set :@restore_callback_called, true } + after_restore { |model| model.instance_variable_set :@after_restore_callback_called, true } + after_commit :set_after_other_commit_called, on: [:create, :destroy, :update] + + def set_after_other_commit_called + @after_other_commit_callback_called = true + end +end + +class AssociationWithAbortModel < ActiveRecord::Base + acts_as_paranoid + has_many :related_models, class_name: 'RelatedModel', foreign_key: :parent_model_id, dependent: :destroy + before_destroy { |_| + if ActiveRecord::VERSION::MAJOR < 5 + false + else + throw :abort + end + } +end + +class AfterCommitCallbackModel < ActiveRecord::Base + acts_as_paranoid + + after_commit :increment_after_create_commit_called_times, on: :create + after_commit :increment_after_destroy_commit_called_times, on: :destroy + + def increment_after_create_commit_called_times + @after_create_commit_called_times = after_create_commit_called_times + 1 + end + + def increment_after_destroy_commit_called_times + @after_destroy_commit_called_times = after_destroy_commit_called_times + 1 + end + + def after_create_commit_called_times + @after_create_commit_called_times || 0 + end + + def after_destroy_commit_called_times + @after_destroy_commit_called_times || 0 + end + + def remove_called_variables + instance_variables.each {|name| (name.to_s.end_with?('_called_times')) ? remove_instance_variable(name) : nil} + end +end + class ParentModel < ActiveRecord::Base + attr_accessor :destroy_unavailable acts_as_paranoid - has_many :paranoid_models + has_many :paranoid_models, dependent: :destroy has_many :related_models has_many :very_related_models, :class_name => 'RelatedModel', dependent: :destroy has_many :non_paranoid_models, dependent: :destroy has_one :non_paranoid_model, dependent: :destroy has_many :asplode_models, dependent: :destroy has_one :polymorphic_model, as: :parent, dependent: :destroy + before_destroy :validate_destroy + + def validate_destroy + return unless self.destroy_unavailable + + if ActiveRecord::VERSION::MAJOR < 5 + false + else + throw :abort + end + end end class ParentModelWithCounterCacheColumn < ActiveRecord::Base @@ -1147,16 +1622,17 @@ class Employer < ActiveRecord::Base acts_as_paranoid validates_uniqueness_of :name has_many :jobs - has_many :employees, :through => :jobs + has_many :employees, :through => :jobs, dependent: :destroy end class Employee < ActiveRecord::Base - acts_as_paranoid + acts_as_paranoid(delete_all_enabled: true) has_many :jobs has_many :employers, :through => :jobs end class Job < ActiveRecord::Base + acts_as_paranoid(delete_all_enabled: true) acts_as_paranoid belongs_to :employer belongs_to :employee @@ -1174,9 +1650,12 @@ class WithoutDefaultScopeModel < ActiveRecord::Base acts_as_paranoid without_default_scope: true end + class ActiveColumnModel < ActiveRecord::Base acts_as_paranoid column: :active, sentinel_value: true + belongs_to :paranoid_model + def paranoia_restore_attributes { deleted_at: nil, @@ -1359,3 +1838,37 @@ class ParanoidBelongsTo < ActiveRecord::Base belongs_to :paranoid_has_one end end + +class ParanoidHasThroughRestoreParent < ActiveRecord::Base + acts_as_paranoid + + has_one :paranoid_has_one_through, dependent: :destroy + has_one :empty_paranoid_model, through: :paranoid_has_one_through, dependent: :destroy + + has_many :paranoid_has_many_throughs, dependent: :destroy + has_many :empty_paranoid_models, through: :paranoid_has_many_throughs, dependent: :destroy +end + +class EmptyParanoidModel < ActiveRecord::Base + acts_as_paranoid +end + +class ParanoidHasOneThrough < ActiveRecord::Base + acts_as_paranoid + belongs_to :paranoid_has_through_restore_parent + belongs_to :empty_paranoid_model, dependent: :destroy +end + +class ParanoidHasManyThrough < ActiveRecord::Base + acts_as_paranoid + belongs_to :paranoid_has_through_restore_parent + belongs_to :empty_paranoid_model, dependent: :destroy +end + +class ParanoidHasOneWithScope < ActiveRecord::Base + acts_as_paranoid + has_one :alpha, -> () { where(kind: :alpha) }, class_name: "ParanoidHasOneWithScope", dependent: :destroy + has_one :beta, -> () { where(kind: :beta) }, class_name: "ParanoidHasOneWithScope", dependent: :destroy + has_one :gamma, -> () { where(kind: :gamma) }, class_name: "ParanoidHasOneWithScope" + belongs_to :paranoid_has_one_with_scope +end