├── .document ├── .github └── workflows │ ├── gempush.yml │ └── test_and_lint.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── Appraisals ├── CODE_OF_CONDUCT.md ├── CONTRIBUTORS.md ├── Gemfile ├── LICENSE ├── README.md ├── README.rst ├── Rakefile ├── VERSION ├── bin ├── gempush-if-changed └── rspec ├── gemfiles ├── .gitignore ├── rails_5.2.gemfile ├── rails_6.0.gemfile ├── rails_6.1.gemfile ├── rails_7.0.gemfile └── rails_7.1.gemfile ├── lib ├── permanent_records.rb └── permanent_records │ └── active_record.rb ├── permanent_records.gemspec └── spec ├── permanent_records ├── circular_sti_dependency_spec.rb ├── counter_cache_spec.rb ├── propagate_validation_flag_spec.rb ├── revive_parent_first_spec.rb └── validate_presence_spec.rb ├── permanent_records_spec.rb ├── spec_helper.rb └── support ├── ant.rb ├── bed.rb ├── comment.rb ├── database.yml ├── difficulty.rb ├── dirt.rb ├── earthworm.rb ├── hole.rb ├── house.rb ├── kitty.rb ├── location.rb ├── meerkat.rb ├── mole.rb ├── muskrat.rb ├── room.rb ├── schema.rb └── unused_model.rb /.document: -------------------------------------------------------------------------------- 1 | init.rb 2 | uninstall.rb 3 | rails/* 4 | test/* 5 | README 6 | MIT-LICENSE 7 | -------------------------------------------------------------------------------- /.github/workflows/gempush.yml: -------------------------------------------------------------------------------- 1 | name: Ruby Gem 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | build: 10 | name: gem publishing 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@master 15 | with: 16 | fetch-depth: 2 17 | - name: Set up Ruby 2.7 18 | uses: ruby/setup-ruby@v1 19 | with: 20 | ruby-version: 2.7.8 21 | 22 | - name: Install bundle 23 | run: bundle 24 | - name: Run rubocop 25 | run: bundle exec rubocop 26 | 27 | - name: Publish to RubyGems 28 | run: | 29 | mkdir -p $HOME/.gem 30 | touch $HOME/.gem/credentials 31 | chmod 0600 $HOME/.gem/credentials 32 | printf -- "---\n:rubygems_api_key: ${GEM_HOST_API_KEY}\n" > $HOME/.gem/credentials 33 | bin/gempush-if-changed 34 | env: 35 | GEM_HOST_API_KEY: ${{secrets.RUBYGEMS_AUTH_TOKEN}} 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | -------------------------------------------------------------------------------- /.github/workflows/test_and_lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint & Test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | name: Test 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby: 12 | - 2.7.8 13 | - 3.0.6 14 | - 3.1.4 15 | - 3.2.3 16 | appraisal: 17 | - rails-5.2 18 | - rails-6.0 19 | - rails-6.1 20 | - rails-7.0 21 | - rails-7.1 22 | exclude: 23 | - ruby: 3.0.6 24 | appraisal: rails-5.2 25 | - ruby: 3.1.4 26 | appraisal: rails-5.2 27 | - ruby: 3.2.3 28 | appraisal: rails-5.2 29 | steps: 30 | - name: Install system dependencies 31 | run: sudo apt-get install -y libsqlite3-dev 32 | - uses: actions/checkout@master 33 | - name: Setup ruby 34 | uses: ruby/setup-ruby@v1 35 | with: 36 | ruby-version: ${{ matrix.ruby }} 37 | - name: Install bundle 38 | run: bundle 39 | - name: Install appraisal 40 | run: bundle exec appraisal install 41 | - name: Run tests 42 | run: bundle exec appraisal ${{ matrix.appraisal }} rspec 43 | lint: 44 | name: Lint 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Install system dependencies 48 | run: sudo apt-get install -y libsqlite3-dev 49 | - uses: actions/checkout@master 50 | - name: Setup ruby 51 | uses: ruby/setup-ruby@v1 52 | with: 53 | ruby-version: 2.7.8 54 | - name: Install bundle 55 | run: bundle 56 | - name: Run rubocop 57 | run: bundle exec rubocop 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | spec/support/debug.log 2 | Gemfile.lock 3 | pkg 4 | permanent_records*.gem 5 | *.swp 6 | .bundle 7 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-performance 3 | - rubocop-rake 4 | - rubocop-rspec 5 | 6 | inherit_from: .rubocop_todo.yml 7 | 8 | AllCops: 9 | Exclude: 10 | - bin/* 11 | - gemfiles/* 12 | - vendor/bundle/**/* 13 | NewCops: enable 14 | TargetRubyVersion: 2.7 15 | 16 | Gemspec/RequireMFA: 17 | Exclude: 18 | - 'permanent_records.gemspec' 19 | 20 | Metrics/BlockLength: 21 | Enabled: false 22 | 23 | Layout/LineLength: 24 | Max: 120 25 | 26 | RSpec/ContextWording: 27 | Enabled: false 28 | 29 | RSpec/MultipleExpectations: 30 | Enabled: false 31 | 32 | RSpec/MultipleMemoizedHelpers: 33 | Enabled: false 34 | 35 | RSpec/NestedGroups: 36 | Enabled: false 37 | 38 | Style/BlockDelimiters: 39 | Enabled: false 40 | -------------------------------------------------------------------------------- /.rubocop_todo.yml: -------------------------------------------------------------------------------- 1 | # This configuration was generated by 2 | # `rubocop --auto-gen-config` 3 | # on 2024-03-26 14:12:23 UTC using RuboCop version 1.62.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 | # Offense count: 11 10 | RSpec/AnyInstance: 11 | Exclude: 12 | - 'spec/permanent_records_spec.rb' 13 | 14 | # Offense count: 3 15 | # Configuration parameters: CountAsOne. 16 | RSpec/ExampleLength: 17 | Max: 22 18 | 19 | # Offense count: 5 20 | # Configuration parameters: Include, CustomTransform, IgnoreMethods, SpecSuffixOnly. 21 | # Include: **/*_spec*rb*, **/spec/**/* 22 | RSpec/FilePath: 23 | Exclude: 24 | - 'spec/permanent_records/circular_sti_dependency_spec.rb' 25 | - 'spec/permanent_records/counter_cache_spec.rb' 26 | - 'spec/permanent_records/propagate_validation_flag_spec.rb' 27 | - 'spec/permanent_records/revive_parent_first_spec.rb' 28 | - 'spec/permanent_records/validate_presence_spec.rb' 29 | 30 | # Offense count: 5 31 | RSpec/LetSetup: 32 | Exclude: 33 | - 'spec/permanent_records/revive_parent_first_spec.rb' 34 | - 'spec/permanent_records_spec.rb' 35 | 36 | # Offense count: 1 37 | # Configuration parameters: . 38 | # SupportedStyles: have_received, receive 39 | RSpec/MessageSpies: 40 | EnforcedStyle: receive 41 | 42 | # Offense count: 60 43 | # Configuration parameters: EnforcedStyle, IgnoreSharedExamples. 44 | # SupportedStyles: always, named_only 45 | RSpec/NamedSubject: 46 | Exclude: 47 | - 'spec/permanent_records_spec.rb' 48 | 49 | # Offense count: 5 50 | # Configuration parameters: Include, CustomTransform, IgnoreMethods, IgnoreMetadata. 51 | # Include: **/*_spec.rb 52 | RSpec/SpecFilePathFormat: 53 | Exclude: 54 | - '**/spec/routing/**/*' 55 | - 'spec/permanent_records/circular_sti_dependency_spec.rb' 56 | - 'spec/permanent_records/counter_cache_spec.rb' 57 | - 'spec/permanent_records/propagate_validation_flag_spec.rb' 58 | - 'spec/permanent_records/revive_parent_first_spec.rb' 59 | - 'spec/permanent_records/validate_presence_spec.rb' 60 | 61 | # Offense count: 2 62 | # This cop supports safe autocorrection (--autocorrect). 63 | Rake/Desc: 64 | Exclude: 65 | - 'Rakefile' 66 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-5.2' do 4 | gem 'rails', '5.2.8.1' 5 | end 6 | 7 | appraise 'rails-6.0' do 8 | gem 'rails', '6.0.6.1' 9 | end 10 | 11 | appraise 'rails-6.1' do 12 | gem 'rails', '6.1.7.7' 13 | end 14 | 15 | appraise 'rails-7.0' do 16 | gem 'rails', '7.0.8.1' 17 | end 18 | 19 | appraise 'rails-7.1' do 20 | gem 'rails', '7.1.3.2' 21 | end 22 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at permanentrecords@jackcanty.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | Folks who gave their time and effort and didn't have to: 2 | 3 | * [David Sulc](https://github.com/davidsulc) 4 | * [Joe Nelson](https://github.com/begriffs) 5 | * [Trond Arve Nordheim](https://github.com/tanordheim) 6 | * [Josh Teneycke](https://github.com/jteneycke) 7 | * [Maximilian Herold](https://github.com/mherold) 8 | * [Hugh Evans](https://github.com/hughevans) 9 | 10 | To join this list just open a GH issue with some code you'd like to 11 | change in this project. New features are fine, bug fixes are better. No 12 | experience or credentials necessary to begin contributing - if you can 13 | read this you're welcome to join. 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec 6 | 7 | gem 'appraisal' 8 | gem 'database_cleaner', '>= 1.5.1' 9 | gem 'pry-byebug' 10 | gem 'rake' 11 | gem 'rspec', '>= 3.5.0' 12 | gem 'rubocop' 13 | gem 'rubocop-performance' 14 | gem 'rubocop-rake' 15 | gem 'rubocop-rspec' 16 | gem 'sqlite3' 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Jack Danger Canty 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PermanentRecords 2 | 3 | [http://github.com/JackDanger/permanent_records/](http://github.com/JackDanger/permanent_records/) 4 | 5 | This gem prevents any of your ActiveRecord data from being destroyed. 6 | Any model that you've given a "deleted_at" datetime column will have that column set rather than let the record be deleted. 7 | 8 | ## What methods does it give me? 9 | 10 | ```ruby 11 | User.find(3).destroy # Sets the 'deleted_at' attribute to Time.now 12 | # and returns a frozen record. If halted by a 13 | # before_destroy callback it returns false instead 14 | 15 | User.find(3).destroy(:force) # Executes the real destroy method, the record 16 | # will be removed from the database. 17 | 18 | User.destroy_all # Soft-deletes all User records. 19 | 20 | User.delete_all # bye bye everything (no soft-deleting here) 21 | ``` 22 | There are also two scopes provided for easily searching deleted and not deleted records: 23 | 24 | ```ruby 25 | User.deleted.find(...) # Only returns deleted records. 26 | 27 | User.not_deleted.find(...) # Only returns non-deleted records. 28 | ``` 29 | 30 | Note: Your normal finds will, by default, _include_ deleted records. You'll have to manually use the ```not_deleted``` scope to avoid this: 31 | 32 | ```ruby 33 | User.find(1) # Will find record number 1, even if it's deleted. 34 | 35 | User.not_deleted.find(1) # This is probably what you want, it doesn't find deleted records. 36 | ``` 37 | 38 | ## Can I easily undelete records? 39 | 40 | Yes. All you need to do is call the 'revive' method. 41 | 42 | ```ruby 43 | User.find(3).destroy # The user is now deleted. 44 | 45 | User.find(3).revive # The user is back to it's original state. 46 | ``` 47 | 48 | And if you had dependent records that were set to be destroyed along with the parent record: 49 | 50 | ```ruby 51 | class User < ActiveRecord::Base 52 | has_many :comments, :dependent => :destroy 53 | end 54 | 55 | User.find(3).destroy # All the comments are destroyed as well. 56 | 57 | User.find(3).revive # All the comments that were just destroyed 58 | # are now back in pristine condition. 59 | ``` 60 | 61 | Forcing deletion works the same way: if you hard delete a record, its dependent records will also be hard deleted. 62 | 63 | ## Can I use default scopes? 64 | 65 | ```ruby 66 | default_scope where(:deleted_at => nil) 67 | ``` 68 | 69 | If you use such a default scope, you will need to simulate the `deleted` scope with a method 70 | 71 | ```ruby 72 | def self.deleted 73 | self.unscoped.where('deleted_at IS NOT NULL') 74 | end 75 | ``` 76 | 77 | ## Is Everything Automated? 78 | 79 | Yes. You don't have to change ANY of your code to get permanent archiving of all your data with this gem. 80 | When you call `destroy` on any record (or `destroy_all` on a class or association) your records will 81 | all have a deleted_at timestamp set on them. 82 | 83 | ## Upgrading from 3.x 84 | 85 | The behaviour of the `destroy` method has been updated so that it now returns 86 | `false` when halted by a before_destroy callback. This is in line with behaviour 87 | of ActiveRecord. For more information see 88 | [#47](https://github.com/JackDanger/permanent_records/issues/47). 89 | 90 | ## Productionizing 91 | 92 | If you operate a system where destroying or reviving a record takes more 93 | than about 3 seconds then you'll want to customize 94 | `PermanentRecords.dependent_record_window = 10.seconds` or some other 95 | value that works for you. 96 | 97 | Patches welcome, forks celebrated. 98 | 99 | Copyright 2015 Jack Danger Canty @ [https://jdanger.com](https://jdanger.com) released under the MIT license 100 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | PermanentRecords (Rails 5) 2 | ============================= 3 | 4 | http://github.com/JackDanger/permanent_records/ 5 | 6 | This gem prevents any of your ActiveRecord data from being destroyed. 7 | Any model that you've given a "deleted\_at" datetime column will have 8 | that column set rather than let the record be deleted. 9 | 10 | What methods does it give me? 11 | ----------------------------- 12 | 13 | .. code:: ruby 14 | 15 | User.find(3).destroy # Sets the 'deleted_at' attribute to Time.now 16 | # and returns a frozen record. If halted by a 17 | # before_destroy callback it returns false instead 18 | 19 | User.find(3).destroy(:force) # Executes the real destroy method, the record 20 | # will be removed from the database. 21 | 22 | User.destroy_all # Soft-deletes all User records. 23 | 24 | User.delete_all # bye bye everything (no soft-deleting here) 25 | 26 | There are also two scopes provided for easily searching deleted and not 27 | deleted records: 28 | 29 | .. code:: ruby 30 | 31 | User.deleted.find(...) # Only returns deleted records. 32 | 33 | User.not_deleted.find(...) # Only returns non-deleted records. 34 | 35 | Note: Your normal finds will, by default, *include* deleted records. 36 | You'll have to manually use the ``not_deleted`` scope to avoid this: 37 | 38 | .. code:: ruby 39 | 40 | User.find(1) # Will find record number 1, even if it's deleted. 41 | 42 | User.not_deleted.find(1) # This is probably what you want, it doesn't find deleted records. 43 | 44 | Can I easily undelete records? 45 | ------------------------------ 46 | 47 | Yes. All you need to do is call the 'revive' method. 48 | 49 | .. code:: ruby 50 | 51 | User.find(3).destroy # The user is now deleted. 52 | 53 | User.find(3).revive # The user is back to it's original state. 54 | 55 | And if you had dependent records that were set to be destroyed along 56 | with the parent record: 57 | 58 | .. code:: ruby 59 | 60 | class User < ActiveRecord::Base 61 | has_many :comments, :dependent => :destroy 62 | end 63 | 64 | User.find(3).destroy # All the comments are destroyed as well. 65 | 66 | User.find(3).revive # All the comments that were just destroyed 67 | # are now back in pristine condition. 68 | 69 | Forcing deletion works the same way: if you hard delete a record, its 70 | dependent records will also be hard deleted. 71 | 72 | Can I use default scopes? 73 | ------------------------- 74 | 75 | .. code:: ruby 76 | 77 | default_scope where(:deleted_at => nil) 78 | 79 | If you use such a default scope, you will need to simulate the 80 | ``deleted`` scope with a method 81 | 82 | .. code:: ruby 83 | 84 | def self.deleted 85 | self.unscoped.where('deleted_at IS NOT NULL') 86 | end 87 | 88 | Is Everything Automated? 89 | ------------------------ 90 | 91 | Yes. You don't have to change ANY of your code to get permanent 92 | archiving of all your data with this gem. When you call ``destroy`` on 93 | any record (or ``destroy_all`` on a class or association) your records 94 | will all have a deleted\_at timestamp set on them. 95 | 96 | Upgrading from 3.x 97 | ------------------ 98 | 99 | The behaviour of the ``destroy`` method has been updated so that it now 100 | returns ``false`` when halted by a before\_destroy callback. This is in 101 | line with behaviour of ActiveRecord. For more information see 102 | `#47 `__. 103 | 104 | Productionizing 105 | --------------- 106 | 107 | If you operate a system where destroying or reviving a record takes more 108 | than about 3 seconds then you'll want to customize 109 | ``PermanentRecords.dependent_record_window = 10.seconds`` or some other 110 | value that works for you. 111 | 112 | Patches welcome, forks celebrated. 113 | 114 | Copyright 2015 Jack Danger Canty @ https://jdanger.com released under 115 | the MIT license 116 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | require 'yaml' 5 | require 'English' 6 | Bundler::GemHelper.install_tasks 7 | 8 | version = File.read('./VERSION').chomp 9 | CONFIG = YAML.load_file( 10 | File.expand_path('spec/support/database.yml', File.dirname(__FILE__)) 11 | ) 12 | 13 | def test_database_exists? 14 | system "psql -l | grep -q #{CONFIG['test'][:database]}" 15 | $CHILD_STATUS.success? 16 | end 17 | 18 | def create_test_database 19 | system "createdb #{CONFIG['test'][:database]}" 20 | end 21 | 22 | namespace :db do 23 | task :create do 24 | create_test_database unless test_database_exists? 25 | end 26 | end 27 | 28 | require 'rubocop/rake_task' 29 | RuboCop::RakeTask.new do |t| 30 | t.options = ['-d'] 31 | end 32 | 33 | require 'rspec/core/rake_task' 34 | RSpec::Core::RakeTask.new(:rspec) do |t| 35 | t.rspec_opts = '-f d -c' 36 | end 37 | 38 | task :pandoc do 39 | system('pandoc -s -r markdown -w rst README.md -o README.rst') 40 | end 41 | 42 | task publish: %i[pandoc rubocop rspec] do 43 | # Ensure the gem builds 44 | system('gem build permanent_records.gemspec') && 45 | # And we didn't leave anything (aside from the gem) uncommitted 46 | !system('git status -s | egrep -v .') && 47 | system('git push') && 48 | system("gem push permanent_records-#{version}.gem") 49 | end 50 | 51 | task default: %i[rspec rubocop] 52 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 6.0.1 2 | -------------------------------------------------------------------------------- /bin/gempush-if-changed: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x 4 | set -euo pipefail 5 | if git diff --name-only HEAD..HEAD^ | egrep -q '^VERSION$'; then 6 | # The VERSION file changed in the last commit, build the gem and push 7 | gem build *.gemspec 8 | gem push *.gem 9 | 10 | # We set the GITHUB_TOKEN variable from the repo's 'Secrets' admin panel. 11 | git remote set-url --push origin https://JackDanger:${GITHUB_TOKEN}@github.com/JackDanger/permanent_records.git 12 | 13 | version=$(cat VERSION) 14 | git tag ${version} 15 | git push origin ${version} 16 | fi 17 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'rspec' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("rspec-core", "rspec") 18 | -------------------------------------------------------------------------------- /gemfiles/.gitignore: -------------------------------------------------------------------------------- 1 | *.gemfile.lock 2 | -------------------------------------------------------------------------------- /gemfiles/rails_5.2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "database_cleaner", ">= 1.5.1" 7 | gem "pry-byebug" 8 | gem "rake" 9 | gem "rspec", ">= 3.5.0" 10 | gem "rubocop" 11 | gem "rubocop-performance" 12 | gem "rubocop-rake" 13 | gem "rubocop-rspec" 14 | gem "sqlite3" 15 | gem "rails", "5.2.8.1" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_6.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "database_cleaner", ">= 1.5.1" 7 | gem "pry-byebug" 8 | gem "rake" 9 | gem "rspec", ">= 3.5.0" 10 | gem "rubocop" 11 | gem "rubocop-performance" 12 | gem "rubocop-rake" 13 | gem "rubocop-rspec" 14 | gem "sqlite3" 15 | gem "rails", "6.0.6.1" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_6.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "database_cleaner", ">= 1.5.1" 7 | gem "pry-byebug" 8 | gem "rake" 9 | gem "rspec", ">= 3.5.0" 10 | gem "rubocop" 11 | gem "rubocop-performance" 12 | gem "rubocop-rake" 13 | gem "rubocop-rspec" 14 | gem "sqlite3" 15 | gem "rails", "6.1.7.7" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_7.0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "database_cleaner", ">= 1.5.1" 7 | gem "pry-byebug" 8 | gem "rake" 9 | gem "rspec", ">= 3.5.0" 10 | gem "rubocop" 11 | gem "rubocop-performance" 12 | gem "rubocop-rake" 13 | gem "rubocop-rspec" 14 | gem "sqlite3" 15 | gem "rails", "7.0.8.1" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /gemfiles/rails_7.1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "appraisal" 6 | gem "database_cleaner", ">= 1.5.1" 7 | gem "pry-byebug" 8 | gem "rake" 9 | gem "rspec", ">= 3.5.0" 10 | gem "rubocop" 11 | gem "rubocop-performance" 12 | gem "rubocop-rake" 13 | gem "rubocop-rspec" 14 | gem "sqlite3" 15 | gem "rails", "7.1.3.2" 16 | 17 | gemspec path: "../" 18 | -------------------------------------------------------------------------------- /lib/permanent_records.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # PermanentRecords works with ActiveRecord to set deleted_at columns with a 4 | # timestamp reflecting when a record was 'deleted' instead of actually deleting 5 | # the record. All dependent records and associations are treated exactly as 6 | # you'd expect: If there's a deleted_at column then the record is preserved, 7 | # otherwise it's deleted. 8 | module PermanentRecords 9 | # This module defines the public api that you can 10 | # use in your model instances. 11 | # 12 | # * is_permanent? #=> true/false, depending if you have a deleted_at column 13 | # * deleted? #=> true/false, depending if you've called .destroy 14 | # * destroy #=> sets deleted_at, your record is now in 15 | # the .destroyed scope 16 | # * revive #=> undo the destroy 17 | module ActiveRecord # rubocop:disable Metrics/ModuleLength 18 | def self.included(base) 19 | base.extend Scopes 20 | base.extend IsPermanent 21 | 22 | base.instance_eval do 23 | define_model_callbacks :revive 24 | end 25 | end 26 | 27 | def is_permanent? # rubocop:disable Naming/PredicateName 28 | respond_to?(:deleted_at) 29 | end 30 | 31 | def deleted? 32 | if is_permanent? 33 | !!deleted_at 34 | else 35 | destroyed? 36 | end 37 | end 38 | 39 | def revive(options = nil) 40 | with_transaction_returning_status do 41 | if PermanentRecords.should_revive_parent_first?(options) 42 | revival.reverse 43 | else 44 | revival 45 | end.each { |p| p.call(options) } 46 | 47 | self 48 | end 49 | end 50 | 51 | def destroy(force = nil) 52 | with_transaction_returning_status do 53 | if !is_permanent? || PermanentRecords.should_force_destroy?(force) 54 | permanently_delete_records_after { super() } 55 | else 56 | destroy_with_permanent_records(force) 57 | end 58 | end 59 | end 60 | 61 | private 62 | 63 | def revival # rubocop:disable Metrics/MethodLength 64 | [ 65 | lambda do |validate| 66 | revive_destroyed_dependent_records(validate) 67 | end, 68 | lambda do |validate| 69 | run_callbacks(:revive) do 70 | set_deleted_at(nil, validate) 71 | # increment all associated counters for counter cache 72 | each_counter_cache do |assoc_class, counter_cache_column, assoc_id| 73 | assoc_class.increment_counter(counter_cache_column, assoc_id) 74 | end 75 | true 76 | end 77 | end 78 | ] 79 | end 80 | 81 | def get_deleted_record # rubocop:disable Naming/AccessorMethodName 82 | self.class.unscoped.find(id) 83 | end 84 | 85 | # rubocop:disable Metrics/MethodLength 86 | def set_deleted_at(value, force = nil) 87 | return self unless is_permanent? 88 | 89 | record = get_deleted_record 90 | record.deleted_at = value 91 | begin 92 | # we call save! instead of update_attribute so an 93 | # ActiveRecord::RecordInvalid error will be raised if the record isn't 94 | # valid. (This prevents reviving records that disregard validation 95 | # constraints,) 96 | if PermanentRecords.should_ignore_validations?(force) 97 | record.save(validate: false) 98 | else 99 | record.save! 100 | end 101 | 102 | @attributes = record.instance_variable_get(:@attributes) 103 | rescue StandardError => e 104 | # trigger dependent record destruction (they were revived before this 105 | # record, which cannot be revived due to validations) 106 | record.destroy 107 | raise e 108 | end 109 | end 110 | 111 | # rubocop:enable Metrics/MethodLength 112 | 113 | def each_counter_cache 114 | _reflections.each do |name, reflection| 115 | association = respond_to?(name.to_sym) ? send(name.to_sym) : nil 116 | next if association.nil? 117 | next unless reflection.belongs_to? && reflection.counter_cache_column 118 | 119 | associated_class = association.class 120 | 121 | yield(associated_class, reflection.counter_cache_column, send(reflection.foreign_key)) 122 | end 123 | end 124 | 125 | # rubocop:disable Metrics/MethodLength 126 | def destroy_with_permanent_records(force = nil) 127 | run_callbacks(:destroy) do 128 | if deleted? || new_record? 129 | save 130 | else 131 | set_deleted_at(Time.now, force) 132 | # decrement all associated counters for counter cache 133 | each_counter_cache do |assoc_class, counter_cache_column, assoc_id| 134 | assoc_class.decrement_counter(counter_cache_column, assoc_id) 135 | end 136 | end 137 | true 138 | end 139 | deleted? ? self : false 140 | end 141 | # rubocop:enable Metrics/MethodLength 142 | 143 | def add_record_window(_request, name, reflection) 144 | send(name).unscope(where: :deleted_at).where( 145 | [ 146 | "#{reflection.klass.quoted_table_name}.deleted_at > ? " \ 147 | 'AND ' \ 148 | "#{reflection.klass.quoted_table_name}.deleted_at < ?", 149 | deleted_at - PermanentRecords.dependent_record_window, 150 | deleted_at + PermanentRecords.dependent_record_window 151 | ] 152 | ) 153 | end 154 | 155 | # TODO: Feel free to refactor this without polluting the ActiveRecord namespace. 156 | def revive_destroyed_dependent_records(force = nil) 157 | destroyed_dependent_relations.each do |relation| 158 | relation.to_a.each { |destroyed_dependent_record| destroyed_dependent_record.try(:revive, force) } 159 | end 160 | reload 161 | end 162 | 163 | # rubocop:disable Metrics/MethodLength 164 | def destroyed_dependent_relations 165 | PermanentRecords.dependent_permanent_reflections(self.class).map do |name, relation| 166 | case relation.macro.to_sym 167 | when :has_many 168 | if deleted_at 169 | add_record_window(send(name), name, relation) 170 | else 171 | send(name).unscope(where: :deleted_at) 172 | end 173 | when :has_one, :belongs_to 174 | self.class.unscoped { Array(send(name)) } 175 | end 176 | end 177 | end 178 | # rubocop:enable Metrics/MethodLength 179 | 180 | def attempt_notifying_observers(callback) 181 | notify_observers(callback) 182 | rescue NoMethodError 183 | # do nothing: this model isn't being observed 184 | end 185 | 186 | # return the records corresponding to an association with the `:dependent 187 | # => :destroy` option 188 | def dependent_record_ids 189 | # check which dependent records are to be destroyed 190 | PermanentRecords.dependent_reflections(self.class) 191 | .reduce({}) do |records, (key, _)| 192 | found = Array(send(key)).compact 193 | next records if found.empty? 194 | 195 | records.update found.first.class => found.map(&:id) 196 | end 197 | end 198 | 199 | # If we force the destruction of the record, we will need to force the 200 | # destruction of dependent records if the user specified `:dependent => 201 | # :destroy` in the model. By default, the call to 202 | # super/destroy_with_permanent_records (i.e. the &block param) will only 203 | # soft delete the dependent records; we keep track of the dependent records 204 | # that have `:dependent => :destroy` and call destroy(force) on them after 205 | # the call to super 206 | def permanently_delete_records_after(&_block) 207 | dependent_records = dependent_record_ids 208 | result = yield 209 | permanently_delete_records(dependent_records) if result 210 | result 211 | end 212 | 213 | # permanently delete the records (i.e. remove from database) 214 | def permanently_delete_records(dependent_records) 215 | dependent_records.each do |klass, ids| 216 | ids.each do |id| 217 | record = klass.unscoped.where(klass.primary_key => id).first 218 | next unless record 219 | 220 | record.deleted_at = nil if record.respond_to?(:deleted_at) 221 | record.destroy(:force) 222 | end 223 | end 224 | end 225 | end 226 | 227 | # ActiveRelation scopes 228 | module Scopes 229 | def deleted 230 | where arel_table[:deleted_at].not_eq(nil) 231 | end 232 | 233 | def not_deleted 234 | where arel_table[:deleted_at].eq(nil) 235 | end 236 | end 237 | 238 | # Included into ActiveRecord for all models 239 | module IsPermanent 240 | def is_permanent? # rubocop:disable Naming/PredicateName 241 | columns.detect { |c| c.name == 'deleted_at' } 242 | end 243 | end 244 | 245 | def self.should_force_destroy?(force) 246 | if force.is_a?(Hash) 247 | force[:force] 248 | else 249 | force == :force 250 | end 251 | end 252 | 253 | def self.should_revive_parent_first?(order) 254 | order.is_a?(Hash) && order[:reverse] == true 255 | end 256 | 257 | def self.should_ignore_validations?(force) 258 | force.is_a?(Hash) && force[:validate] == false 259 | end 260 | 261 | def self.dependent_record_window 262 | @dependent_record_window || 3.seconds 263 | end 264 | 265 | def self.dependent_record_window=(time_value) 266 | @dependent_record_window = time_value 267 | end 268 | 269 | def self.dependent_reflections(klass) 270 | klass.reflections.select do |_, reflection| 271 | # skip if there are no dependent record instances 272 | reflection.options[:dependent] == :destroy 273 | end 274 | end 275 | 276 | def self.dependent_permanent_reflections(klass) 277 | dependent_reflections(klass).select do |_name, reflection| 278 | reflection.klass.is_permanent? 279 | end 280 | end 281 | end 282 | 283 | ActiveSupport.on_load(:active_record) do 284 | ActiveRecord::Base.include PermanentRecords::ActiveRecord 285 | require 'permanent_records/active_record' 286 | end 287 | -------------------------------------------------------------------------------- /lib/permanent_records/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Support destroy for rails belongs_to assocations. 4 | module HandlePermanentRecordsDestroyedInBelongsToAssociation 5 | def handle_dependency 6 | return unless load_target 7 | 8 | # only patch :destroy case and delegate to super otherwise 9 | case options[:dependent] 10 | when :destroy 11 | target.destroy 12 | raise ActiveRecord::Rollback if target.respond_to?(:deleted?) && !target.deleted? 13 | else 14 | super 15 | end 16 | end 17 | end 18 | 19 | # Support destroy for rails has_one associations. 20 | module HandlePermanentRecordsDestroyedInHasOneAssociation 21 | def delete(method = options[:dependent]) 22 | return unless load_target 23 | 24 | # only patch :destroy case and delegate to super otherwise 25 | case method 26 | when :destroy 27 | target.destroyed_by_association = reflection 28 | target.destroy 29 | throw(:abort) if target.respond_to?(:deleted?) && !target.deleted? 30 | else 31 | super(method) 32 | end 33 | end 34 | end 35 | ActiveRecord::Associations::BelongsToAssociation.prepend(HandlePermanentRecordsDestroyedInBelongsToAssociation) 36 | ActiveRecord::Associations::HasOneAssociation.prepend(HandlePermanentRecordsDestroyedInHasOneAssociation) 37 | -------------------------------------------------------------------------------- /permanent_records.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Gem::Specification.new do |s| 4 | s.name = 'permanent_records' 5 | s.version = File.read('VERSION') 6 | s.license = 'MIT' 7 | 8 | s.authors = ['Jack Danger Canty', 'David Sulc', 'Joe Nelson', 9 | 'Trond Arve Nordheim', 'Josh Teneycke', 'Maximilian Herold', 10 | 'Hugh Evans', 'Sergey Gnuskov', 'aq', 'Joel AZEMAR'] 11 | s.summary = 'Soft-delete your ActiveRecord records' 12 | s.description = <<-DESCRIPTION 13 | Never Lose Data. Rather than deleting rows this sets Record#deleted_at and 14 | gives you all the scopes you need to work with your data. 15 | DESCRIPTION 16 | s.email = 'github@jackcanty.com' 17 | s.extra_rdoc_files = %w[LICENSE README.md] 18 | s.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | s.homepage = 'https://github.com/JackDanger/permanent_records' 22 | s.require_paths = ['lib'] 23 | 24 | s.required_ruby_version = '>= 2.7.8' 25 | 26 | s.add_runtime_dependency 'activerecord', '>= 5.2' 27 | s.add_runtime_dependency 'activesupport', '>= 5.2' 28 | end 29 | -------------------------------------------------------------------------------- /spec/permanent_records/circular_sti_dependency_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:hole) { Hole.create } 7 | let(:dirt) { Dirt.create(hole: hole) } 8 | let(:location) { Location.create(name: 'location', hole: hole) } 9 | let!(:zone) do 10 | location.zones.create(name: 'zone', parent_id: location.id) 11 | end 12 | 13 | describe '#revive' do 14 | it 'revives children properly on STI' do 15 | expect { 16 | hole.destroy 17 | }.to change { 18 | hole.reload.deleted? 19 | }.to(true) & change { 20 | dirt.reload.deleted? 21 | }.to(true) & change { 22 | location.reload.deleted? 23 | }.to(true) & change { 24 | zone.reload.deleted? 25 | }.to(true) 26 | 27 | expect { 28 | hole.revive 29 | }.to change { 30 | hole.reload.deleted? 31 | }.to(false) & change { 32 | dirt.reload.deleted? 33 | }.to(false) & change { 34 | location.reload.deleted? 35 | }.to(false) & change { 36 | zone.reload.deleted? 37 | }.to(false) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/permanent_records/counter_cache_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:hole) { Hole.create } 7 | let(:dirt) { Dirt.create(hole: hole) } 8 | 9 | it 'have correct initial ants_count' do 10 | expect(hole.ants_count).to eq 0 11 | end 12 | 13 | describe 'polymorpic associations' do 14 | before do 15 | hole.poly_ants.create! 16 | hole.poly_ants.last.destroy! 17 | end 18 | 19 | it 'decrements counter_cache after destroying ant' do 20 | expect(hole.reload.ants_count).to eq(0) 21 | end 22 | 23 | context 'revive' do 24 | before do 25 | hole.poly_ants.deleted.first.revive 26 | end 27 | 28 | it 'increment counter_cache after reviving ant' do 29 | expect(hole.reload.ants_count).to eq(1) 30 | end 31 | end 32 | end 33 | 34 | describe 'counter cache' do 35 | before do 36 | hole.ants.create! 37 | end 38 | 39 | context 'increment' do 40 | before do 41 | hole.ants.create! 42 | end 43 | 44 | it 'increments counter_cache after creating new ant' do 45 | expect(hole.ants_count).to eq(2) 46 | end 47 | end 48 | 49 | context 'decrement' do 50 | before do 51 | hole.ants.last.destroy! 52 | end 53 | 54 | it 'decrements counter_cache after destroying ant' do 55 | expect(hole.reload.ants_count).to eq(0) 56 | end 57 | 58 | context 'revive' do 59 | before do 60 | hole.ants.deleted.first.revive 61 | end 62 | 63 | it 'increment counter_cache after reviving ant' do 64 | expect(hole.reload.ants_count).to eq(1) 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/permanent_records/propagate_validation_flag_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:hole) { Hole.create } 7 | let(:dirt) { Dirt.create(hole: hole) } 8 | 9 | before { hole.destroy } 10 | 11 | describe '#revive' do 12 | subject(:revive) { hole.revive(validate: false) } 13 | 14 | it 'propagates validation flag on dependent records' do 15 | allow(dirt).to receive(:get_deleted_record) { dirt } 16 | expect(dirt).to receive(:save).with(validate: false) 17 | revive 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /spec/permanent_records/revive_parent_first_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:hole) { Hole.create } 7 | let(:dirt) { Dirt.create(hole: hole) } 8 | let!(:ant) { hole.ants.create! } 9 | 10 | before { hole.destroy } 11 | 12 | describe '#revive' do 13 | subject(:revive) { hole.revive(reverse: true) } 14 | 15 | it 'revives parent first' do 16 | expect { revive }.not_to raise_error 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/permanent_records/validate_presence_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | describe PermanentRecords do 6 | let(:house) { House.create } 7 | let(:room) { house.rooms.create } 8 | 9 | describe '#revive' do 10 | context 'child validate presence of parent' do 11 | it 'when you revive parent first children are revived although the default scope' do 12 | expect { 13 | house.destroy 14 | }.to change { 15 | house.reload.deleted? 16 | }.to(true) & change { 17 | room.reload.deleted? 18 | }.to(true) 19 | 20 | expect { 21 | house.revive(reverse: true) 22 | }.to change { 23 | house.reload.deleted? 24 | }.to(false) & change { 25 | room.reload.deleted? 26 | }.to(false) 27 | end 28 | 29 | it 'it\'s impossible to revive children first when child validate presence of parent and have a default scope' do 30 | expect { 31 | house.destroy 32 | }.to change { 33 | house.reload.deleted? 34 | }.to(true) & change { 35 | room.reload.deleted? 36 | }.to(true) 37 | 38 | # Room Validation failed 39 | expect { house.revive }.to raise_error(ActiveRecord::RecordInvalid, "Validation failed: House can't be blank") 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/permanent_records_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable Performance/TimesMap 4 | require 'spec_helper' 5 | 6 | describe PermanentRecords do 7 | let!(:dirt) { Dirt.create! } 8 | let!(:earthworm) { dirt.create_earthworm } 9 | let!(:hole) { dirt.create_hole(options: {}) } 10 | let!(:muskrat) { hole.muskrats.create! } 11 | let!(:mole) { hole.moles.create! } 12 | let!(:location) { hole.create_location } 13 | let!(:difficulty) { hole.create_difficulty } 14 | let!(:comments) { 2.times.map { hole.comments.create! } } 15 | let!(:bed) { Bed.create! } 16 | let!(:kitty) { Kitty.create!(beds: [bed]) } 17 | let!(:meerkat) { Meerkat.create!(holes: [hole]) } 18 | 19 | describe '#destroy' do 20 | subject { record.destroy(should_force) } 21 | 22 | let(:record) { hole } 23 | let(:should_force) { false } 24 | 25 | it 'returns the record' do 26 | expect(subject).to eq(record) 27 | end 28 | 29 | it 'makes deleted? return true' do 30 | expect(subject).to be_deleted 31 | end 32 | 33 | it 'sets the deleted_at attribute' do 34 | expect(subject.deleted_at).to be_within(0.1).of(Time.now) 35 | end 36 | 37 | it 'does not really remove the record' do 38 | expect { subject }.not_to change(record.class, :count) 39 | end 40 | 41 | it 'handles serialized attributes correctly' do 42 | expect(subject.options).to eq({}) 43 | expect(subject.size).to be_nil if record.respond_to?(:size) 44 | end 45 | 46 | context 'with force argument set to truthy' do 47 | let(:should_force) { :force } 48 | 49 | it 'does really remove the record' do 50 | expect { subject }.to change { record.class.count }.by(-1) 51 | end 52 | end 53 | 54 | context 'with hash-style :force argument' do 55 | let(:should_force) { { force: true } } 56 | 57 | it 'does really remove the record' do 58 | expect { subject }.to change { record.class.count }.by(-1) 59 | end 60 | end 61 | 62 | context 'when validations fail' do 63 | before do 64 | allow_any_instance_of(Hole).to receive(:valid?).and_return(false) 65 | end 66 | 67 | it 'raises' do 68 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 69 | end 70 | 71 | context 'with validation opt-out' do 72 | let(:should_force) { { validate: false } } 73 | 74 | it 'doesnt raise' do 75 | expect { subject }.not_to raise_error 76 | end 77 | 78 | it 'soft-deletes the invalid record' do 79 | expect(subject).to be_deleted 80 | end 81 | end 82 | end 83 | 84 | context 'when before_destroy returns false' do 85 | before do 86 | record.youre_in_the_hole = true 87 | end 88 | 89 | it 'returns false' do 90 | expect(subject).to be(false) 91 | end 92 | 93 | it 'does not set deleted_at' do 94 | expect { subject }.not_to change(record, :deleted_at) 95 | end 96 | 97 | context 'and using the !' do 98 | it 'raises a ActiveRecord::RecordNotDestroyed exception' do 99 | expect do 100 | record.destroy! 101 | end.to raise_error(ActiveRecord::RecordNotDestroyed) 102 | end 103 | end 104 | end 105 | 106 | context 'with dependent records' do 107 | context 'that are permanent' do 108 | it { expect { subject }.not_to change(Muskrat, :count) } 109 | 110 | context 'with has_many cardinality' do 111 | it 'marks records as deleted' do 112 | expect(subject.muskrats).to all(be_deleted) 113 | end 114 | 115 | context 'when error occurs' do 116 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 117 | 118 | it 'does not mark records as deleted' do 119 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 120 | expect(record.muskrats.not_deleted.count).to eq(1) 121 | end 122 | end 123 | 124 | context 'with force delete' do 125 | let(:should_force) { :force } 126 | 127 | it { expect { subject }.to change(Muskrat, :count).by(-1) } 128 | it { expect { subject }.to change(Comment, :count).by(-2) } 129 | 130 | context 'when error occurs' do 131 | before do 132 | allow_any_instance_of(Difficulty).to receive(:destroy).and_raise(ActiveRecord::RecordNotDestroyed) 133 | end 134 | 135 | it { expect { subject }.not_to change(Muskrat, :count) } 136 | it { expect { subject }.not_to change(Comment, :count) } 137 | end 138 | end 139 | end 140 | 141 | context 'with has_one cardinality' do 142 | it 'marks records as deleted' do 143 | expect(subject.location).to be_deleted 144 | end 145 | 146 | context 'when error occurs' do 147 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 148 | 149 | it('does not mark records as deleted') do 150 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 151 | expect(record.reload.location).not_to be_deleted 152 | end 153 | end 154 | 155 | context 'with force delete' do 156 | let(:should_force) { :force } 157 | 158 | it { expect { subject }.to change(Muskrat, :count).by(-1) } 159 | it { expect { subject }.to change(Location, :count).by(-1) } 160 | 161 | context 'when error occurs' do 162 | before do 163 | allow_any_instance_of(Difficulty).to receive(:destroy).and_raise(ActiveRecord::RecordNotDestroyed) 164 | end 165 | 166 | it { expect { subject }.not_to change(Muskrat, :count) } 167 | it { expect { subject }.not_to change(Location, :count) } 168 | end 169 | end 170 | end 171 | 172 | context 'with belongs_to cardinality' do 173 | it 'marks records as deleted' do 174 | expect(subject.dirt).to be_deleted 175 | end 176 | 177 | context 'when error occurs' do 178 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 179 | 180 | it 'does not mark records as deleted' do 181 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 182 | expect(record.dirt).not_to be_deleted 183 | end 184 | end 185 | 186 | context 'with force delete' do 187 | let(:should_force) { :force } 188 | 189 | it { expect { subject }.to change(Dirt, :count).by(-1) } 190 | 191 | context 'when error occurs' do 192 | before do 193 | allow_any_instance_of(Difficulty).to receive(:destroy).and_raise(ActiveRecord::RecordNotDestroyed) 194 | end 195 | 196 | it { expect { subject }.not_to change(Dirt, :count) } 197 | end 198 | end 199 | end 200 | end 201 | 202 | context 'that are non-permanent' do 203 | it 'removes them' do 204 | expect { subject }.to change(Mole, :count).by(-1) 205 | end 206 | 207 | context 'with has many cardinality' do 208 | context 'when model has no deleted_at column' do 209 | let(:record) { kitty } 210 | 211 | it 'really removes the record' do 212 | expect { subject }.to change { record.class.count }.by(-1) 213 | end 214 | 215 | it 'really removes the associations' do 216 | expect { subject }.to change(Bed, :count).by(-1) 217 | end 218 | 219 | it 'makes deleted? return true' do 220 | expect(subject).to be_deleted 221 | end 222 | end 223 | end 224 | end 225 | 226 | context 'as default scope' do 227 | let(:load_comments) { Comment.unscoped.where(hole_id: subject.id) } 228 | 229 | context 'with :has_many cardinality' do 230 | it 'deletes them' do 231 | expect(load_comments.size).to eq(2) 232 | expect(load_comments).to be_all(&:deleted?) 233 | expect(subject.comments).to be_blank 234 | end 235 | end 236 | 237 | context 'with :has_one cardinality' do 238 | it 'deletes them' do 239 | expect(subject.difficulty).to be_deleted 240 | expect(Difficulty.find_by_id(subject.difficulty.id)).to be_nil 241 | end 242 | end 243 | end 244 | end 245 | 246 | context 'with habtm association' do 247 | it 'does not remove the associated records' do 248 | expect { subject }.not_to change(Muskrat, :count) 249 | end 250 | 251 | it 'does not remove the entry from the join table' do 252 | expect { subject }.not_to change(meerkat.holes, :count) 253 | end 254 | 255 | context 'with force argument set to truthy' do 256 | let(:should_force) { :force } 257 | 258 | it 'does not remove the associated records' do 259 | expect { subject }.not_to change(Meerkat, :count) 260 | end 261 | 262 | it 'removes the entry from the join table' do 263 | expect { subject }.to change { meerkat.holes.count }.by(-1) 264 | end 265 | end 266 | end 267 | end 268 | 269 | describe '#revive' do 270 | subject { record.revive should_validate } 271 | 272 | let!(:record) { hole.tap(&:destroy) } 273 | let(:should_validate) { nil } 274 | 275 | it 'returns the record' do 276 | expect(subject).to eq(record) 277 | end 278 | 279 | it 'unsets deleted_at' do 280 | expect { subject }.to change(record, :deleted_at).to(nil) 281 | end 282 | 283 | it 'makes deleted? return false' do 284 | expect(subject).not_to be_deleted 285 | end 286 | 287 | context 'when validations fail' do 288 | before do 289 | allow_any_instance_of(Hole).to receive(:valid?).and_return(false) 290 | end 291 | 292 | it 'raises' do 293 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 294 | end 295 | 296 | context 'with validation opt-out' do 297 | let(:should_validate) { { validate: false } } 298 | 299 | it 'doesnt raise' do 300 | expect { subject }.not_to raise_error 301 | end 302 | 303 | it 'makes deleted? return false' do 304 | expect(subject).not_to be_deleted 305 | end 306 | end 307 | end 308 | 309 | context 'with dependent records' do 310 | context 'that are permanent' do 311 | it { expect { subject }.not_to change(Muskrat, :count) } 312 | 313 | context 'that were deleted previously' do 314 | before { muskrat.update_attribute :deleted_at, 2.minutes.ago } 315 | 316 | it 'does not restore' do 317 | expect { subject }.not_to change(muskrat, :deleted?) 318 | end 319 | end 320 | 321 | context 'with has_many cardinality' do 322 | it 'revives them' do 323 | subject.muskrats.each { |m| expect(m).not_to be_deleted } 324 | end 325 | 326 | context 'when error occurs' do 327 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 328 | 329 | it 'does not revive them' do 330 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 331 | expect(record.muskrats.deleted.count).to eq(1) 332 | end 333 | end 334 | end 335 | 336 | context 'with has_one cardinality' do 337 | it 'revives them' do 338 | expect(subject.location).not_to be_deleted 339 | end 340 | 341 | context 'when error occurs' do 342 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 343 | 344 | it('does not mark records as deleted') do 345 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 346 | expect(record.location).to be_deleted 347 | end 348 | end 349 | end 350 | 351 | context 'with belongs_to cardinality' do 352 | it 'revives them' do 353 | expect(subject.dirt).not_to be_deleted 354 | end 355 | 356 | context 'when error occurs' do 357 | before { allow_any_instance_of(Hole).to receive(:valid?).and_return(false) } 358 | 359 | it 'does not revive them' do 360 | expect { subject }.to raise_error(ActiveRecord::RecordInvalid) 361 | expect(record.dirt).to be_deleted 362 | end 363 | end 364 | end 365 | end 366 | 367 | context 'that are non-permanent' do 368 | it 'cannot revive them' do 369 | expect { subject }.not_to change(Mole, :count) 370 | end 371 | end 372 | 373 | context 'as default scope' do 374 | context 'with :has_many cardinality' do 375 | describe '#comments' do 376 | subject { super().comments } 377 | 378 | describe '#size' do 379 | subject { super().size } 380 | 381 | it { is_expected.to eq(2) } 382 | end 383 | end 384 | 385 | it 'revives them' do 386 | subject.comments.each { |c| 387 | expect(c).not_to be_deleted 388 | expect(Comment.find_by_id(c.id)).to eq(c) 389 | } 390 | end 391 | end 392 | 393 | context 'with :has_one cardinality' do 394 | it 'revives them' do 395 | expect(subject.difficulty).not_to be_deleted 396 | expect(Difficulty.find_by_id(subject.difficulty.id)).to eq(difficulty) 397 | end 398 | end 399 | end 400 | end 401 | 402 | context 'with habtm association' do 403 | it 'does not change entries from the join table' do 404 | expect { subject }.not_to change(meerkat.holes, :count) 405 | end 406 | end 407 | end 408 | 409 | describe 'scopes' do 410 | before do 411 | 3.times { Muskrat.create!(hole: hole) } 412 | 6.times { Muskrat.create!(hole: hole).destroy } 413 | end 414 | 415 | describe '.not_deleted' do 416 | it 'counts' do 417 | expect(Muskrat.not_deleted.count).to eq(Muskrat.all.count { |element| !element.deleted? }) 418 | end 419 | 420 | it 'has no deleted records' do 421 | Muskrat.not_deleted.each { |m| expect(m).not_to be_deleted } 422 | end 423 | end 424 | 425 | describe '.deleted' do 426 | it 'counts' do 427 | expect(Muskrat.deleted.count).to eq(Muskrat.all.count(&:deleted?)) 428 | end 429 | 430 | it 'has no non-deleted records' do 431 | expect(Muskrat.deleted).to all(be_deleted) 432 | end 433 | end 434 | end 435 | end 436 | # rubocop:enable Performance/TimesMap 437 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # rubocop:disable 4 | # Include this file in your test by copying the following line to your test: 5 | # require File.expand_path(File.dirname(__FILE__) + "/test_helper") 6 | 7 | require 'pathname' 8 | lib = Pathname.new( 9 | File.expand_path('../../lib', File.dirname(__FILE__)) 10 | ) 11 | support = Pathname.new( 12 | File.expand_path('../spec/support', File.dirname(__FILE__)) 13 | ) 14 | $LOAD_PATH.unshift lib 15 | $LOAD_PATH.unshift support 16 | RAILS_ROOT = File.dirname(__FILE__) 17 | 18 | require 'active_record' 19 | require 'active_support' 20 | require 'permanent_records' 21 | 22 | module Rails 23 | def self.env 24 | 'test' 25 | end 26 | end 27 | 28 | I18n.config.enforce_available_locales = true if I18n.config.respond_to?(:enforce_available_locales) 29 | 30 | require 'logger' 31 | ActiveRecord::Base.logger = Logger.new support.join('debug.log') 32 | ActiveRecord::Base.configurations = YAML.load_file support.join('database.yml') 33 | ActiveRecord::Base.establish_connection 34 | 35 | load 'schema.rb' if File.exist?(support.join('schema.rb')) 36 | 37 | Dir.glob(support.join('*.rb')).sort.each do |file| 38 | autoload File.basename(file).chomp('.rb').camelcase.intern, file 39 | require file 40 | end 41 | 42 | require 'database_cleaner' 43 | 44 | RSpec.configure do |config| 45 | config.before(:suite) do 46 | DatabaseCleaner.strategy = :transaction 47 | DatabaseCleaner.clean_with(:truncation) 48 | end 49 | 50 | config.before do 51 | DatabaseCleaner.start 52 | end 53 | 54 | config.after do 55 | DatabaseCleaner.clean 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/support/ant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Ant < ActiveRecord::Base 4 | belongs_to :hole, counter_cache: true 5 | belongs_to :any_hole, polymorphic: true, counter_cache: true 6 | 7 | validates :hole, presence: true, unless: :any_hole 8 | 9 | def add_ant(ant) 10 | # do something like you want 11 | 12 | # Force reload 13 | Hole.not_deleted.where(id: hole.id).first.add_to_ants_cache ant 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/bed.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Bed < ActiveRecord::Base 4 | has_one :kitty 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/comment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Comment < ActiveRecord::Base 4 | belongs_to :hole 5 | validates :hole, presence: true 6 | default_scope { where(deleted_at: nil) } 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | database: ":memory:" 4 | min_messages: ERROR 5 | # sqlite: 6 | # adapter: sqlite 7 | # database: plugin.sqlite.db 8 | # sqlite3: 9 | # adapter: sqlite3 10 | # database: ":memory:" 11 | # mysql: 12 | # adapter: mysql 13 | # host: localhost 14 | # username: rails 15 | # password: 16 | # database: plugin_test 17 | -------------------------------------------------------------------------------- /spec/support/difficulty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Difficulty < ActiveRecord::Base 4 | belongs_to :hole 5 | 6 | default_scope { where(deleted_at: nil) } 7 | 8 | validates :hole, presence: true 9 | end 10 | -------------------------------------------------------------------------------- /spec/support/dirt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Dirt < ActiveRecord::Base 4 | has_one :hole 5 | # validates :hole, presence: true 6 | has_one :earthworm, dependent: :destroy 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/earthworm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Earthworm < ActiveRecord::Base 4 | belongs_to :dirt 5 | validates :dirt, presence: true 6 | # Earthworms have been known to complain if they're left on their deathbeds 7 | # without any dirt 8 | before_destroy :complain! 9 | 10 | def complain! 11 | raise "Where's my dirt?!" if Dirt.not_deleted.find(dirt_id).nil? 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/hole.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Hole < ActiveRecord::Base 4 | # Because when we're destroying a mole hole we're obviously using high 5 | # explosives. 6 | belongs_to :dirt, dependent: :destroy 7 | 8 | # muskrats are permanent 9 | has_many :muskrats, dependent: :destroy 10 | # moles are not permanent 11 | has_many :moles, dependent: :destroy 12 | 13 | has_many :ants, dependent: :destroy 14 | has_one :location, dependent: :destroy 15 | has_one :unused_model, dependent: :destroy 16 | has_one :difficulty, dependent: :destroy 17 | has_many :comments, dependent: :destroy 18 | has_and_belongs_to_many :meerkats 19 | 20 | has_many :poly_ants, class_name: 'Ant', dependent: :destroy, as: :any_hole 21 | 22 | serialize :options, Hash 23 | store :properties, accessors: [:size] if respond_to?(:store) 24 | 25 | attr_accessor :youre_in_the_hole 26 | 27 | before_destroy :check_youre_not_in_the_hole 28 | 29 | private 30 | 31 | def check_youre_not_in_the_hole 32 | throw(:abort) if youre_in_the_hole 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/support/house.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class House < ActiveRecord::Base 4 | has_many :rooms, dependent: :destroy 5 | validates_associated :rooms 6 | 7 | default_scope -> { where(deleted_at: nil) } 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/kitty.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Kitty < ActiveRecord::Base 4 | has_and_belongs_to_many :beds, dependent: :destroy 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/location.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Location < ActiveRecord::Base 4 | belongs_to :hole 5 | validates :hole, presence: true, unless: :zone? 6 | validates_uniqueness_of :name, scope: :deleted_at 7 | has_many :zones, 8 | class_name: 'Location', 9 | foreign_key: 'parent_id', 10 | dependent: :destroy 11 | 12 | private 13 | 14 | def zone? 15 | parent_id.present? 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/meerkat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Meerkat < ActiveRecord::Base 4 | has_and_belongs_to_many :holes 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/mole.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Mole < ActiveRecord::Base 4 | belongs_to :hole 5 | validates :hole, presence: true 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/muskrat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Muskrat < ActiveRecord::Base 4 | belongs_to :hole 5 | validates :hole, presence: true 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/room.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Room < ActiveRecord::Base 4 | belongs_to :house 5 | validates :house, presence: true 6 | 7 | default_scope -> { where(deleted_at: nil) } 8 | end 9 | -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define(version: 1) do 4 | create_table :ants, force: true do |t| 5 | t.column :name, :string 6 | t.column :deleted_at, :datetime 7 | t.references :hole 8 | t.integer :any_hole_id 9 | t.string :any_hole_type 10 | end 11 | 12 | create_table :muskrats, force: true do |t| 13 | t.column :name, :string 14 | t.column :deleted_at, :datetime 15 | t.references :hole 16 | end 17 | 18 | create_table :beds, force: true do |t| 19 | t.column :name, :string 20 | end 21 | 22 | create_table :kitties, force: true do |t| 23 | t.column :name, :string 24 | t.references :bed 25 | end 26 | 27 | create_table :beds_kitties, force: true do |t| 28 | t.references :kitty 29 | t.references :bed 30 | end 31 | 32 | create_table :holes, force: true do |t| 33 | t.integer :number 34 | t.text :options 35 | t.text :properties 36 | t.references :dirt 37 | t.integer :ants_count, default: 0 38 | t.datetime :deleted_at 39 | end 40 | 41 | create_table :moles, force: true do |t| 42 | t.string :name 43 | t.references :hole 44 | end 45 | 46 | create_table :locations, force: true do |t| 47 | t.string :name 48 | t.references :hole 49 | t.integer :parent_id 50 | t.datetime :deleted_at 51 | end 52 | 53 | create_table :comments, force: true do |t| 54 | t.string :text 55 | t.references :hole 56 | t.datetime :deleted_at 57 | end 58 | 59 | create_table :difficulties, force: true do |t| 60 | t.string :name 61 | t.references :hole 62 | t.datetime :deleted_at 63 | end 64 | 65 | create_table :unused_models, force: true do |t| 66 | t.string :name 67 | t.references :hole 68 | t.datetime :deleted_at 69 | end 70 | 71 | create_table :holes_meerkats, force: true do |t| 72 | t.references :hole 73 | t.references :meerkat 74 | end 75 | 76 | create_table :meerkats, force: true do |t| 77 | t.string :name 78 | end 79 | 80 | create_table :dirts, force: true do |t| 81 | t.string :color 82 | t.datetime :deleted_at 83 | end 84 | 85 | create_table :earthworms, force: true do |t| 86 | t.references :dirt 87 | end 88 | 89 | create_table :houses, force: true do |t| 90 | t.datetime :deleted_at 91 | end 92 | 93 | create_table :rooms, force: true do |t| 94 | t.references :house 95 | t.datetime :deleted_at 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /spec/support/unused_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class UnusedModel < ActiveRecord::Base 4 | end 5 | --------------------------------------------------------------------------------