diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..b3131be4 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,61 @@ +# 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: + ruby: + - 3.1 + - '3.0' + - 2.7 + - 2.6 + - 2.5 + # - jruby-9.2.19.0 + # - jruby-9.3.1.0 + rails: + - '~> 5.1.0' + - '~> 5.2.0' + - '~> 6.0.0' + - '~> 6.1.0' + - '~> 7.0.0' + - 'edge' + exclude: + # Rails edge is now 7.x and requires ruby 2.7 + - rails: 'edge' + ruby: 2.6 + - rails: 'edge' + ruby: 2.5 + - rails: '~> 7.0.0' + ruby: 2.6 + - rails: '~> 7.0.0' + ruby: 2.5 + # Legacy Rails with newer rubies + - rails: '~> 5.1.0' + ruby: '3.0' + - rails: '~> 5.2.0' + ruby: '3.0' + - rails: '~> 5.1.0' + ruby: 3.1 + - rails: '~> 5.2.0' + ruby: 3.1 + + env: + RAILS: ${{ matrix.rails }} + steps: + - uses: actions/checkout@v2 + - 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 78b46b95..00000000 --- a/.travis.yml +++ /dev/null @@ -1,39 +0,0 @@ -sudo: false -language: ruby -before_install: gem update --system -cache: bundler -rvm: - - 2.2 - - 2.3.8 - - 2.4.5 - - 2.5.3 - - 2.6.3 - - jruby-9.1.6.0 - -env: - matrix: - - RAILS='~> 4.2.0' SQLITE_VERSION='~> 1.3.6' - - RAILS='~> 5.0.0' SQLITE_VERSION='~> 1.3.6' - - RAILS='~> 5.1.0' - - RAILS='~> 5.2.0' - - RAILS='master' - -matrix: - allow_failures: - - env: RAILS='~> 4.2.0' SQLITE_VERSION='~> 1.3.6' - rvm: jruby-9.1.6.0 - - env: RAILS='~> 5.0.0' SQLITE_VERSION='~> 1.3.6' - rvm: jruby-9.1.6.0 - - env: RAILS='~> 5.1.0' - rvm: jruby-9.1.6.0 - - env: RAILS='~> 5.2.0' - rvm: jruby-9.1.6.0 - - env: RAILS='master' - rvm: jruby-9.1.6.0 - exclude: - - rvm: 2.2 - env: RAILS='master' - - rvm: 2.3.8 - env: RAILS='master' - - rvm: 2.4.5 - env: RAILS='master' diff --git a/CHANGELOG.md b/CHANGELOG.md index 08ac9d61..33d169db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,53 @@ # paranoia Changelog +## 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 diff --git a/Gemfile b/Gemfile index 6c061d29..fe5f736b 100644 --- a/Gemfile +++ b/Gemfile @@ -12,15 +12,17 @@ platforms :jruby do gem 'activerecord-jdbcsqlite3-adapter' end -platforms :rbx do - gem 'rubinius-developer_tools' - gem 'rubysl', '~> 2.0' - gem 'rubysl-test-unit' +if RUBY_ENGINE == 'rbx' + platforms :rbx do + gem 'rubinius-developer_tools' + gem 'rubysl', '~> 2.0' + gem 'rubysl-test-unit' + end end -rails = ENV['RAILS'] || '~> 5.2.0' +rails = ENV['RAILS'] || '~> 6.0.4' -if rails == 'master' +if rails == 'edge' gem 'rails', github: 'rails/rails' else gem 'rails', rails diff --git a/README.md b/README.md index fd503fd8..740fe44e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,6 @@ +[![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. diff --git a/lib/paranoia.rb b/lib/paranoia.rb index 71f95f5d..959e5534 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -58,7 +58,7 @@ def restore(id_or_ids, opts = {}) end def paranoia_destroy - transaction do + with_transaction_returning_status do result = run_callbacks(:destroy) do @_disable_counter_cache = deleted? result = paranoia_delete @@ -69,6 +69,7 @@ def paranoia_destroy next unless send(association.reflection.name) association.decrement_counters end + @_trigger_destroy_callback = true @_disable_counter_cache = false result end @@ -83,6 +84,10 @@ def paranoia_destroy! raise(ActiveRecord::RecordNotDestroyed.new("Failed to destroy the record", self)) end + def trigger_transactional_callbacks? + super || @_trigger_destroy_callback && paranoia_destroyed? + end + def paranoia_delete raise ActiveRecord::ReadOnlyRecord, "#{self.class} is marked as readonly" if readonly? if persisted? @@ -126,21 +131,21 @@ def restore!(opts = {}) 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 + with_transaction_returning_status do run_callbacks(:real_destroy) do @_disable_counter_cache = paranoia_destroyed? dependent_reflections = self.class.reflections.select do |name, reflection| @@ -227,13 +232,24 @@ def restore_associated_records(recovery_window_range = nil) 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 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? @@ -282,9 +298,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,7 +337,7 @@ class AssociationNotSoftDestroyedValidator < ActiveModel::EachValidator def validate_each(record, attribute, value) # if association is soft destroyed, add an error if value.present? && value.paranoia_destroyed? - record.errors[attribute] << 'has been soft-deleted' + record.errors.add(attribute, 'has been soft-deleted') end end end 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 046171d6..946e0abb 100644 --- a/lib/paranoia/version.rb +++ b/lib/paranoia/version.rb @@ -1,3 +1,3 @@ module Paranoia - VERSION = '2.4.2'.freeze + VERSION = '2.5.3'.freeze end diff --git a/paranoia.gemspec b/paranoia.gemspec index 9e401e99..febe2afc 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 4, 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.5' - s.add_dependency 'activerecord', '>= 4.0', '< 6.1' + s.add_dependency 'activerecord', '>= 5.1', '< 7.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 e2604fc1..0a0a15ea 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -30,6 +30,7 @@ def setup! 'featureful_models' => 'deleted_at DATETIME, name VARCHAR(32)', 'plain_models' => 'deleted_at DATETIME', 'callback_models' => 'deleted_at DATETIME', + 'after_commit_callback_models' => 'deleted_at DATETIME', 'fail_callback_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', @@ -43,7 +44,7 @@ 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', @@ -83,6 +84,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 @@ -131,6 +143,35 @@ def test_destroy_behavior_for_plain_models_callbacks assert model.instance_variable_get(:@after_commit_callback_called) 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 @@ -202,6 +243,31 @@ 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') @@ -230,6 +296,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 @@ -1074,7 +1156,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 @@ -1131,10 +1221,37 @@ def remove_called_variables 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 @@ -1208,9 +1325,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,