├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-proposal.md ├── dependabot.yml └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── .yardopts ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── bin ├── console └── setup ├── discard.gemspec ├── lib ├── discard.rb └── discard │ ├── errors.rb │ ├── model.rb │ └── version.rb └── spec ├── discard └── model_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Is Discard not working correctly for you? Let us know! 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Additional context** 24 | Please tells us your Rails and Ruby versions, and anything else that might be helpful about the environment you encountered the issue. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-proposal.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Proposal 3 | about: Discard is feature-complete, but we're happy to hear you out! 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Discard is feature-complete, but if you have an idea for a feature that will benefit all discard users and that won't offer a significant maintenance burden, we're happy to listen. Explain it as best you can and we'll let you know if it's something we'd like to have or if it's something that might be a better off as an extension or fork.** 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | name: Test on Rails ${{ matrix.rails_version }} and Ruby ${{ matrix.ruby_version }} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | include: 19 | - rails_version: 8.0.0.rc1 20 | ruby_version: '3.3' 21 | sqlite_version: ~> 2.0 22 | - rails_version: ~> 7.2.0 23 | ruby_version: '3.3' 24 | sqlite_version: ~> 2.0 25 | - rails_version: ~> 7.2.0 26 | ruby_version: '3.2' 27 | sqlite_version: ~> 2.0 28 | - rails_version: ~> 7.2.0 29 | ruby_version: '3.1' 30 | sqlite_version: ~> 2.0 31 | - rails_version: ~> 7.1.0 32 | ruby_version: '3.3' 33 | sqlite_version: ~> 1.0 34 | - rails_version: ~> 7.0.0 35 | ruby_version: '3.2' 36 | sqlite_version: ~> 1.0 37 | - rails_version: ~> 6.1.0 38 | ruby_version: '3.0' 39 | sqlite_version: ~> 1.0 40 | env: 41 | RAILS_VERSION: ${{ matrix.rails_version }} 42 | SQLITE_VERSION: ${{ matrix.sqlite_version }} 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: ruby/setup-ruby@v1 46 | with: 47 | ruby-version: ${{ matrix.ruby_version }} 48 | - name: Bundle install 49 | run: bundle install 50 | - name: Test 51 | run: bundle exec rake 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | /spec/examples.txt 11 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --protected 2 | --no-private 3 | --embed-mixin ClassMethods 4 | - 5 | README.md 6 | CHANGELOG.md 7 | CODE_OF_CONDUCT.md 8 | LICENSE.txt 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### Unreleased 2 | 3 | ### Version 1.4.0 4 | Release date: 2024-11-05 5 | 6 | * Support Rails 8.0 and 8.1 (#110, #111) 7 | 8 | * More descriptive error messages (#108) 9 | 10 | ### Version 1.3.0 11 | Release date: 2023-08-17 12 | 13 | * Fix `undiscard` so it returns false instead of nil when the record isn't 14 | discarded (#95, #96) 15 | 16 | ### Version 1.2.1 17 | Release date: 2021-12-16 18 | 19 | * Support for ActiveRecord 7 20 | 21 | ### Version 1.2.0 22 | Release date: 2020-02-17 23 | 24 | * Add `discard_all!` and `undiscard_all!` 25 | * Add `undiscarded?` and `kept?` to match the scopes of the same names 26 | 27 | ### Version 1.1.0 28 | Release date: 2019-05-03 29 | 30 | * Support for ActiveRecord 6 31 | * `discard_all` and `undiscard_all` now return affected records 32 | * Add `discard!` and `undiscard!` 33 | 34 | ### Version 1.0.0 35 | Release date: 2018-03-16 36 | 37 | * Add undiscard callbacks and `.undiscard_all` 38 | 39 | ### Version 0.2.0 40 | Release date: 2017-11-22 41 | 42 | * Add `.discard_all` 43 | * Add `undiscarded` scope 44 | * Add callbacks 45 | 46 | ### Version 0.1.0 47 | Release date: 2017-04-28 48 | 49 | * Initial version! 50 | -------------------------------------------------------------------------------- /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 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at john.hawthorn@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | rails_version = ENV['RAILS_VERSION'] 4 | gem 'activerecord', rails_version 5 | 6 | if sqlite_version = ENV['SQLITE_VERSION'] 7 | gem 'sqlite3', sqlite_version 8 | end 9 | 10 | # Specify your gem's dependencies in discard.gemspec 11 | gemspec 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 John Hawthorn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Discard [![Test](https://github.com/jhawthorn/discard/actions/workflows/test.yml/badge.svg)](https://github.com/jhawthorn/discard/actions/workflows/test.yml) 2 | 3 | Soft deletes for ActiveRecord done right. 4 | 5 | 6 | 7 | ## What does this do? 8 | 9 | A simple ActiveRecord mixin to add conventions for flagging records as discarded. 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | ```ruby 16 | gem 'discard', '~> 1.4' 17 | ``` 18 | 19 | And then execute: 20 | 21 | $ bundle 22 | 23 | ## Usage 24 | 25 | **Declare a record as discardable** 26 | 27 | Declare the record as being discardable 28 | 29 | ``` ruby 30 | class Post < ActiveRecord::Base 31 | include Discard::Model 32 | end 33 | ``` 34 | 35 | You can either generate a migration using: 36 | ``` 37 | rails generate migration add_discarded_at_to_posts discarded_at:datetime:index 38 | ``` 39 | 40 | or create one yourself like the one below: 41 | ``` ruby 42 | class AddDiscardToPosts < ActiveRecord::Migration[5.0] 43 | def change 44 | add_column :posts, :discarded_at, :datetime 45 | add_index :posts, :discarded_at 46 | end 47 | end 48 | ``` 49 | 50 | 51 | #### Discard a record 52 | 53 | ```ruby 54 | Post.all # => [#] 55 | Post.kept # => [#] 56 | Post.discarded # => [] 57 | 58 | post = Post.first # => # 59 | post.discard # => true 60 | post.discard! # => Discard::RecordNotDiscarded: Failed to discard the record 61 | post.discarded? # => true 62 | post.undiscarded? # => false 63 | post.kept? # => false 64 | post.discarded_at # => 2017-04-18 18:49:49 -0700 65 | 66 | Post.all # => [#] 67 | Post.kept # => [] 68 | Post.discarded # => [#] 69 | ``` 70 | 71 | ***From a controller*** 72 | 73 | Controller actions need a small modification to discard records instead of deleting them. Just replace `destroy` with `discard`. 74 | 75 | ``` ruby 76 | def destroy 77 | @post.discard 78 | redirect_to users_url, notice: "Post removed" 79 | end 80 | ``` 81 | 82 | 83 | #### Undiscard a record 84 | 85 | ```ruby 86 | post = Post.first # => # 87 | post.undiscard # => true 88 | post.undiscard! # => Discard::RecordNotUndiscarded: Failed to undiscard the record 89 | post.discarded_at # => nil 90 | ``` 91 | 92 | ***From a controller*** 93 | 94 | ```ruby 95 | def update 96 | @post.undiscard 97 | redirect_to users_url, notice: "Post undiscarded" 98 | end 99 | ``` 100 | 101 | #### Working with associations 102 | 103 | Under paranoia, soft deleting a record will destroy any `dependent: :destroy` 104 | associations. Probably not what you want! This leads to all dependent records 105 | also needing to be `acts_as_paranoid`, which makes restoring awkward: paranoia 106 | handles this by restoring any records which have their deleted_at set to a 107 | similar timestamp. Also, it doesn't always make sense to mark these records as 108 | deleted, it depends on the application. 109 | 110 | A better approach is to simply mark the one record as discarded, and use SQL 111 | joins to restrict finding these if that's desired. 112 | 113 | For example, in a blog comment system, with `Post`s and `Comment`s, you might 114 | want to discard the records independently. A user's comment history could 115 | include comments on deleted posts. 116 | 117 | ``` ruby 118 | Post.kept # SELECT * FROM posts WHERE discarded_at IS NULL 119 | Comment.kept # SELECT * FROM comments WHERE discarded_at IS NULL 120 | ``` 121 | 122 | Or you could decide that comments are dependent on their posts not being 123 | discarded. Just override the `kept` scope on the Comment model. 124 | 125 | ``` ruby 126 | class Comment < ActiveRecord::Base 127 | belongs_to :post 128 | 129 | include Discard::Model 130 | scope :kept, -> { undiscarded.joins(:post).merge(Post.kept) } 131 | 132 | def kept? 133 | undiscarded? && post.kept? 134 | end 135 | end 136 | 137 | Comment.kept 138 | # SELECT * FROM comments 139 | # INNER JOIN posts ON comments.post_id = posts.id 140 | # WHERE 141 | # comments.discarded_at IS NULL AND 142 | # posts.discarded_at IS NULL 143 | ``` 144 | 145 | SQL databases are very good at this, and performance should not be an issue. 146 | 147 | In both of these cases restoring either of these records will do right thing! 148 | 149 | 150 | #### Default scope 151 | 152 | It's usually undesirable to add a default scope. It will take more effort to 153 | work around and will cause more headaches. If you know you need a default scope, it's easy to add yourself ❤. 154 | 155 | ``` ruby 156 | class Post < ActiveRecord::Base 157 | include Discard::Model 158 | default_scope -> { kept } 159 | end 160 | 161 | Post.all # Only kept posts 162 | Post.with_discarded # All Posts 163 | Post.with_discarded.discarded # Only discarded posts 164 | ``` 165 | 166 | #### Custom column 167 | 168 | If you're migrating from paranoia, you might want to continue using the same 169 | column. 170 | 171 | ``` ruby 172 | class Post < ActiveRecord::Base 173 | include Discard::Model 174 | self.discard_column = :deleted_at 175 | end 176 | ``` 177 | 178 | #### Callbacks 179 | 180 | Callbacks can be run before, after, or around the discard and undiscard operations. 181 | A likely use is discarding or deleting associated records (but see "Working with associations" for an alternative). 182 | 183 | ``` ruby 184 | class Comment < ActiveRecord::Base 185 | include Discard::Model 186 | end 187 | 188 | class Post < ActiveRecord::Base 189 | include Discard::Model 190 | 191 | has_many :comments 192 | 193 | after_discard do 194 | comments.discard_all 195 | end 196 | 197 | after_undiscard do 198 | comments.undiscard_all 199 | end 200 | end 201 | ``` 202 | 203 | *Warning:* Please note that callbacks for save and update are run when discarding/undiscarding a record 204 | 205 | 206 | #### Performance tuning 207 | `discard_all` and `undiscard_all` is intended to behave like `destroy_all` which has callbacks, validations, and does one query per record. If performance is a big concern, you may consider replacing it with: 208 | 209 | `scope.update_all(discarded_at: Time.current)` 210 | or 211 | `scope.update_all(discarded_at: nil)` 212 | 213 | #### Working with Devise 214 | 215 | A common use case is to apply discard to a User record. Even though a user has been discarded they can still login and continue their session. 216 | If you are using Devise and wish for discarded users to be unable to login and stop their session you can override Devise's method. 217 | 218 | ```ruby 219 | class User < ActiveRecord::Base 220 | def active_for_authentication? 221 | super && !discarded? 222 | end 223 | end 224 | ``` 225 | 226 | ## Non-features 227 | 228 | * Special handling of AR counter cache columns - The counter cache counts the total number of records, both kept and discarded. 229 | * Recursive discards (like AR's dependent: destroy) - This can be avoided using queries (See "Working with associations") or emulated using callbacks. 230 | * Recursive restores - This concept is fundamentally broken, but not necessary if the recursive discards are avoided. 231 | 232 | ## Extensions 233 | 234 | Discard provides the smallest subset of soft-deletion features that we think are useful to all users of the gem. We welcome the addition of gems that work with Discard to provide additional features. 235 | 236 | - [discard-rails-observers](https://github.com/pelargir/discard-rails-observers) integrates discard with the [rails-observers gem](https://github.com/rails/rails-observers) 237 | 238 | ## Why not paranoia or acts_as_paranoid? 239 | 240 | I've worked with and have helped maintain 241 | [paranoia](https://github.com/rubysherpas/paranoia) for a while. I'm convinced 242 | it does the wrong thing for most cases. 243 | 244 | Paranoia and 245 | [acts_as_paranoid](https://github.com/ActsAsParanoid/acts_as_paranoid) both 246 | attempt to emulate deletes by setting a column and adding a default scope on the 247 | model. This requires some ActiveRecord hackery, and leads to some surprising 248 | and awkward behaviour. 249 | 250 | * A default scope is added to hide soft-deleted records, which necessitates 251 | adding `.with_deleted` to associations or anywhere soft-deleted records 252 | should be found. :disappointed: 253 | * Adding `belongs_to :child, -> { with_deleted }` helps, but doesn't work for 254 | joins and eager-loading [before Rails 5.2](https://github.com/rubysherpas/paranoia/issues/355) 255 | * `delete` is overridden (`really_delete` will actually delete the record) :unamused: 256 | * `destroy` is overridden (`really_destroy` will actually delete the record) :pensive: 257 | * `dependent: :destroy` associations are deleted when performing soft-destroys :scream: 258 | * requiring any dependent records to also be `acts_as_paranoid` to avoid losing data. :grimacing: 259 | 260 | There are some use cases where these behaviours make sense: if you really did 261 | want to _almost_ delete the record. More often developers are just looking to 262 | hide some records, or mark them as inactive. 263 | 264 | Discard takes a different approach. It doesn't override any ActiveRecord 265 | methods and instead simply provides convenience methods and scopes for 266 | discarding (hiding), restoring, and querying records. 267 | 268 | You can find more information about the history and purpose of Discard in [this blog post](https://supergood.software/introduction-to-discard/). 269 | 270 | ## Development 271 | 272 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 273 | 274 | ## Contributing 275 | 276 | Please consider filing an issue with the details of any features you'd like to see before implementing them. Discard is feature-complete and we are only interested in adding additional features that won't require substantial maintenance burden and that will benefit all users of the gem. We encourage anyone that needs additional or different behaviour to either create their own gem that builds off of discard or implement a new package with the different behaviour. 277 | 278 | Discard is very simple and we like it that way. Creating your own clone or fork with slightly different behaviour may not be that much work! 279 | 280 | If you find a bug in discard, please report it! We try to keep up with any issues and keep the gem running smoothly for everyone! You can report issues [here](https://github.com/jhawthorn/discard/issues). 281 | 282 | ## License 283 | 284 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 285 | 286 | ## Acknowledgments 287 | 288 | * [Ben Morgan](https://github.com/BenMorganIO) who has done a great job maintaining paranoia 289 | * [Ryan Bigg](http://github.com/radar), the original author of paranoia (and many things), as a simpler replacement of acts_as_paranoid 290 | * All paranoia users and contributors 291 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'bundler/gem_tasks' 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:rspec) 6 | 7 | desc 'Run the test suite' 8 | task default: :rspec 9 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "discard" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /discard.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('../lib', __FILE__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'discard/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "discard" 9 | spec.version = Discard::VERSION 10 | spec.authors = ["John Hawthorn"] 11 | spec.email = ["john.hawthorn@gmail.com"] 12 | 13 | spec.summary = %q{ActiveRecord soft-deletes done right} 14 | spec.description = %q{Allows marking ActiveRecord objects as discarded, and provides scopes for filtering.} 15 | spec.homepage = "https://github.com/jhawthorn/discard" 16 | spec.license = "MIT" 17 | 18 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 19 | f.match(%r{^(test|spec|features)/}) 20 | end 21 | spec.bindir = "exe" 22 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 23 | spec.require_paths = ["lib"] 24 | 25 | spec.add_dependency "activerecord", ">= 4.2", "< 9.0" 26 | spec.add_development_dependency "bundler" 27 | spec.add_development_dependency "rake", ">= 10.0" 28 | spec.add_development_dependency "rspec", "~> 3.5.0" 29 | spec.add_development_dependency "database_cleaner", "~> 1.5" 30 | spec.add_development_dependency "with_model", "~> 2.0" 31 | spec.add_development_dependency "sqlite3" 32 | end 33 | -------------------------------------------------------------------------------- /lib/discard.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_record" 4 | 5 | require "discard/version" 6 | require "discard/errors" 7 | require "discard/model" 8 | -------------------------------------------------------------------------------- /lib/discard/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discard 4 | # = Discard Errors 5 | # 6 | # Generic exception class. 7 | class DiscardError < StandardError 8 | end 9 | 10 | # Raised by {Discard::Model#discard!} 11 | class RecordNotDiscarded < DiscardError 12 | attr_reader :record 13 | 14 | def initialize(message = nil, record = nil) 15 | @record = record 16 | super(message) 17 | end 18 | end 19 | 20 | # Raised by {Discard::Model#undiscard!} 21 | class RecordNotUndiscarded < DiscardError 22 | attr_reader :record 23 | 24 | def initialize(message = nil, record = nil) 25 | @record = record 26 | super(message) 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/discard/model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discard 4 | # Handles soft deletes of records. 5 | # 6 | # Options: 7 | # 8 | # - :discard_column - The columns used to track soft delete, defaults to `:discarded_at`. 9 | module Model 10 | extend ActiveSupport::Concern 11 | 12 | included do 13 | class_attribute :discard_column 14 | self.discard_column = :discarded_at 15 | 16 | scope :kept, ->{ undiscarded } 17 | scope :undiscarded, ->{ where(discard_column => nil) } 18 | scope :discarded, ->{ where.not(discard_column => nil) } 19 | scope :with_discarded, ->{ unscope(where: discard_column) } 20 | 21 | define_model_callbacks :discard 22 | define_model_callbacks :undiscard 23 | end 24 | 25 | # :nodoc: 26 | module ClassMethods 27 | # Discards the records by instantiating each 28 | # record and calling its {#discard} method. 29 | # Each object's callbacks are executed. 30 | # Returns the collection of objects that were discarded. 31 | # 32 | # Note: Instantiation, callback execution, and update of each 33 | # record can be time consuming when you're discarding many records at 34 | # once. It generates at least one SQL +UPDATE+ query per record (or 35 | # possibly more, to enforce your callbacks). If you want to discard many 36 | # rows quickly, without concern for their associations or callbacks, use 37 | # #update_all(discarded_at: Time.current) instead. 38 | # 39 | # ==== Examples 40 | # 41 | # Person.where(age: 0..18).discard_all 42 | def discard_all 43 | kept.each(&:discard) 44 | end 45 | 46 | # Discards the records by instantiating each 47 | # record and calling its {#discard!} method. 48 | # Each object's callbacks are executed. 49 | # Returns the collection of objects that were discarded. 50 | # 51 | # Note: Instantiation, callback execution, and update of each 52 | # record can be time consuming when you're discarding many records at 53 | # once. It generates at least one SQL +UPDATE+ query per record (or 54 | # possibly more, to enforce your callbacks). If you want to discard many 55 | # rows quickly, without concern for their associations or callbacks, use 56 | # #update_all!(discarded_at: Time.current) instead. 57 | # 58 | # ==== Examples 59 | # 60 | # Person.where(age: 0..18).discard_all! 61 | def discard_all! 62 | kept.each(&:discard!) 63 | end 64 | 65 | # Undiscards the records by instantiating each 66 | # record and calling its {#undiscard} method. 67 | # Each object's callbacks are executed. 68 | # Returns the collection of objects that were undiscarded. 69 | # 70 | # Note: Instantiation, callback execution, and update of each 71 | # record can be time consuming when you're undiscarding many records at 72 | # once. It generates at least one SQL +UPDATE+ query per record (or 73 | # possibly more, to enforce your callbacks). If you want to undiscard many 74 | # rows quickly, without concern for their associations or callbacks, use 75 | # #update_all(discarded_at: nil) instead. 76 | # 77 | # ==== Examples 78 | # 79 | # Person.where(age: 0..18).undiscard_all 80 | def undiscard_all 81 | discarded.each(&:undiscard) 82 | end 83 | 84 | # Undiscards the records by instantiating each 85 | # record and calling its {#undiscard!} method. 86 | # Each object's callbacks are executed. 87 | # Returns the collection of objects that were undiscarded. 88 | # 89 | # Note: Instantiation, callback execution, and update of each 90 | # record can be time consuming when you're undiscarding many records at 91 | # once. It generates at least one SQL +UPDATE+ query per record (or 92 | # possibly more, to enforce your callbacks). If you want to undiscard many 93 | # rows quickly, without concern for their associations or callbacks, use 94 | # #update_all!(discarded_at: nil) instead. 95 | # 96 | # ==== Examples 97 | # 98 | # Person.where(age: 0..18).undiscard_all! 99 | def undiscard_all! 100 | discarded.each(&:undiscard!) 101 | end 102 | end 103 | 104 | # @return [Boolean] true if this record has been discarded, otherwise false 105 | def discarded? 106 | self[self.class.discard_column].present? 107 | end 108 | 109 | # @return [Boolean] false if this record has been discarded, otherwise true 110 | def undiscarded? 111 | !discarded? 112 | end 113 | alias kept? undiscarded? 114 | 115 | # Discard the record in the database 116 | # 117 | # @return [Boolean] true if successful, otherwise false 118 | def discard 119 | return false if discarded? 120 | run_callbacks(:discard) do 121 | update_attribute(self.class.discard_column, Time.current) 122 | end 123 | end 124 | 125 | # Discard the record in the database 126 | # 127 | # There's a series of callbacks associated with #discard!. If the 128 | # before_discard callback throws +:abort+ the action is cancelled 129 | # and #discard! raises {Discard::RecordNotDiscarded}. 130 | # 131 | # @return [Boolean] true if successful 132 | # @raise {Discard::RecordNotDiscarded} 133 | def discard! 134 | discard || _raise_record_not_discarded 135 | end 136 | 137 | # Undiscard the record in the database 138 | # 139 | # @return [Boolean] true if successful, otherwise false 140 | def undiscard 141 | return false unless discarded? 142 | run_callbacks(:undiscard) do 143 | update_attribute(self.class.discard_column, nil) 144 | end 145 | end 146 | 147 | # Undiscard the record in the database 148 | # 149 | # There's a series of callbacks associated with #undiscard!. If the 150 | # before_undiscard callback throws +:abort+ the action is cancelled 151 | # and #undiscard! raises {Discard::RecordNotUndiscarded}. 152 | # 153 | # @return [Boolean] true if successful 154 | # @raise {Discard::RecordNotUndiscarded} 155 | def undiscard! 156 | undiscard || _raise_record_not_undiscarded 157 | end 158 | 159 | private 160 | 161 | def _raise_record_not_discarded 162 | raise ::Discard::RecordNotDiscarded.new(discarded_fail_message, self) 163 | end 164 | 165 | def _raise_record_not_undiscarded 166 | raise ::Discard::RecordNotUndiscarded.new(undiscarded_fail_message, self) 167 | end 168 | 169 | def discarded_fail_message 170 | return "A discarded record cannot be discarded" if discarded? 171 | 172 | "Failed to discard the record" 173 | end 174 | 175 | def undiscarded_fail_message 176 | return "An undiscarded record cannot be undiscarded" if undiscarded? 177 | 178 | "Failed to undiscard the record" 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /lib/discard/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Discard 4 | # Discard version 5 | VERSION = "1.4.0".freeze 6 | end 7 | -------------------------------------------------------------------------------- /spec/discard/model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Discard::Model do 4 | context "with simple Post model" do 5 | with_model :Post, scope: :all do 6 | table do |t| 7 | t.string :title 8 | t.datetime :discarded_at 9 | t.timestamps null: false 10 | end 11 | 12 | model do 13 | include Discard::Model 14 | end 15 | end 16 | 17 | context "an undiscarded Post" do 18 | let!(:post) { Post.create!(title: "My very first post") } 19 | 20 | it "is included in the default scope" do 21 | expect(Post.all).to eq([post]) 22 | end 23 | 24 | it "is included in kept scope" do 25 | expect(Post.kept).to eq([post]) 26 | end 27 | 28 | it "is included in undiscarded scope" do 29 | expect(Post.undiscarded).to eq([post]) 30 | end 31 | 32 | it "is not included in discarded scope" do 33 | expect(Post.discarded).to eq([]) 34 | end 35 | 36 | it "should not be discarded?" do 37 | expect(post).not_to be_discarded 38 | end 39 | 40 | it "should be undiscarded?" do 41 | expect(post).to be_undiscarded 42 | end 43 | 44 | it "should be kept?" do 45 | expect(post).to be_kept 46 | end 47 | 48 | describe '#discard' do 49 | it "sets discarded_at" do 50 | expect { 51 | post.discard 52 | }.to change { post.discarded_at } 53 | end 54 | 55 | it "sets discarded_at in DB" do 56 | expect { 57 | post.discard 58 | }.to change { post.reload.discarded_at } 59 | end 60 | end 61 | 62 | describe '#discard!' do 63 | it "sets discarded_at" do 64 | expect { 65 | post.discard! 66 | }.to change { post.discarded_at } 67 | end 68 | 69 | it "sets discarded_at in DB" do 70 | expect { 71 | post.discard! 72 | }.to change { post.reload.discarded_at } 73 | end 74 | end 75 | 76 | describe '#undiscard' do 77 | it "doesn't change discarded_at" do 78 | expect { 79 | post.undiscard 80 | }.not_to change { post.discarded_at } 81 | end 82 | 83 | it "doesn't change discarded_at in DB" do 84 | expect { 85 | post.undiscard 86 | }.not_to change { post.reload.discarded_at } 87 | end 88 | 89 | it "returns false" do 90 | expect(post.undiscard).to be false 91 | end 92 | end 93 | 94 | describe '#undiscard!' do 95 | it "raises Discard::RecordNotUndiscarded" do 96 | expect { 97 | post.undiscard! 98 | }.to raise_error(Discard::RecordNotUndiscarded, 'An undiscarded record cannot be undiscarded') 99 | end 100 | end 101 | end 102 | 103 | context "discarded Post" do 104 | let!(:post) { Post.create!(title: "A discarded post", discarded_at: Time.parse('2017-01-01')) } 105 | 106 | it "is included in the default scope" do 107 | expect(Post.all).to eq([post]) 108 | end 109 | 110 | it "is not included in kept scope" do 111 | expect(Post.kept).to eq([]) 112 | end 113 | 114 | it "is not included in undiscarded scope" do 115 | expect(Post.undiscarded).to eq([]) 116 | end 117 | 118 | it "is included in discarded scope" do 119 | expect(Post.discarded).to eq([post]) 120 | end 121 | 122 | it "should be discarded?" do 123 | expect(post).to be_discarded 124 | end 125 | 126 | it "should not be undiscarded?" do 127 | expect(post).to_not be_undiscarded 128 | end 129 | 130 | it "should not be kept?" do 131 | expect(post).to_not be_kept 132 | end 133 | 134 | describe '#discard' do 135 | it "doesn't change discarded_at" do 136 | expect { 137 | post.discard 138 | }.not_to change { post.discarded_at } 139 | end 140 | 141 | it "doesn't change discarded_at in DB" do 142 | expect { 143 | post.discard 144 | }.not_to change { post.reload.discarded_at } 145 | end 146 | 147 | it "returns false" do 148 | expect(post.discard).to be false 149 | end 150 | end 151 | 152 | describe '#discard!' do 153 | it "raises Discard::RecordNotDiscarded" do 154 | expect { 155 | post.discard! 156 | }.to raise_error(Discard::RecordNotDiscarded, "A discarded record cannot be discarded") 157 | end 158 | end 159 | 160 | describe '#undiscard' do 161 | it "clears discarded_at" do 162 | expect { 163 | post.undiscard 164 | }.to change { post.discarded_at }.to(nil) 165 | end 166 | 167 | it "clears discarded_at in DB" do 168 | expect { 169 | post.undiscard 170 | }.to change { post.reload.discarded_at }.to(nil) 171 | end 172 | end 173 | 174 | describe '#undiscard!' do 175 | it "clears discarded_at" do 176 | expect { 177 | post.undiscard! 178 | }.to change { post.discarded_at }.to(nil) 179 | end 180 | 181 | it "clears discarded_at in DB" do 182 | expect { 183 | post.undiscard! 184 | }.to change { post.reload.discarded_at }.to(nil) 185 | end 186 | end 187 | end 188 | end 189 | 190 | context "with default scope" do 191 | with_model :WithDefaultScope, scope: :all do 192 | table do |t| 193 | t.datetime :discarded_at 194 | t.timestamps null: false 195 | end 196 | 197 | model do 198 | include Discard::Model 199 | default_scope -> { kept } 200 | end 201 | end 202 | let(:klass) { WithDefaultScope } 203 | 204 | context "an undiscarded record" do 205 | let!(:record) { klass.create! } 206 | 207 | it "is included in the default scope" do 208 | expect(klass.all).to eq([record]) 209 | end 210 | 211 | it "is included in kept scope" do 212 | expect(klass.kept).to eq([record]) 213 | end 214 | 215 | it "is included in undiscarded scope" do 216 | expect(klass.undiscarded).to eq([record]) 217 | end 218 | 219 | it "is included in with_discarded scope" do 220 | expect(klass.with_discarded).to eq([record]) 221 | end 222 | 223 | it "is not included in discarded scope" do 224 | expect(klass.discarded).to eq([]) 225 | end 226 | end 227 | 228 | context "a discarded record" do 229 | let!(:record) { klass.create!(discarded_at: Time.current) } 230 | 231 | it "is not included in the default scope" do 232 | expect(klass.all).to eq([]) 233 | end 234 | 235 | it "is not included in kept scope" do 236 | expect(klass.kept).to eq([]) 237 | end 238 | 239 | it "is not included in undiscarded scope" do 240 | expect(klass.kept).to eq([]) 241 | end 242 | 243 | it "is not included in discarded scope" do 244 | # This is not ideal, but I don't want to improve it at the expense of 245 | # models withot a default scope 246 | expect(klass.discarded).to eq([]) 247 | end 248 | 249 | it "is included in with_discarded scope" do 250 | expect(klass.with_discarded).to eq([record]) 251 | end 252 | 253 | it "is included in with_discarded.discarded scope" do 254 | expect(klass.with_discarded.discarded).to eq([record]) 255 | end 256 | end 257 | end 258 | 259 | context "with custom column name" do 260 | with_model :Post, scope: :all do 261 | table do |t| 262 | t.string :title 263 | t.datetime :deleted_at 264 | t.timestamps null: false 265 | end 266 | 267 | model do 268 | include Discard::Model 269 | self.discard_column = :deleted_at 270 | end 271 | end 272 | 273 | context "an undiscarded Post" do 274 | let!(:post) { Post.create!(title: "My very first post") } 275 | 276 | it "is included in the default scope" do 277 | expect(Post.all).to eq([post]) 278 | end 279 | 280 | it "is included in kept scope" do 281 | expect(Post.kept).to eq([post]) 282 | end 283 | 284 | it "is included in undiscarded scope" do 285 | expect(Post.undiscarded).to eq([post]) 286 | end 287 | 288 | it "is not included in discarded scope" do 289 | expect(Post.discarded).to eq([]) 290 | end 291 | 292 | it "should not be discarded?" do 293 | expect(post).not_to be_discarded 294 | end 295 | 296 | it "should be undiscarded?" do 297 | expect(post).to be_undiscarded 298 | end 299 | 300 | it "should be kept?" do 301 | expect(post).to be_kept 302 | end 303 | 304 | describe '#discard' do 305 | it "sets discarded_at" do 306 | expect { 307 | post.discard 308 | }.to change { post.deleted_at } 309 | end 310 | 311 | it "sets discarded_at in DB" do 312 | expect { 313 | post.discard 314 | }.to change { post.reload.deleted_at } 315 | end 316 | end 317 | 318 | describe '#undiscard' do 319 | it "doesn't change discarded_at" do 320 | expect { 321 | post.undiscard 322 | }.not_to change { post.deleted_at } 323 | end 324 | 325 | it "doesn't change discarded_at in DB" do 326 | expect { 327 | post.undiscard 328 | }.not_to change { post.reload.deleted_at } 329 | end 330 | end 331 | end 332 | 333 | context "discarded Post" do 334 | let!(:post) { Post.create!(title: "A discarded post", deleted_at: Time.parse('2017-01-01')) } 335 | 336 | it "is included in the default scope" do 337 | expect(Post.all).to eq([post]) 338 | end 339 | 340 | it "is not included in kept scope" do 341 | expect(Post.kept).to eq([]) 342 | end 343 | 344 | it "is not included in undiscarded scope" do 345 | expect(Post.undiscarded).to eq([]) 346 | end 347 | 348 | it "is included in discarded scope" do 349 | expect(Post.discarded).to eq([post]) 350 | end 351 | 352 | it "should be discarded?" do 353 | expect(post).to be_discarded 354 | end 355 | 356 | it "should not be undiscarded?" do 357 | expect(post).to_not be_undiscarded 358 | end 359 | 360 | it "should not be kept?" do 361 | expect(post).to_not be_kept 362 | end 363 | 364 | describe '#discard' do 365 | it "doesn't change discarded_at" do 366 | expect { 367 | post.discard 368 | }.not_to change { post.deleted_at } 369 | end 370 | 371 | it "doesn't change discarded_at in DB" do 372 | expect { 373 | post.discard 374 | }.not_to change { post.reload.deleted_at } 375 | end 376 | end 377 | 378 | describe '#undiscard' do 379 | it "clears discarded_at" do 380 | expect { 381 | post.undiscard 382 | }.to change { post.deleted_at }.to(nil) 383 | end 384 | 385 | it "clears discarded_at in DB" do 386 | expect { 387 | post.undiscard 388 | }.to change { post.reload.deleted_at }.to(nil) 389 | end 390 | end 391 | end 392 | end 393 | 394 | describe '.discard_all' do 395 | with_model :Post, scope: :all do 396 | table do |t| 397 | t.string :title 398 | t.datetime :discarded_at 399 | t.timestamps null: false 400 | end 401 | 402 | model do 403 | include Discard::Model 404 | end 405 | end 406 | 407 | let!(:post) { Post.create!(title: "My very first post") } 408 | let!(:post2) { Post.create!(title: "A second post") } 409 | 410 | it "can discard all posts" do 411 | expect { 412 | Post.discard_all 413 | }.to change { post.reload.discarded? }.to(true) 414 | .and change { post2.reload.discarded? }.to(true) 415 | end 416 | 417 | it "can discard a single post" do 418 | Post.where(id: post.id).discard_all 419 | expect(post.reload).to be_discarded 420 | expect(post2.reload).not_to be_discarded 421 | end 422 | 423 | it "can discard no records" do 424 | Post.where(id: []).discard_all 425 | expect(post.reload).not_to be_discarded 426 | expect(post2.reload).not_to be_discarded 427 | end 428 | 429 | context "through a collection" do 430 | with_model :Comment, scope: :all do 431 | table do |t| 432 | t.belongs_to :user 433 | t.datetime :discarded_at 434 | t.timestamps null: false 435 | end 436 | 437 | model do 438 | include Discard::Model 439 | end 440 | end 441 | 442 | with_model :User, scope: :all do 443 | table do |t| 444 | t.timestamps null: false 445 | end 446 | 447 | model do 448 | include Discard::Model 449 | 450 | has_many :comments 451 | end 452 | end 453 | 454 | it "can be discard all related posts" do 455 | user1 = User.create! 456 | user2 = User.create! 457 | 458 | 2.times { user1.comments.create! } 459 | 2.times { user2.comments.create! } 460 | 461 | user1.comments.discard_all 462 | user1.comments.each do |comment| 463 | expect(comment).to be_discarded 464 | expect(comment).to_not be_undiscarded 465 | expect(comment).to_not be_kept 466 | end 467 | user2.comments.each do |comment| 468 | expect(comment).to_not be_discarded 469 | expect(comment).to be_undiscarded 470 | expect(comment).to be_kept 471 | end 472 | end 473 | end 474 | end 475 | 476 | describe '.discard_all!' do 477 | with_model :Post, scope: :all do 478 | table do |t| 479 | t.string :title 480 | t.datetime :discarded_at 481 | t.timestamps null: false 482 | end 483 | 484 | model do 485 | include Discard::Model 486 | end 487 | end 488 | 489 | let!(:post) { Post.create!(title: "My very first post") } 490 | let!(:post2) { Post.create!(title: "A second post") } 491 | 492 | it "can discard all posts" do 493 | expect { 494 | Post.discard_all! 495 | }.to change { post.reload.discarded? }.to(true) 496 | .and change { post2.reload.discarded? }.to(true) 497 | end 498 | end 499 | 500 | describe '.undiscard_all' do 501 | with_model :Post, scope: :all do 502 | table do |t| 503 | t.string :title 504 | t.datetime :discarded_at 505 | t.timestamps null: false 506 | end 507 | 508 | model do 509 | include Discard::Model 510 | end 511 | end 512 | 513 | let!(:post) { Post.create!(title: "My very first post", discarded_at: Time.now) } 514 | let!(:post2) { Post.create!(title: "A second post", discarded_at: Time.now) } 515 | 516 | it "can undiscard all posts" do 517 | expect { 518 | Post.undiscard_all 519 | }.to change { post.reload.discarded? }.to(false) 520 | .and change { post2.reload.discarded? }.to(false) 521 | end 522 | 523 | it "can undiscard a single post" do 524 | Post.where(id: post.id).undiscard_all 525 | expect(post.reload).not_to be_discarded 526 | expect(post2.reload).to be_discarded 527 | end 528 | 529 | it "can undiscard no records" do 530 | Post.where(id: []).undiscard_all 531 | expect(post.reload).to be_discarded 532 | expect(post2.reload).to be_discarded 533 | end 534 | end 535 | 536 | describe '.undiscard_all!' do 537 | with_model :Post, scope: :all do 538 | table do |t| 539 | t.string :title 540 | t.datetime :discarded_at 541 | t.timestamps null: false 542 | end 543 | 544 | model do 545 | include Discard::Model 546 | end 547 | end 548 | 549 | let!(:post) { Post.create!(title: "My very first post", discarded_at: Time.now) } 550 | let!(:post2) { Post.create!(title: "A second post", discarded_at: Time.now) } 551 | 552 | it "can undiscard all posts" do 553 | expect { 554 | Post.undiscard_all! 555 | }.to change { post.reload.discarded? }.to(false) 556 | .and change { post2.reload.discarded? }.to(false) 557 | end 558 | end 559 | 560 | describe 'discard callbacks' do 561 | with_model :Post, scope: :all do 562 | table do |t| 563 | t.datetime :discarded_at 564 | t.timestamps null: false 565 | end 566 | 567 | model do 568 | include Discard::Model 569 | before_discard :do_before_discard 570 | before_save :do_before_save 571 | after_save :do_after_save 572 | after_discard :do_after_discard 573 | 574 | def do_before_discard; end 575 | def do_before_save; end 576 | def do_after_save; end 577 | def do_after_discard; end 578 | end 579 | end 580 | 581 | def abort_callback 582 | if ActiveRecord::VERSION::MAJOR < 5 583 | false 584 | else 585 | throw :abort 586 | end 587 | end 588 | 589 | let!(:post) { Post.create! } 590 | 591 | it "runs callbacks in correct order" do 592 | expect(post).to receive(:do_before_discard).ordered 593 | expect(post).to receive(:do_before_save).ordered 594 | expect(post).to receive(:do_after_save).ordered 595 | expect(post).to receive(:do_after_discard).ordered 596 | 597 | expect(post.discard).to be true 598 | expect(post).to be_discarded 599 | end 600 | 601 | context 'before_discard' do 602 | it "can allow discard" do 603 | expect(post).to receive(:do_before_discard).and_return(true) 604 | expect(post.discard).to be true 605 | expect(post).to be_discarded 606 | end 607 | 608 | it "can prevent discard" do 609 | expect(post).to receive(:do_before_discard) { abort_callback } 610 | expect(post.discard).to be false 611 | expect(post).not_to be_discarded 612 | end 613 | 614 | describe '#discard!' do 615 | it "raises Discard::RecordNotDiscarded" do 616 | expect(post).to receive(:do_before_discard) { abort_callback } 617 | expect { 618 | post.discard! 619 | }.to raise_error(Discard::RecordNotDiscarded, "Failed to discard the record") 620 | end 621 | end 622 | end 623 | end 624 | 625 | describe 'undiscard callbacks' do 626 | with_model :Post, scope: :all do 627 | table do |t| 628 | t.datetime :discarded_at 629 | t.timestamps null: false 630 | end 631 | 632 | model do 633 | include Discard::Model 634 | before_undiscard :do_before_undiscard 635 | before_save :do_before_save 636 | after_save :do_after_save 637 | after_undiscard :do_after_undiscard 638 | 639 | def do_before_undiscard; end 640 | def do_before_save; end 641 | def do_after_save; end 642 | def do_after_undiscard; end 643 | end 644 | end 645 | 646 | def abort_callback 647 | if ActiveRecord::VERSION::MAJOR < 5 648 | false 649 | else 650 | throw :abort 651 | end 652 | end 653 | 654 | let!(:post) { Post.create! discarded_at: Time.now } 655 | 656 | it "runs callbacks in correct order" do 657 | expect(post).to receive(:do_before_undiscard).ordered 658 | expect(post).to receive(:do_before_save).ordered 659 | expect(post).to receive(:do_after_save).ordered 660 | expect(post).to receive(:do_after_undiscard).ordered 661 | 662 | expect(post.undiscard).to be true 663 | expect(post).not_to be_discarded 664 | end 665 | 666 | context 'before_undiscard' do 667 | it "can allow undiscard" do 668 | expect(post).to receive(:do_before_undiscard).and_return(true) 669 | expect(post.undiscard).to be true 670 | expect(post).not_to be_discarded 671 | end 672 | 673 | it "can prevent undiscard" do 674 | expect(post).to receive(:do_before_undiscard) { abort_callback } 675 | expect(post.undiscard).to be false 676 | expect(post).to be_discarded 677 | end 678 | 679 | describe '#undiscard!' do 680 | it "raises Discard::RecordNotUndiscarded" do 681 | expect(post).to receive(:do_before_undiscard) { abort_callback } 682 | expect { 683 | post.undiscard! 684 | }.to raise_error(Discard::RecordNotUndiscarded, "Failed to undiscard the record") 685 | end 686 | end 687 | end 688 | end 689 | end 690 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 2 | require 'discard' 3 | 4 | require 'database_cleaner' 5 | require 'with_model' 6 | 7 | DatabaseCleaner.strategy = :transaction 8 | 9 | RSpec.configure do |config| 10 | config.before :suite do 11 | ActiveRecord::Base.establish_connection :adapter => 'sqlite3', database: ':memory:' 12 | end 13 | 14 | config.before :each do 15 | DatabaseCleaner.start 16 | end 17 | config.after :each do 18 | DatabaseCleaner.clean 19 | end 20 | 21 | config.extend WithModel 22 | 23 | config.expect_with :rspec do |expectations| 24 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 25 | end 26 | 27 | config.mock_with :rspec do |mocks| 28 | mocks.verify_partial_doubles = true 29 | end 30 | 31 | config.shared_context_metadata_behavior = :apply_to_host_groups 32 | config.filter_run_when_matching :focus 33 | config.example_status_persistence_file_path = "spec/examples.txt" 34 | config.disable_monkey_patching! 35 | config.warnings = true 36 | 37 | if config.files_to_run.one? 38 | config.default_formatter = 'doc' 39 | end 40 | 41 | config.order = :random 42 | 43 | Kernel.srand config.seed 44 | end 45 | --------------------------------------------------------------------------------