├── .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 [](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 |
--------------------------------------------------------------------------------