diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b3131be4..66c036d7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,45 +15,24 @@ jobs: 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' + rails: ["edge", "~> 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 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 + - 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@v2 + - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 33d169db..ffb76447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,44 @@ # 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 diff --git a/Gemfile b/Gemfile index fe5f736b..66ecd287 100644 --- a/Gemfile +++ b/Gemfile @@ -5,7 +5,7 @@ sqlite = ENV['SQLITE_VERSION'] if sqlite gem 'sqlite3', sqlite, platforms: [:ruby] else - gem 'sqlite3', platforms: [:ruby] + gem 'sqlite3', '~> 1.4', platforms: [:ruby] end platforms :jruby do diff --git a/README.md b/README.md index cc6746e8..cf898b8f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![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:** +**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. @@ -103,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 diff --git a/lib/paranoia.rb b/lib/paranoia.rb index ab89bf9e..f7775b7f 100644 --- a/lib/paranoia.rb +++ b/lib/paranoia.rb @@ -24,6 +24,7 @@ def self.included(klazz) 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 @@ -31,6 +32,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) @@ -45,6 +47,7 @@ def only_deleted 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 } @@ -100,7 +103,7 @@ def paranoia_update!(attributes) def paranoia_destroy with_transaction_returning_status do result = run_callbacks(:destroy) do - @_disable_counter_cache = deleted? + @_disable_counter_cache = paranoia_destroyed? result = paranoia_delete next result unless result && ActiveRecord::VERSION::STRING >= '4.2' each_counter_cached_associations do |association| @@ -113,7 +116,7 @@ def paranoia_destroy @_disable_counter_cache = false result end - raise ActiveRecord::Rollback, "Not destroyed" unless self.deleted? + raise ActiveRecord::Rollback, "Not destroyed" unless paranoia_destroyed? result end || false end @@ -184,7 +187,7 @@ def paranoia_destroyed? end alias :deleted? :paranoia_destroyed? - def really_destroy! + def really_destroy!(update_destroy_attributes: true) with_transaction_returning_status do run_callbacks(:real_destroy) do @_disable_counter_cache = paranoia_destroyed? @@ -198,12 +201,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 - update_columns(paranoia_destroy_attributes) + update_columns(paranoia_destroy_attributes) if update_destroy_attributes destroy_without_paranoia end end @@ -211,8 +216,25 @@ 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 @@ -231,6 +253,16 @@ 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 + 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 + + 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 # we called #destroy def restore_associated_records(recovery_window_range = nil) @@ -254,19 +286,8 @@ 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 diff --git a/lib/paranoia/version.rb b/lib/paranoia/version.rb index 946e0abb..16735463 100644 --- a/lib/paranoia/version.rb +++ b/lib/paranoia/version.rb @@ -1,3 +1,3 @@ module Paranoia - VERSION = '2.5.3'.freeze + VERSION = '3.0.0'.freeze end diff --git a/paranoia.gemspec b/paranoia.gemspec index febe2afc..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 4, 5, 6, and 7, + 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,9 +22,9 @@ Gem::Specification.new do |s| s.required_rubygems_version = ">= 1.3.6" - s.required_ruby_version = '>= 2.5' + s.required_ruby_version = '>= 2.7' - s.add_dependency 'activerecord', '>= 5.1', '< 7.1' + s.add_dependency 'activerecord', '>= 6', '< 8.1' s.add_development_dependency "bundler", ">= 1.0.0" s.add_development_dependency "rake" diff --git a/test/paranoia_test.rb b/test/paranoia_test.rb index f482ffb6..a67be3fe 100644 --- a/test/paranoia_test.rb +++ b/test/paranoia_test.rb @@ -3,7 +3,7 @@ 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 @@ -55,7 +55,12 @@ def setup! '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' + '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 @@ -393,14 +398,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 @@ -409,7 +422,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 @@ -1148,6 +1161,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 @@ -1270,6 +1317,37 @@ 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 + private def get_featureful_model @@ -1725,3 +1803,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