├── .github ├── ISSUE_TEMPLATE.md └── workflows │ └── test.yml ├── .gitignore ├── .rspec ├── CHANGELOG.md ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── doc └── bug_report_template.rb ├── lib ├── generators │ └── paper_trail_association_tracking │ │ ├── add_foreign_type_to_version_associations_generator.rb │ │ ├── install_generator.rb │ │ └── templates │ │ ├── add_foreign_type_to_version_associations.rb.erb │ │ ├── add_transaction_id_column_to_versions.rb.erb │ │ └── create_version_associations.rb.erb ├── paper_trail-association_tracking.rb └── paper_trail_association_tracking │ ├── config.rb │ ├── frameworks │ ├── active_record.rb │ ├── active_record │ │ └── models │ │ │ └── paper_trail │ │ │ └── version_association.rb │ ├── rails.rb │ └── rails │ │ └── railtie.rb │ ├── model_config.rb │ ├── paper_trail.rb │ ├── record_trail.rb │ ├── reifier.rb │ ├── reifiers │ ├── belongs_to.rb │ ├── has_and_belongs_to_many.rb │ ├── has_many.rb │ ├── has_many_through.rb │ └── has_one.rb │ ├── request.rb │ ├── version.rb │ ├── version_association_concern.rb │ └── version_concern.rb ├── paper_trail-association_tracking.gemspec └── spec ├── .DS_Store ├── controllers └── widgets_controller_spec.rb ├── dummy_app ├── Rakefile ├── app │ ├── controllers │ │ ├── application_controller.rb │ │ └── widgets_controller.rb │ └── models │ │ ├── animal.rb │ │ ├── authorship.rb │ │ ├── bar_habtm.rb │ │ ├── bicycle.rb │ │ ├── bizzo.rb │ │ ├── book.rb │ │ ├── car.rb │ │ ├── cat.rb │ │ ├── chapter.rb │ │ ├── citation.rb │ │ ├── custom_version.rb │ │ ├── custom_version_association.rb │ │ ├── customer.rb │ │ ├── document.rb │ │ ├── dog.rb │ │ ├── editor.rb │ │ ├── editorship.rb │ │ ├── elephant.rb │ │ ├── family │ │ ├── family.rb │ │ └── family_line.rb │ │ ├── fluxor.rb │ │ ├── foo_habtm.rb │ │ ├── hunt.rb │ │ ├── line_item.rb │ │ ├── note.rb │ │ ├── order.rb │ │ ├── paragraph.rb │ │ ├── person.rb │ │ ├── pet.rb │ │ ├── quotation.rb │ │ ├── section.rb │ │ ├── thing.rb │ │ ├── user.rb │ │ ├── vehicle.rb │ │ ├── whatchamajigger.rb │ │ ├── widget.rb │ │ └── wotsit.rb ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ └── test.rb │ ├── initializers │ │ ├── backtrace_silencers.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── paper_trail.rb │ │ └── session_store.rb │ ├── locales │ │ └── en.yml │ └── routes.rb └── db │ ├── migrate │ └── 20110208155312_set_up_test_tables.rb │ └── schema.rb ├── generators └── paper_trail_association_tracking │ ├── add_foreign_type_to_version_associations_generator_spec.rb │ └── install_generator_spec.rb ├── models ├── family │ └── family_spec.rb ├── note_spec.rb ├── person_spec.rb └── pet_spec.rb ├── paper_trail ├── association_reify_error_behaviour │ ├── error.rb │ ├── ignore.rb │ └── warn.rb ├── associations │ ├── belongs_to_spec.rb │ ├── habtm_spec.rb │ ├── has_many_spec.rb │ ├── has_many_through_spec.rb │ └── has_one_spec.rb ├── config_spec.rb ├── model_config_spec.rb ├── model_spec.rb ├── request_spec.rb ├── version_concern_spec.rb └── version_spec.rb ├── paper_trail_association_tracking_spec.rb ├── paper_trail_spec.rb └── spec_helper.rb /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Thanks for your interest in PaperTrail-AssociationTracking! 2 | 3 | Where applicable, please use the following template to report any bugs: 4 | https://github.com/westonganger/paper_trail-association_tracking/blob/master/doc/bug_report_template.rb 5 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: ['master'] 5 | pull_request: 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | include: 14 | ### TEST RUBY VERSIONS 15 | - ruby: "2.6" 16 | - ruby: "2.7" 17 | - ruby: "3.0" 18 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue 19 | - ruby: "3.1" 20 | - ruby: "3.2" 21 | - ruby: "3.3" 22 | - ruby: "3.4" 23 | 24 | ### TEST RAILS VERSIONS 25 | - ruby: "2.6" 26 | rails_version: "~> 5.2.0" 27 | - ruby: "2.6" 28 | rails_version: "~> 6.0.0" 29 | - ruby: "2.6" 30 | rails_version: "~> 6.1.0" 31 | - ruby: "3.3" 32 | rails_version: "~> 7.0.0" 33 | db_gem_version: "~> 1.4" # fixes sqlite3 gem dependency issue 34 | - ruby: "3.4" 35 | rails_version: "~> 7.1.0" 36 | - ruby: "3.4" 37 | rails_version: "~> 7.2.0" 38 | - ruby: "3.4" 39 | rails_version: "~> 8.0.0" 40 | 41 | ### TEST PT VERSIONS 42 | - ruby: "2.6" 43 | paper_trail_version: "~> 12.0" 44 | - ruby: "2.6" 45 | paper_trail_version: "~> 13.0" 46 | - ruby: "2.7" 47 | paper_trail_version: "~> 14.0" 48 | - ruby: "3.1" 49 | paper_trail_version: "~> 15.0" 50 | 51 | ### TEST NON-SQLITE DATABASES 52 | - ruby: "3.4" 53 | db_gem: "mysql2" 54 | - ruby: "3.4" 55 | db_gem: "pg" 56 | 57 | services: 58 | mysql: 59 | image: ${{ (matrix.db_gem == 'mysql2' && 'mysql') || '' }} # conditional service 60 | env: 61 | MYSQL_ROOT_PASSWORD: password 62 | MYSQL_DATABASE: test 63 | options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 64 | ports: ['3306:3306'] 65 | postgres: 66 | image: ${{ (matrix.db_gem == 'pg' && 'postgres') || '' }} # conditional service 67 | env: 68 | POSTGRES_USER: postgres 69 | POSTGRES_PASSWORD: password 70 | POSTGRES_DB: test 71 | ports: ['5432:5432'] 72 | 73 | steps: 74 | - uses: actions/checkout@v3 75 | 76 | - name: Set env DATABASE_URL 77 | run: | 78 | if [[ "${{ matrix.db_gem }}" == 'mysql2' ]]; then 79 | echo "DATABASE_URL=mysql2://root:password@127.0.0.1:3306/test" >> "$GITHUB_ENV" 80 | elif [[ "${{ matrix.db_gem }}" == 'pg' ]]; then 81 | echo "DATABASE_URL=postgres://postgres:password@localhost:5432/test" >> "$GITHUB_ENV" 82 | fi 83 | 84 | - name: Set env variables 85 | run: | 86 | echo "RAILS_VERSION=${{ matrix.rails_version }}" >> "$GITHUB_ENV" 87 | echo "DB_GEM=${{ matrix.db_gem }}" >> "$GITHUB_ENV" 88 | echo "DB_GEM_VERSION=${{ matrix.db_gem_version }}" >> "$GITHUB_ENV" 89 | echo "PAPER_TRAIL_VERSION=${{ matrix.paper_trail_version }}" >> "$GITHUB_ENV" 90 | 91 | - name: Install ruby 92 | uses: ruby/setup-ruby@v1 93 | with: 94 | ruby-version: "${{ matrix.ruby }}" 95 | bundler-cache: false ### not compatible with ENV-style Gemfile 96 | 97 | - name: Run tests 98 | run: | 99 | bundle install 100 | bundle exec rake test 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.sqlite3-journal 3 | .DS_Store 4 | .bundle 5 | .byebug_history 6 | .idea 7 | .rbenv-gemsets 8 | .rbenv-version 9 | .rspec_results 10 | .ruby-gemset 11 | .ruby-version 12 | .rvmrc 13 | .tags 14 | .tags_sorted_by_file 15 | Gemfile.lock 16 | NOTES 17 | coverage 18 | gemfiles/*.lock 19 | gemfiles/.bundle 20 | pkg/* 21 | spec/dummy/ 22 | spec/dummy_app/db/*.sqlite3* 23 | spec/dummy_app/log/* 24 | spec/dummy_app/tmp/* 25 | test/debug.log 26 | test/paper_trail_plugin.sqlite3.db 27 | vendor/* 28 | .DS_Store 29 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ### Unreleased - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.3.0...master) 4 | - Nothing yet 5 | 6 | ### v2.3.0 - 2025-04-17 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.2.1...v2.3.0) 7 | - [#46](https://github.com/westonganger/paper_trail-association_tracking/pull/46) - Add support for custom version association class with separate database connection 8 | - [#49](https://github.com/westonganger/paper_trail-association_tracking/pull/49) - Fix has_many though associations when the through association is singular 9 | - [#48](https://github.com/westonganger/paper_trail-association_tracking/pull/48) - Fix belongs_to polymorphic associations with "empty string" type 10 | 11 | ### v2.2.1 - 2022-03-28 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.2.0...v2.2.1) 12 | - [PR #38](https://github.com/westonganger/paper_trail-association_tracking/pull/38) - Fix the issue where reifying has_one association with `dependent: :destroy` could destroy a live record 13 | 14 | ### v2.2.0 - 2022-03-14 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.1.3...v2.2.0) 15 | 16 | - [PR #36](https://github.com/westonganger/paper_trail-association_tracking/pull/36) - Fix load order for paper_trail v12+ 17 | - Drop support for Ruby 2.5 18 | - Add Github Actions CI supporting multiple version of Ruby, Rails and multiple databases types 19 | 20 | ### 2.1.3 - 2021-04-20 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.1.1...v2.1.3) 21 | 22 | - [PR #24](https://github.com/westonganger/paper_trail-association_tracking/pull/24) - Fix reification on STI models that have parent child relationships 23 | 24 | ### 2.1.2 - 2021-04-20 25 | 26 | - A late night oopsies, Release yanked immediately, had bug preventing installation. 27 | 28 | ### 2.1.1 - 2020-10-21 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.1.0...v2.1.1) 29 | 30 | - Bug fix for reify on `has_many :through` relationships when `:source` is specified 31 | - Bug fix for reify on `has_many :through` relationships where the association is a has_one on the through model 32 | - Bug fix to ensure install generator will set `PaperTrail.association_tracking = true` 33 | 34 | ### 2.1.0 - 2020-08-14 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v2.0.0...v2.1.0) 35 | 36 | - [PR #18](https://github.com/westonganger/paper_trail-association_tracking/pull/18) - Improve performance for `Model.reify(has_many: true)` by separating the SQL subquery. 37 | - [PR #15](https://github.com/westonganger/paper_trail-association_tracking/pull/15) - Recreate `version_associations.foreign_key` index to utilize the new `version_associations.foreign_type` column 38 | - Update test matrix to support multiple versions of PT-core and ActiveRecord 39 | - Remove deprecated methods `clear_transaction_id`, `transaction_id` and `transaction_id=` 40 | 41 | ### 2.0.0 - 2019-01-22 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v1.1.1...v2.0.0) 42 | 43 | - [PR #11](https://github.com/westonganger/paper_trail-association_tracking/issues/11) - Remove null constraint on `version_associations.foreign_type` column which was added in `v1.1.0`. This fixes issues adding the column to existing projects who are upgrading. 44 | - Add generator `rails g paper_trail_association_tracking:add_foreign_type_to_version_associations` for `versions_associations.foreign_type` column for upgrading applications from `v1.0.0` or earlier. 45 | 46 | ### How to Upgrade from v1.0.0 or earlier 47 | 48 | - Run `rails g paper_trail_association_tracking:add_foreign_type_to_version_associations` and then migrate your database. 49 | 50 | ### 1.1.1 - 2018-01-14 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v1.1.0...v1.1.1) 51 | 52 | - Same as v2 release, this is released simply to maintain a working `v1` branch since `v1.1.0` was broken 53 | 54 | ### 1.1.0 - 2018-12-28 - [View Diff](https://github.com/westonganger/paper_trail-association_tracking/compare/v1.0.0...v1.1.0) 55 | 56 | - Note: This release is somewhat broken, please upgrade to `v2.0.0` or stay on `v1.0.0` 57 | - [PR #10](https://github.com/westonganger/paper_trail-association_tracking/pull/10) - The `has_many: true` option now reifies polymorphic associations. Previously they were skipped. 58 | - [PR #9](https://github.com/westonganger/paper_trail-association_tracking/pull/9) - The `belongs_to: true` option now reifies polymorphic associations. Previously they were skipped. 59 | 60 | ### 1.0.0 - 2018-06-04 61 | 62 | - [PT #1070](https://github.com/paper-trail-gem/paper_trail/issues/1070), [#2](https://github.com/westonganger/paper_trail-association_tracking/issues/2) - Extracted from paper_trail gem in v9.2 63 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | gem 'rails-controller-testing' 6 | 7 | def get_env(name) 8 | (ENV[name] && !ENV[name].empty?) ? ENV[name] : nil 9 | end 10 | 11 | gem "rails", get_env("RAILS_VERSION") 12 | 13 | db_gem = get_env("DB_GEM") || "sqlite3" 14 | gem db_gem, get_env("DB_GEM_VERSION") 15 | 16 | gem 'paper_trail', get_env("PAPER_TRAIL_VERSION") 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Weston Ganger 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PaperTrail-AssociationTracking 2 | 3 | Gem Version 4 | CI Status 5 | RubyGems Downloads 6 | 7 | Plugin for the [PaperTrail](https://github.com/paper-trail-gem/paper_trail.git) gem to track and reify associations. This gem was extracted from PaperTrail for v9.2.0 to simplify things in PaperTrail and association tracking separately. 8 | 9 | **PR's will happily be accepted** 10 | 11 | PaperTrail-AssociationTracking can restore three types of associations: Has-One, Has-Many, and Has-Many-Through. 12 | 13 | It will store in the `version_associations` table additional information to correlate versions of the association and versions of the model when the associated record is changed. When reifying the model, it will utilize this table, together with the `transaction_id` to find the correct version of the association and reify it. The `transaction_id` is a unique id for version records created in the same transaction. It is used to associate the version of the model and the version of the association that are created in the same transaction. 14 | 15 | ## Table of Contents 16 | 17 | - [Alternative Solution](#alternative-solution) 18 | - [Install](#install) 19 | - [Usage](#usage) 20 | - [Limitations](#limitations) 21 | - [Known Issues](#known-issues) 22 | - [Contributing](#contributing) 23 | - [Credits](#credits) 24 | 25 | # Alternative Solution 26 | 27 | Model versioning and restoration require concious thought, design, and understanding. You should understand your versioning and restoration process completely. This gem paper_trail-association-tracking is mostly a blackbox solution which encourages you to set it up and then assume its Just WorkingTM. This can make for major data problems later. 28 | 29 | Instead I recommend a newer gem that I have created for handling snapshots of records and associations called [active_snapshot](https://github.com/westonganger/active_snapshot). This gem does not utilize `paper_trail` at all. The focus of the [active_snapshot](https://github.com/westonganger/active_snapshot) gem is to have a simple and fully understandable design is easy to customize and know inside and out for your projects needs. 30 | 31 | # Install 32 | 33 | ```ruby 34 | gem 'paper_trail' 35 | gem 'paper_trail-association_tracking' 36 | ``` 37 | 38 | Then run `rails generate paper_trail_association_tracking:install` which will do the following two things for you: 39 | 40 | 1. Create a `version_associations` table 41 | 2. Set `PaperTrail.config.track_associations = true` in an initializer 42 | 43 | # Usage 44 | 45 | First, ensure that you have added `has_paper_trail` to your main model and all associated models that are to be tracked. 46 | 47 | To restore associations as they were at the time you must pass any of the following options to the `reify` method. 48 | 49 | - To restore Has-Many and Has-Many-Through associations, use option `has_many: true` 50 | - To restore Has-One associations , use option `has_one: true` to `reify` 51 | - To restore Belongs-To associations, use option `belongs_to: true` 52 | 53 | For example: 54 | 55 | ```ruby 56 | item.versions.last.reify(has_many: true, has_one: true, belongs_to: false) 57 | ``` 58 | 59 | If you want the reified associations to be saved upon calling `save` on the parent model then you must set `autosave: true` on all required associations. A little tip, `accepts_nested_attributes` automatically sets `autosave` to true but you should probably still state it explicitly. 60 | 61 | For example: 62 | 63 | ```ruby 64 | class Product 65 | has_many :photos, autosave: true 66 | end 67 | 68 | product = Product.first.versions.last.reify(has_many: true, has_one: true, belongs_to: false) 69 | product.save! ### now this will also save all reified photos 70 | ``` 71 | 72 | If you do not set `autosave: true` true on the association then you will have to save/delete them manually. 73 | 74 | For example: 75 | 76 | ```ruby 77 | class Product < ActiveRecord::Base 78 | has_paper_trail 79 | has_many :photos, autosave: false ### or if autosave not set 80 | end 81 | 82 | product = Product.create(name: 'product_0') 83 | product.photos.create(name: 'photo') 84 | product.update(name: 'product_a') 85 | product.photos.create(name: 'photo') 86 | 87 | reified_product = product.versions.last.reify(has_many: true, mark_for_destruction: true) 88 | reified_product.save! 89 | reified_product.name # product_a 90 | reified_product.photos.size # 2 91 | reified_product.photos.reload 92 | reified_product.photos.size # 1 ### bad, didnt save the associations 93 | 94 | product = Product.create(name: 'product_1') 95 | product.update(name: 'product_b') 96 | product.photos.create(name: 'photo') 97 | 98 | reified_product = product.versions.last.reify(has_many: true, mark_for_destruction: true) 99 | reified_product.save! 100 | reified_product.name # product_b 101 | reified_product.photos.size # 1 102 | reified_product.photos.each{|x| x.marked_for_destruction? ? x.destroy! : x.save! } 103 | reified_product.photos.size # 0 104 | ``` 105 | 106 | It will also respect AR transactions by utilizing the aforementioned `transaction_id` to reify the models as they were before the transaction (instead of before the update to the model). 107 | 108 | For example: 109 | 110 | ```ruby 111 | item.amount # 100 112 | item.location.latitude # 12.345 113 | 114 | Item.transaction do 115 | item.location.update(latitude: 54.321) 116 | item.update(amount: 153) 117 | end 118 | 119 | t = item.versions.last.reify(has_one: true) 120 | t.amount # 100 121 | t.location.latitude # 12.345, instead of 54.321 122 | ``` 123 | 124 | # Configuration 125 | 126 | You can configure a different version association class by using the following configuration: 127 | 128 | ```ruby 129 | class ProductionVersionAssociation < PaperTrail::VersionAssociation 130 | # You can change the table name, i.e.: 131 | self.table_name = "product_version_associations" 132 | end 133 | 134 | class Product < ActiveRecord::Base 135 | has_paper_trail version_associations: { class_name: "ProductVersionAssociation" } 136 | end 137 | ``` 138 | 139 | # Limitations 140 | 141 | 1. Only reifies the first level of associations. If you want to include nested associations simply add `:through` relationships to your model. 142 | 1. Currently we only supports a single `version_associations` table. Therefore, you can only use a single table to store the versions for all related models. 143 | 1. Relies on the callbacks on the association model (and the `:through` association model for Has-Many-Through associations) to record the versions and the relationship between the versions. If the association is changed without invoking the callbacks, then reification won't work. Example: 144 | 145 | ```ruby 146 | class Book < ActiveRecord::Base 147 | has_many :authorships, dependent: :destroy 148 | has_many :authors, through: :authorships, source: :person 149 | has_paper_trail 150 | end 151 | 152 | class Authorship < ActiveRecord::Base 153 | belongs_to :book 154 | belongs_to :person 155 | has_paper_trail # NOTE 156 | end 157 | 158 | class Person < ActiveRecord::Base 159 | has_many :authorships, dependent: :destroy 160 | has_many :books, through: :authorships 161 | has_paper_trail 162 | end 163 | 164 | ### Each of the following will store authorship versions: 165 | @book.authors << @john 166 | @book.authors.create(name: 'Jack') 167 | @book.authorships.last.destroy 168 | @book.authorships.clear 169 | @book.author_ids = [@john.id, @joe.id] 170 | 171 | ### But none of these will: 172 | @book.authors.delete @john 173 | @book.author_ids = [] 174 | @book.authors = [] 175 | ``` 176 | 177 | 178 | # Known Issues 179 | 180 | 1. Sometimes the has_one association will find more than one possible candidate and will raise a `PaperTrailAssociationTracking::Reifiers::HasOne::FoundMoreThanOne` error. For example, see `spec/models/person_spec.rb` 181 | - If you are not using STI, you may want to just assume the first result of multiple is the correct one and continue. PaperTrail <= v8 did this without error or warning. To do so add the following line to your initializer: `PaperTrail.config.association_reify_error_behaviour = :warn`. Valid options are: `[:error, :warn, :ignore]` 182 | - When using STI, even if you enable `:warn` you will likely still end up recieving an `ActiveRecord::AssociationTypeMismatch` error. See [PT Issue #594](https://github.com/airblade/paper_trail/issues/594). I strongly recommend that you do not use STI, however if you do need to decide to use STI, please see https://github.com/paper-trail-gem/paper_trail#4b1-the-optional-item_subtype-column 183 | 1. Not compatible with transactional tests, see [PT Issue #542](https://github.com/airblade/paper_trail/issues/542). However, apparently there has been some success by using the [transactional_capybara](https://rubygems.org/gems/transactional_capybara) gem. 184 | 185 | 186 | # Credits 187 | 188 | Maintained by [Weston Ganger](https://westonganger.com) - [@westonganger](https://github.com/westonganger) 189 | 190 | Plugin authored by [Weston Ganger](https://westonganger.com) - [@westonganger](https://github.com/westonganger) 191 | 192 | Associations code originally contributed by Ben Atkins, Jared Beck, Andy Stewart & more 193 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler::GemHelper.install_tasks 3 | 4 | require "rspec/core/rake_task" 5 | RSpec::Core::RakeTask.new(:spec) 6 | 7 | task test: [:spec] 8 | 9 | task default: [:spec] 10 | 11 | task :console do 12 | require "paper_trail-association-tracking" 13 | 14 | require "irb" 15 | binding.irb 16 | end 17 | -------------------------------------------------------------------------------- /doc/bug_report_template.rb: -------------------------------------------------------------------------------- 1 | ### Bug Report Template 2 | 3 | require "bundler/inline" 4 | 5 | # STEP ONE: What versions are you using? 6 | gemfile(true) do 7 | ruby "2.6" 8 | source "https://rubygems.org" 9 | gem "activerecord", "6.0.2" 10 | gem "minitest" 11 | gem "paper_trail", "~>10.3.0" 12 | gem "paper_trail-association_tracking" 13 | gem "sqlite3" 14 | end 15 | 16 | require "active_record" 17 | require "minitest/autorun" 18 | require "logger" 19 | 20 | # Please use sqlite for your bug reports, if possible. 21 | ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") 22 | 23 | ActiveRecord::Base.logger = nil 24 | 25 | ActiveRecord::Schema.define do 26 | # STEP TWO: Define your tables here. 27 | create_table :users, force: true do |t| 28 | t.text :first_name, null: false 29 | t.timestamps null: false 30 | end 31 | 32 | create_table :versions do |t| 33 | t.string :item_type, null: false 34 | t.integer :item_id, null: false 35 | t.string :event, null: false 36 | t.string :whodunnit 37 | t.text :object, limit: 1_073_741_823 38 | t.text :object_changes, limit: 1_073_741_823 39 | t.integer :transaction_id 40 | t.datetime :created_at 41 | end 42 | add_index :versions, %i[item_type item_id] 43 | add_index :versions, [:transaction_id] 44 | 45 | create_table :version_associations do |t| 46 | t.integer :version_id 47 | t.string :foreign_key_name, null: false 48 | t.integer :foreign_key_id 49 | end 50 | add_index :version_associations, [:version_id] 51 | add_index :version_associations, %i[foreign_key_name foreign_key_id], 52 | name: "index_version_associations_on_foreign_key" 53 | end 54 | 55 | ActiveRecord::Base.logger = Logger.new(STDOUT) 56 | 57 | require "paper_trail" 58 | require "paper_trail-association_tracking" 59 | 60 | PaperTrail.config.track_associations = true 61 | 62 | # STEP FOUR: Define your AR models here. 63 | class User < ActiveRecord::Base 64 | has_paper_trail 65 | end 66 | 67 | # STEP FIVE: Please write a test that demonstrates your issue. 68 | class BugTest < ActiveSupport::TestCase 69 | def test_1 70 | assert_difference(-> { PaperTrail::Version.count }, +1) { 71 | User.create(first_name: "Jane") 72 | } 73 | end 74 | end 75 | 76 | # STEP SIX: Run this script using `ruby my_bug_report.rb` 77 | -------------------------------------------------------------------------------- /lib/generators/paper_trail_association_tracking/add_foreign_type_to_version_associations_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/active_record" 5 | 6 | module PaperTrailAssociationTracking 7 | # Installs PaperTrail in a rails app. 8 | class AddForeignTypeToVersionAssociationsGenerator < ::Rails::Generators::Base 9 | include ::Rails::Generators::Migration 10 | 11 | # Class names of MySQL adapters. 12 | # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`. 13 | # - `Mysql2Adapter` - Used by `mysql2` gem. 14 | MYSQL_ADAPTERS = [ 15 | "ActiveRecord::ConnectionAdapters::MysqlAdapter", 16 | "ActiveRecord::ConnectionAdapters::Mysql2Adapter" 17 | ].freeze 18 | 19 | source_root File.expand_path("../templates", __FILE__) 20 | 21 | desc "Generates (but does not run) a migration to add a the foreign_type column to the version_associations table after upgrading from v1.0 or earlier." 22 | 23 | def create_migrations 24 | add_paper_trail_migration("add_foreign_type_to_version_associations") 25 | end 26 | 27 | def self.next_migration_number(dirname) 28 | ::ActiveRecord::Generators::Base.next_migration_number(dirname) 29 | end 30 | 31 | protected 32 | 33 | def add_paper_trail_migration(template) 34 | migration_dir = File.expand_path("db/migrate") 35 | if self.class.migration_exists?(migration_dir, template) 36 | ::Kernel.warn "Migration already exists: #{template}" 37 | else 38 | migration_template( 39 | "#{template}.rb.erb", 40 | "db/migrate/#{template}.rb", 41 | migration_version: migration_version 42 | ) 43 | end 44 | end 45 | 46 | private 47 | 48 | def migration_version 49 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/generators/paper_trail_association_tracking/install_generator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "rails/generators" 4 | require "rails/generators/active_record" 5 | 6 | module PaperTrailAssociationTracking 7 | # Installs PaperTrail in a rails app. 8 | class InstallGenerator < ::Rails::Generators::Base 9 | include ::Rails::Generators::Migration 10 | 11 | # Class names of MySQL adapters. 12 | # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`. 13 | # - `Mysql2Adapter` - Used by `mysql2` gem. 14 | MYSQL_ADAPTERS = [ 15 | "ActiveRecord::ConnectionAdapters::MysqlAdapter", 16 | "ActiveRecord::ConnectionAdapters::Mysql2Adapter" 17 | ].freeze 18 | 19 | source_root File.expand_path("../templates", __FILE__) 20 | 21 | desc "Generates (but does not run) a migration to add a versions table." 22 | 23 | def create_migrations 24 | add_paper_trail_migration("create_version_associations") 25 | add_paper_trail_migration("add_transaction_id_column_to_versions") 26 | end 27 | 28 | def create_initializer 29 | create_file( 30 | "config/initializers/paper_trail.rb", 31 | "PaperTrail.config.track_associations = true\n", 32 | "PaperTrail.config.association_reify_error_behaviour = :error" 33 | ) 34 | end 35 | 36 | def self.next_migration_number(dirname) 37 | ::ActiveRecord::Generators::Base.next_migration_number(dirname) 38 | end 39 | 40 | protected 41 | 42 | def add_paper_trail_migration(template) 43 | migration_dir = File.expand_path("db/migrate") 44 | if self.class.migration_exists?(migration_dir, template) 45 | ::Kernel.warn "Migration already exists: #{template}" 46 | else 47 | migration_template( 48 | "#{template}.rb.erb", 49 | "db/migrate/#{template}.rb", 50 | migration_version: migration_version 51 | ) 52 | end 53 | end 54 | 55 | private 56 | 57 | def migration_version 58 | "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/generators/paper_trail_association_tracking/templates/add_foreign_type_to_version_associations.rb.erb: -------------------------------------------------------------------------------- 1 | # This migration and AddTransactionIdColumnToVersions provide the necessary 2 | # schema for tracking associations. 3 | class AddForeignTypeToVersionAssociations < ActiveRecord::Migration<%= migration_version %> 4 | def self.up 5 | add_column :version_associations, :foreign_type, :string, index: true 6 | remove_index :version_associations, 7 | name: "index_version_associations_on_foreign_key" 8 | add_index :version_associations, 9 | %i(foreign_key_name foreign_key_id foreign_type), 10 | name: "index_version_associations_on_foreign_key" 11 | end 12 | 13 | def self.down 14 | remove_index :version_associations, 15 | name: "index_version_associations_on_foreign_key" 16 | remove_column :version_associations, :foreign_type 17 | add_index :version_associations, 18 | %i(foreign_key_name foreign_key_id), 19 | name: "index_version_associations_on_foreign_key" 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/generators/paper_trail_association_tracking/templates/add_transaction_id_column_to_versions.rb.erb: -------------------------------------------------------------------------------- 1 | # This migration and CreateVersionAssociations provide the necessary 2 | # schema for tracking associations. 3 | class AddTransactionIdColumnToVersions < ActiveRecord::Migration<%= migration_version %> 4 | def self.up 5 | add_column :versions, :transaction_id, :integer 6 | add_index :versions, [:transaction_id] 7 | end 8 | 9 | def self.down 10 | remove_index :versions, [:transaction_id] 11 | remove_column :versions, :transaction_id 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/generators/paper_trail_association_tracking/templates/create_version_associations.rb.erb: -------------------------------------------------------------------------------- 1 | # This migration and AddTransactionIdColumnToVersions provide the necessary 2 | # schema for tracking associations. 3 | class CreateVersionAssociations < ActiveRecord::Migration<%= migration_version %> 4 | def self.up 5 | create_table :version_associations do |t| 6 | t.integer :version_id 7 | t.string :foreign_key_name, null: false 8 | t.integer :foreign_key_id 9 | t.string :foreign_type 10 | end 11 | add_index :version_associations, [:version_id] 12 | add_index :version_associations, 13 | %i(foreign_key_name foreign_key_id foreign_type), 14 | name: "index_version_associations_on_foreign_key" 15 | end 16 | 17 | def self.down 18 | remove_index :version_associations, [:version_id] 19 | remove_index :version_associations, 20 | name: "index_version_associations_on_foreign_key" 21 | drop_table :version_associations 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/paper_trail-association_tracking.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'paper_trail' 4 | require "paper_trail_association_tracking/config" 5 | require "paper_trail_association_tracking/model_config" 6 | require "paper_trail_association_tracking/reifier" 7 | require "paper_trail_association_tracking/record_trail" 8 | require "paper_trail_association_tracking/request" 9 | require "paper_trail_association_tracking/paper_trail" 10 | require "paper_trail_association_tracking/version_concern" 11 | 12 | if defined?(Rails) 13 | require "paper_trail/frameworks/active_record" 14 | require "paper_trail_association_tracking/frameworks/rails" 15 | elsif defined?(ActiveRecord) 16 | require "paper_trail/frameworks/active_record" 17 | require "paper_trail_association_tracking/frameworks/active_record" 18 | end 19 | 20 | module PaperTrailAssociationTracking 21 | def self.version 22 | VERSION 23 | end 24 | 25 | def self.gem_version 26 | ::Gem::Version.new(VERSION) 27 | end 28 | end 29 | 30 | module PaperTrail 31 | class << self 32 | prepend ::PaperTrailAssociationTracking::PaperTrail::ClassMethods 33 | end 34 | 35 | class Config 36 | prepend ::PaperTrailAssociationTracking::Config 37 | end 38 | 39 | class ModelConfig 40 | prepend ::PaperTrailAssociationTracking::ModelConfig 41 | end 42 | 43 | class RecordTrail 44 | prepend ::PaperTrailAssociationTracking::RecordTrail 45 | end 46 | 47 | module Reifier 48 | class << self 49 | prepend ::PaperTrailAssociationTracking::Reifier::ClassMethods 50 | end 51 | end 52 | 53 | module Request 54 | class << self 55 | prepend ::PaperTrailAssociationTracking::Request::ClassMethods 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Config 5 | def association_reify_error_behaviour=(val) 6 | val = val.to_s 7 | if ['error', 'warn', 'ignore'].include?(val.to_s) 8 | @association_reify_error_behaviour = val.to_s 9 | else 10 | raise ArgumentError.new('Incorrect value passed to `association_reify_error_behaviour`') 11 | end 12 | end 13 | 14 | def association_reify_error_behaviour 15 | @association_reify_error_behaviour ||= "error" 16 | end 17 | 18 | def track_associations=(val) 19 | @track_associations = !!val 20 | end 21 | 22 | def track_associations? 23 | !!@track_associations 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/frameworks/active_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "paper_trail_association_tracking/frameworks/active_record/models/paper_trail/version_association" 4 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/frameworks/active_record/models/paper_trail/version_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "paper_trail_association_tracking/version_association_concern" 4 | 5 | module PaperTrail 6 | # This is the default ActiveRecord model provided by PaperTrail. Most simple 7 | # applications will only use this and its partner, `Version`, but it is 8 | # possible to sub-class, extend, or even do without this model entirely. 9 | # See the readme for details. 10 | class VersionAssociation < ::ActiveRecord::Base 11 | include PaperTrailAssociationTracking::VersionAssociationConcern 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/frameworks/rails.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "paper_trail_association_tracking/frameworks/rails/railtie" 4 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/frameworks/rails/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | class Railtie < ::Rails::Railtie 5 | 6 | initializer "paper_trail_association_tracking", after: "paper_trail" do 7 | ActiveSupport.on_load(:active_record) do 8 | require "paper_trail_association_tracking/frameworks/active_record" 9 | end 10 | end 11 | 12 | config.to_prepare do 13 | ::PaperTrail::Version.include(::PaperTrailAssociationTracking::VersionConcern) 14 | end 15 | 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/model_config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | # Configures an ActiveRecord model, mostly at application boot time, but also 5 | # sometimes mid-request, with methods like enable/disable. 6 | module ModelConfig 7 | # Set up `@model_class` for PaperTrail. Installs callbacks, associations, 8 | # "class attributes", instance methods, and more. 9 | # @api private 10 | def setup(options = {}) 11 | super 12 | 13 | @model_class.class_attribute :version_association_class_name 14 | @model_class.version_association_class_name = options.dig(:version_associations, :class_name) || "PaperTrail::VersionAssociation" 15 | 16 | setup_transaction_callbacks 17 | setup_callbacks_for_habtm(options[:join_tables]) 18 | end 19 | 20 | def version_association_class 21 | @version_association_class ||= @model_class.version_association_class_name.constantize 22 | end 23 | 24 | private 25 | 26 | # Raises an error if the provided class is an `abstract_class`. 27 | # @api private 28 | def assert_concrete_activerecord_class(class_name) 29 | if class_name.constantize.abstract_class? 30 | raise format(::PaperTrail::ModelConfig::E_HPT_ABSTRACT_CLASS, @model_class, class_name) 31 | end 32 | end 33 | 34 | def habtm_assocs_not_skipped 35 | @model_class.reflect_on_all_associations(:has_and_belongs_to_many). 36 | reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) } 37 | end 38 | 39 | # Adds callbacks to record changes to habtm associations such that on save 40 | # the previous version of the association (if changed) can be reconstructed. 41 | def setup_callbacks_for_habtm(join_tables) 42 | @model_class.send :attr_accessor, :paper_trail_habtm 43 | @model_class.class_attribute :paper_trail_save_join_tables 44 | @model_class.paper_trail_save_join_tables = Array.wrap(join_tables) 45 | habtm_assocs_not_skipped.each(&method(:setup_habtm_change_callbacks)) 46 | end 47 | 48 | def setup_habtm_change_callbacks(association) 49 | association_name = association.name 50 | 51 | if ActiveRecord::VERSION::MAJOR >= 7 52 | ### https://github.com/westonganger/paper_trail-association_tracking/pull/37#issuecomment-1067146121 53 | 54 | before_add_callback = lambda do |*args| 55 | update_habtm_state(association_name, :before_add, args[-2], args.last) 56 | end 57 | 58 | before_remove_callback = lambda do |*args| 59 | update_habtm_state(association_name, :before_remove, args[-2], args.last) 60 | end 61 | 62 | assoc_opts = association.options.merge(before_add: before_add_callback, before_remove: before_remove_callback) 63 | 64 | association.instance_variable_set(:@options, **assoc_opts) 65 | 66 | ::ActiveRecord::Associations::Builder::CollectionAssociation.send(:define_callbacks, @model_class, association) 67 | else 68 | %w[add remove].each do |verb| 69 | @model_class.send("before_#{verb}_for_#{association_name}").send( 70 | :<<, 71 | lambda do |*args| 72 | update_habtm_state(association_name, :"before_#{verb}", args[-2], args.last) 73 | end 74 | ) 75 | end 76 | end 77 | end 78 | 79 | # Reset the transaction id when the transaction is closed. 80 | def setup_transaction_callbacks 81 | @model_class.after_commit { ::PaperTrail.request.clear_transaction_id } 82 | @model_class.after_rollback { ::PaperTrail.request.clear_transaction_id } 83 | end 84 | 85 | def update_habtm_state(name, callback, model, assoc) 86 | model.paper_trail_habtm ||= {} 87 | model.paper_trail_habtm[name] ||= { removed: [], added: [] } 88 | state = model.paper_trail_habtm[name] 89 | assoc_id = assoc.id 90 | case callback 91 | when :before_add 92 | state[:added] |= [assoc_id] 93 | state[:removed] -= [assoc_id] 94 | when :before_remove 95 | state[:removed] |= [assoc_id] 96 | state[:added] -= [assoc_id] 97 | else 98 | raise "Invalid callback: #{callback}" 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/paper_trail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module PaperTrail 5 | module ClassMethods 6 | def transaction? 7 | ::ActiveRecord::Base.connection.open_transactions.positive? 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/record_trail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module RecordTrail 5 | # Utility method for reifying. Anything executed inside the block will 6 | # appear like a new record. 7 | # 8 | # > .. as best as I can tell, the purpose of 9 | # > appear_as_new_record was to attempt to prevent the callbacks in 10 | # > AutosaveAssociation (which is the module responsible for persisting 11 | # > foreign key changes earlier than most people want most of the time 12 | # > because backwards compatibility or the maintainer hates himself or 13 | # > something) from running. By also stubbing out persisted? we can 14 | # > actually prevent those. A more stable option might be to use suppress 15 | # > instead, similar to the other branch in reify_has_one. 16 | # > -Sean Griffin (https://github.com/paper-trail-gem/paper_trail/pull/899) 17 | # 18 | # @api private 19 | def appear_as_new_record 20 | @record.instance_eval { 21 | alias :old_new_record? :new_record? 22 | alias :new_record? :present? 23 | alias :old_persisted? :persisted? 24 | alias :persisted? :nil? 25 | } 26 | yield 27 | @record.instance_eval { 28 | alias :new_record? :old_new_record? 29 | alias :persisted? :old_persisted? 30 | } 31 | end 32 | 33 | # @api private 34 | def record_create 35 | version = super 36 | if version 37 | update_transaction_id(version) 38 | save_associations(version) 39 | end 40 | end 41 | 42 | # @api private 43 | def data_for_create 44 | data = super 45 | add_transaction_id_to(data) 46 | data 47 | end 48 | 49 | # @api private 50 | def record_destroy(*args) 51 | version = super 52 | if version && version.respond_to?(:errors) && version.errors.empty? 53 | update_transaction_id(version) 54 | save_associations(version) 55 | end 56 | version 57 | end 58 | 59 | # @api private 60 | def data_for_destroy 61 | data = super 62 | add_transaction_id_to(data) 63 | data 64 | end 65 | 66 | # Returns a boolean indicating whether to store serialized version diffs 67 | # in the `object_changes` column of the version record. 68 | # @api private 69 | def record_object_changes? 70 | @record.paper_trail_options[:save_changes] && 71 | @record.class.paper_trail.version_class.column_names.include?("object_changes") 72 | end 73 | 74 | # @api private 75 | def record_update(**opts) 76 | version = super 77 | if version && version.respond_to?(:errors) && version.errors.empty? 78 | update_transaction_id(version) 79 | save_associations(version) 80 | end 81 | version 82 | end 83 | 84 | # Used during `record_update`, returns a hash of data suitable for an AR 85 | # `create`. That is, all the attributes of the nascent `Version` record. 86 | # 87 | # @api private 88 | def data_for_update(*args) 89 | data = super 90 | add_transaction_id_to(data) 91 | data 92 | end 93 | 94 | # @api private 95 | def record_update_columns(*args) 96 | version = super 97 | if version && version.respond_to?(:errors) && version.errors.empty? 98 | update_transaction_id(version) 99 | save_associations(version) 100 | end 101 | version 102 | end 103 | 104 | # Returns data for record_update_columns 105 | # @api private 106 | def data_for_update_columns(*args) 107 | data = super 108 | add_transaction_id_to(data) 109 | data 110 | end 111 | 112 | # Saves associations if the join table for `VersionAssociation` exists. 113 | def save_associations(version) 114 | return unless ::PaperTrail.config.track_associations? 115 | save_bt_associations(version) 116 | save_habtm_associations(version) 117 | end 118 | 119 | # Save all `belongs_to` associations. 120 | # @api private 121 | def save_bt_associations(version) 122 | @record.class.reflect_on_all_associations(:belongs_to).each do |assoc| 123 | save_bt_association(assoc, version) 124 | end 125 | end 126 | 127 | # When a record is created, updated, or destroyed, we determine what the 128 | # HABTM associations looked like before any changes were made, by using 129 | # the `paper_trail_habtm` data structure. Then, we create 130 | # `VersionAssociation` records for each of the associated records. 131 | # @api private 132 | def save_habtm_associations(version) 133 | @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a| 134 | next unless save_habtm_association?(a) 135 | habtm_assoc_ids(a).each do |id| 136 | @record.class.paper_trail.version_association_class.create( 137 | version_id: version.transaction_id, 138 | foreign_key_name: a.name, 139 | foreign_key_id: id, 140 | foreign_type: a.klass 141 | ) 142 | end 143 | end 144 | end 145 | 146 | private 147 | 148 | def add_transaction_id_to(data) 149 | return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id") 150 | data[:transaction_id] = ::PaperTrail.request.transaction_id 151 | end 152 | 153 | # Given a HABTM association, returns an array of ids. 154 | # 155 | # @api private 156 | def habtm_assoc_ids(habtm_assoc) 157 | current = @record.send(habtm_assoc.name).to_a.map(&:id) # TODO: `pluck` would use less memory 158 | removed = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :removed) || [] 159 | added = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :added) || [] 160 | current + removed - added 161 | end 162 | 163 | # Save a single `belongs_to` association. 164 | # @api private 165 | def save_bt_association(assoc, version) 166 | assoc_version_args = { 167 | version_id: version.id, 168 | foreign_key_name: assoc.foreign_key 169 | } 170 | 171 | if assoc.options[:polymorphic] 172 | foreign_type = @record.send(assoc.foreign_type) 173 | if foreign_type.present? && ::PaperTrail.request.enabled_for_model?(foreign_type.constantize) 174 | assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key) 175 | assoc_version_args[:foreign_type] = foreign_type 176 | end 177 | elsif ::PaperTrail.request.enabled_for_model?(assoc.klass) 178 | assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key) 179 | assoc_version_args[:foreign_type] = assoc.klass 180 | end 181 | 182 | if assoc_version_args.key?(:foreign_key_id) 183 | @record.class.paper_trail.version_association_class.create(assoc_version_args) 184 | end 185 | end 186 | 187 | # Returns true if the given HABTM association should be saved. 188 | # @api private 189 | def save_habtm_association?(assoc) 190 | @record.class.paper_trail_save_join_tables.include?(assoc.name) || 191 | ::PaperTrail.request.enabled_for_model?(assoc.klass) 192 | end 193 | 194 | def update_transaction_id(version) 195 | return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id") 196 | if ::PaperTrail.transaction? && ::PaperTrail.request.transaction_id.nil? 197 | ::PaperTrail.request.transaction_id = version.id 198 | version.transaction_id = version.id 199 | version.save 200 | end 201 | end 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifier.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "paper_trail_association_tracking/reifiers/belongs_to" 4 | require "paper_trail_association_tracking/reifiers/has_and_belongs_to_many" 5 | require "paper_trail_association_tracking/reifiers/has_many" 6 | require "paper_trail_association_tracking/reifiers/has_many_through" 7 | require "paper_trail_association_tracking/reifiers/has_one" 8 | 9 | module PaperTrailAssociationTracking 10 | # Given a version record and some options, builds a new model object. 11 | # @api private 12 | module Reifier 13 | module ClassMethods 14 | # See `VersionConcern#reify` for documentation. 15 | # @api private 16 | def reify(version, options) 17 | options = apply_defaults_to(options, version) 18 | model = super 19 | reify_associations(model, options, version) 20 | model 21 | end 22 | 23 | # Restore the `model`'s has_many associations as they were at version_at 24 | # timestamp We lookup the first child versions after version_at timestamp or 25 | # in same transaction. 26 | # @api private 27 | def reify_has_manys(transaction_id, model, options = {}) 28 | assoc_has_many_through, assoc_has_many_directly = 29 | model.class.reflect_on_all_associations(:has_many). 30 | partition { |assoc| assoc.options[:through] } 31 | reify_has_many_associations(transaction_id, assoc_has_many_directly, model, options) 32 | reify_has_many_through_associations(transaction_id, assoc_has_many_through, model, options) 33 | end 34 | 35 | private 36 | 37 | # Given a hash of `options` for `.reify`, return a new hash with default 38 | # values applied. 39 | # @api private 40 | def apply_defaults_to(options, version) 41 | { 42 | version_at: version.created_at, 43 | mark_for_destruction: false, 44 | has_one: false, 45 | has_many: false, 46 | belongs_to: false, 47 | has_and_belongs_to_many: false, 48 | unversioned_attributes: :nil 49 | }.merge(options) 50 | end 51 | 52 | # @api private 53 | def each_enabled_association(associations, model) 54 | associations.each do |assoc| 55 | assoc_klass = assoc.polymorphic? ? 56 | model.send(assoc.foreign_type).constantize : assoc.klass 57 | next unless ::PaperTrail.request.enabled_for_model?(assoc_klass) 58 | yield assoc 59 | end 60 | end 61 | 62 | # @api private 63 | def reify_associations(model, options, version) 64 | if options[:has_one] 65 | reify_has_one_associations(version.transaction_id, model, options) 66 | end 67 | if options[:belongs_to] 68 | reify_belongs_to_associations(version.transaction_id, model, options) 69 | end 70 | if options[:has_many] 71 | reify_has_manys(version.transaction_id, model, options) 72 | end 73 | if options[:has_and_belongs_to_many] 74 | reify_habtm_associations version.transaction_id, model, options 75 | end 76 | end 77 | 78 | # Restore the `model`'s has_one associations as they were when this 79 | # version was superseded by the next (because that's what the user was 80 | # looking at when they made the change). 81 | # @api private 82 | def reify_has_one_associations(transaction_id, model, options = {}) 83 | associations = model.class.reflect_on_all_associations(:has_one) 84 | each_enabled_association(associations, model) do |assoc| 85 | ::PaperTrailAssociationTracking::Reifiers::HasOne.reify(assoc, model, options, transaction_id) 86 | end 87 | end 88 | 89 | # Reify all `belongs_to` associations of `model`. 90 | # @api private 91 | def reify_belongs_to_associations(transaction_id, model, options = {}) 92 | associations = model.class.reflect_on_all_associations(:belongs_to) 93 | each_enabled_association(associations, model) do |assoc| 94 | ::PaperTrailAssociationTracking::Reifiers::BelongsTo.reify(assoc, model, options, transaction_id) 95 | end 96 | end 97 | 98 | # Reify all direct (not `through`) `has_many` associations of `model`. 99 | # @api private 100 | def reify_has_many_associations(transaction_id, associations, model, options = {}) 101 | version_table_name = model.class.paper_trail.version_class.table_name 102 | each_enabled_association(associations, model) do |assoc| 103 | ::PaperTrailAssociationTracking::Reifiers::HasMany.reify(assoc, model, options, transaction_id, version_table_name) 104 | end 105 | end 106 | 107 | # Reify all HMT associations of `model`. This must be called after the 108 | # direct (non-`through`) has_manys have been reified. 109 | # @api private 110 | def reify_has_many_through_associations(transaction_id, associations, model, options = {}) 111 | each_enabled_association(associations, model) do |assoc| 112 | ::PaperTrailAssociationTracking::Reifiers::HasManyThrough.reify(assoc, model, options, transaction_id) 113 | end 114 | end 115 | 116 | # Reify all HABTM associations of `model`. 117 | # @api private 118 | def reify_habtm_associations(transaction_id, model, options = {}) 119 | model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc| 120 | pt_enabled = ::PaperTrail.request.enabled_for_model?(assoc.klass) 121 | next unless model.class.paper_trail_save_join_tables.include?(assoc.name) || pt_enabled 122 | ::PaperTrailAssociationTracking::Reifiers::HasAndBelongsToMany.reify(pt_enabled, assoc, model, options, transaction_id) 123 | end 124 | end 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifiers/belongs_to.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Reifiers 5 | # Reify a single `belongs_to` association of `model`. 6 | # @api private 7 | module BelongsTo 8 | class << self 9 | # @api private 10 | def reify(assoc, model, options, transaction_id) 11 | id = model.send(assoc.foreign_key) 12 | klass = assoc.polymorphic? ? 13 | model.send(assoc.foreign_type).constantize : assoc.klass 14 | version = load_version(klass, id, transaction_id, options[:version_at]) 15 | record = load_record(klass, id, options, version) 16 | model.send("#{assoc.name}=".to_sym, record) 17 | end 18 | 19 | private 20 | 21 | # Given a `belongs_to` association and a `version`, return a record that 22 | # can be assigned in order to reify that association. 23 | # @api private 24 | def load_record(assoc_klass, id, options, version) 25 | if version.nil? 26 | assoc_klass.where(assoc_klass.primary_key => id).first 27 | else 28 | version.reify( 29 | options.merge( 30 | has_many: false, 31 | has_one: false, 32 | belongs_to: false, 33 | has_and_belongs_to_many: false 34 | ) 35 | ) 36 | end 37 | end 38 | 39 | # Given a `belongs_to` association and an `id`, return a version record 40 | # from the point in time identified by `transaction_id` or `version_at`. 41 | # @api private 42 | def load_version(assoc_klass, id, transaction_id, version_at) 43 | assoc_klass.paper_trail.version_class. 44 | where("item_type = ?", assoc_klass.base_class.name). 45 | where("item_id = ?", id). 46 | where("created_at >= ? OR transaction_id = ?", version_at, transaction_id). 47 | order("id").limit(1).first 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifiers/has_and_belongs_to_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Reifiers 5 | # Reify a single HABTM association of `model`. 6 | # @api private 7 | module HasAndBelongsToMany 8 | class << self 9 | # @api private 10 | def reify(pt_enabled, assoc, model, options, transaction_id) 11 | version_ids = model.class.paper_trail.version_association_class. 12 | where("foreign_key_name = ?", assoc.name). 13 | where("version_id = ?", transaction_id). 14 | pluck(:foreign_key_id) 15 | 16 | model.send(assoc.name).proxy_association.target = 17 | version_ids.map do |id| 18 | if pt_enabled 19 | version = load_version(assoc, id, transaction_id, options[:version_at]) 20 | if version 21 | next version.reify( 22 | options.merge( 23 | has_many: false, 24 | has_one: false, 25 | belongs_to: false, 26 | has_and_belongs_to_many: false 27 | ) 28 | ) 29 | end 30 | end 31 | assoc.klass.where(assoc.klass.primary_key => id).first 32 | end 33 | end 34 | 35 | private 36 | 37 | # Given a HABTM association `assoc` and an `id`, return a version record 38 | # from the point in time identified by `transaction_id` or `version_at`. 39 | # @api private 40 | def load_version(assoc, id, transaction_id, version_at) 41 | assoc.klass.paper_trail.version_class. 42 | where("item_type = ?", assoc.klass.base_class.name). 43 | where("item_id = ?", id). 44 | where("created_at >= ? OR transaction_id = ?", version_at, transaction_id). 45 | order("id"). 46 | limit(1). 47 | first 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifiers/has_many.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Reifiers 5 | # Reify a single, direct (not `through`) `has_many` association of `model`. 6 | # @api private 7 | module HasMany 8 | class << self 9 | # @api private 10 | def reify(assoc, model, options, transaction_id, version_table_name) 11 | versions = load_versions_for_hm_association( 12 | assoc, 13 | model, 14 | version_table_name, 15 | transaction_id, 16 | options[:version_at] 17 | ) 18 | collection = Array.new model.send(assoc.name).reload # to avoid cache 19 | prepare_array(collection, options, versions) 20 | model.send(assoc.name).proxy_association.target = collection 21 | end 22 | 23 | # Replaces each record in `array` with its reified version, if present 24 | # in `versions`. 25 | # 26 | # @api private 27 | # @param array - The collection to be modified. 28 | # @param options 29 | # @param versions - A `Hash` mapping IDs to `Version`s 30 | # @return nil - Always returns `nil` 31 | # 32 | # Once modified by this method, `array` will be assigned to the 33 | # AR association currently being reified. 34 | # 35 | def prepare_array(array, options, versions) 36 | # Iterate each child to replace it with the previous value if there is 37 | # a version after the timestamp. 38 | array.map! do |record| 39 | if (version = versions.delete(record.id)).nil? 40 | record 41 | elsif version.event == "create" 42 | options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil 43 | else 44 | version.reify( 45 | options.merge( 46 | has_many: false, 47 | has_one: false, 48 | belongs_to: false, 49 | has_and_belongs_to_many: false 50 | ) 51 | ) 52 | end 53 | end 54 | 55 | # Reify the rest of the versions and add them to the collection, these 56 | # versions are for those that have been removed from the live 57 | # associations. 58 | array.concat( 59 | versions.values.map { |v| 60 | v.reify( 61 | options.merge( 62 | has_many: false, 63 | has_one: false, 64 | belongs_to: false, 65 | has_and_belongs_to_many: false 66 | ) 67 | ) 68 | } 69 | ) 70 | 71 | array.compact! 72 | 73 | nil 74 | end 75 | 76 | # Given a SQL fragment that identifies the IDs of version records, 77 | # returns a `Hash` mapping those IDs to `Version`s. 78 | # 79 | # @api private 80 | # @param klass - An ActiveRecord class. 81 | # @param version_ids - Array. The IDs of version records. 82 | # @return A `Hash` mapping IDs to `Version`s 83 | # 84 | def versions_by_id(klass, version_ids) 85 | klass. 86 | paper_trail.version_class. 87 | where(id: version_ids). 88 | inject({}) { |a, e| a.merge!(e.item_id => e) } 89 | end 90 | 91 | private 92 | 93 | # Given a `has_many` association on `model`, return the version records 94 | # from the point in time identified by `tx_id` or `version_at`. 95 | # @api private 96 | def load_versions_for_hm_association(assoc, model, version_table, tx_id, version_at) 97 | # For STI models, associations may be defined to reference superclasses, so looking up 98 | # based on only the child-most class is not appropriate. 99 | sti_model_names = model.class.ancestors 100 | .select { |x| x <= model.class.base_class && x.method_defined?(assoc.name) } 101 | .map(&:name) 102 | 103 | version_ids = model.class.paper_trail.version_association_class. 104 | joins(model.class.version_association_name). 105 | select("MIN(version_id) as version_id"). 106 | where("foreign_key_name = ?", assoc.foreign_key). 107 | where("foreign_key_id = ?", model.id). 108 | where(foreign_type: sti_model_names + [nil]). 109 | where("#{version_table}.item_type = ?", assoc.klass.base_class.name). 110 | where("created_at >= ? OR transaction_id = ?", version_at, tx_id). 111 | group("item_id"). 112 | map{|e| e.version_id} 113 | versions_by_id(model.class, version_ids) 114 | end 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifiers/has_many_through.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Reifiers 5 | # Reify a single HMT association of `model`. 6 | # @api private 7 | module HasManyThrough 8 | class << self 9 | # @api private 10 | def reify(assoc, model, options, transaction_id) 11 | # Load the collection of through-models. For example, if `model` is a 12 | # Chapter, having many Paragraphs through Sections, then 13 | # `through_collection` will contain Sections. 14 | through_collection = through_collection(assoc, model, options, transaction_id) 15 | 16 | # Now, given the collection of "through" models (e.g. sections), load 17 | # the collection of "target" models (e.g. paragraphs) 18 | collection = collection(through_collection, assoc, options, transaction_id) 19 | 20 | # Finally, assign the `collection` of "target" models, e.g. to 21 | # `model.paragraphs`. 22 | model.send(assoc.name).proxy_association.target = collection 23 | end 24 | 25 | private 26 | 27 | # Examine the `through_reflection`, i.e., the "through:" option on the association. 28 | # 29 | # @api private 30 | def through_collection(assoc, model, options, transaction_id) 31 | through_reflection = assoc.through_reflection 32 | # If the through association is has_many, we can just return the reified association 33 | return model.send(assoc.options[:through]) if through_reflection.collection? 34 | 35 | # If the model wasn't reified with belongs_to: true/has_one: true, then 36 | # the through association hasn't been reified yet. 37 | unless model.association(assoc.options[:through]).loaded? 38 | if through_reflection.belongs_to? 39 | BelongsTo.reify(through_reflection, model, options, transaction_id) 40 | else 41 | HasOne.reify(through_reflection, model, options, transaction_id) 42 | end 43 | end 44 | # Wrap the association in a collection for `collection_through_has_many` 45 | [*model.send(assoc.options[:through])] 46 | end 47 | 48 | # Examine the `source_reflection`, i.e. the "source" of `assoc` the 49 | # `ThroughReflection`. The source can be a `BelongsToReflection` 50 | # or a `HasManyReflection`. 51 | # 52 | # If the association is a has_many association again, then call 53 | # reify_has_manys for each record in `through_collection`. 54 | # 55 | # @api private 56 | def collection(through_collection, assoc, options, transaction_id) 57 | if !assoc.source_reflection.belongs_to? && through_collection.present? 58 | collection_through_has_many(through_collection, assoc, options, transaction_id) 59 | else 60 | collection_through_belongs_to(through_collection, assoc, options, transaction_id) 61 | end 62 | end 63 | 64 | # @api private 65 | def collection_through_has_many(through_collection, assoc, options, transaction_id) 66 | through_collection.each do |through_model| 67 | ::PaperTrail::Reifier.reify_has_manys(transaction_id, through_model, options) 68 | end 69 | 70 | # At this point, the "through" part of the association chain has 71 | # been reified, but not the final, "target" part. To continue our 72 | # example, `model.sections` (including `model.sections.paragraphs`) 73 | # has been loaded. However, the final "target" part of the 74 | # association, that is, `model.paragraphs`, has not been loaded. So, 75 | # we do that now. 76 | through_collection.flat_map { |through_model| 77 | records = through_model.public_send(assoc.source_reflection_name) 78 | 79 | if records.respond_to?(:to_a) 80 | # Has Many association 81 | records = records.to_a 82 | else 83 | # Has One association - Nothing more to do 84 | end 85 | 86 | records 87 | } 88 | end 89 | 90 | # @api private 91 | def collection_through_belongs_to(through_collection, assoc, options, tx_id) 92 | ids = through_collection.map { |through_model| 93 | through_model.send(assoc.source_reflection.foreign_key) 94 | } 95 | versions = load_versions_for_hmt_association(assoc, ids, tx_id, options[:version_at]) 96 | collection = Array.new assoc.klass.where(assoc.klass.primary_key => ids) 97 | ::PaperTrailAssociationTracking::Reifiers::HasMany.prepare_array(collection, options, versions) 98 | collection 99 | end 100 | 101 | # Given a `has_many(through:)` association and an array of `ids`, return 102 | # the version records from the point in time identified by `tx_id` or 103 | # `version_at`. 104 | # @api private 105 | def load_versions_for_hmt_association(assoc, ids, tx_id, version_at) 106 | version_ids = assoc.klass.paper_trail.version_class. 107 | select("MIN(id) as id"). 108 | where("item_type = ?", assoc.klass.base_class.name). 109 | where("item_id IN (?)", ids). 110 | where( 111 | "created_at >= ? OR transaction_id = ?", 112 | version_at, 113 | tx_id 114 | ). 115 | group("item_id"). 116 | map{|e| e.id} 117 | ::PaperTrailAssociationTracking::Reifiers::HasMany.versions_by_id(assoc.klass, version_ids) 118 | end 119 | end 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/reifiers/has_one.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Reifiers 5 | # Reify a single `has_one` association of `model`. 6 | # @api private 7 | module HasOne 8 | # A more helpful error message, instead of the AssociationTypeMismatch 9 | # you would get if, eg. we were to try to assign a Bicycle to the :car 10 | # association (before, if there were multiple records we would just take 11 | # the first and hope for the best). 12 | # @api private 13 | class FoundMoreThanOne < RuntimeError 14 | MESSAGE_FMT = <<~STR 15 | Unable to reify has_one association. Expected to find one %s, 16 | but found %d. 17 | 18 | This is a known issue, and a good example of why association tracking 19 | is an experimental feature that should not be used in production. 20 | 21 | That said, this is a rare error. In spec/models/person_spec.rb we 22 | reproduce it by having two STI models with the same foreign_key (Car 23 | and Bicycle are both Vehicles and the FK for both is owner_id) 24 | 25 | If you'd like to help fix this error, please read 26 | https://github.com/airblade/paper_trail/issues/594 27 | and see spec/models/person_spec.rb 28 | STR 29 | 30 | def initialize(base_class_name, num_records_found) 31 | @base_class_name = base_class_name.to_s 32 | @num_records_found = num_records_found.to_i 33 | end 34 | 35 | def message 36 | format(MESSAGE_FMT, @base_class_name, @num_records_found) 37 | end 38 | end 39 | 40 | class << self 41 | # @api private 42 | def reify(assoc, model, options, transaction_id) 43 | version = load_version(assoc, model, transaction_id, options[:version_at]) 44 | return unless version 45 | if version.event == "create" 46 | create_event(assoc, model, options) 47 | else 48 | noncreate_event(assoc, model, options, version) 49 | end 50 | end 51 | 52 | private 53 | 54 | # @api private 55 | def create_event(assoc, model, options) 56 | if options[:mark_for_destruction] 57 | model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true) 58 | else 59 | model.association(assoc.name).target = nil 60 | end 61 | end 62 | 63 | # Given a has-one association `assoc` on `model`, return the version 64 | # record from the point in time identified by `transaction_id` or `version_at`. 65 | # @api private 66 | def load_version(assoc, model, transaction_id, version_at) 67 | base_class_name = assoc.klass.base_class.name 68 | versions = load_versions(assoc, model, transaction_id, version_at, base_class_name) 69 | case versions.length 70 | when 0 71 | nil 72 | when 1 73 | versions.first 74 | else 75 | case ::PaperTrail.config.association_reify_error_behaviour 76 | when "warn" 77 | version = versions.first 78 | version.logger&.warn( 79 | FoundMoreThanOne.new(base_class_name, versions.length).message 80 | ) 81 | version 82 | when "ignore" 83 | versions.first 84 | else # "error" 85 | raise FoundMoreThanOne.new(base_class_name, versions.length) 86 | end 87 | end 88 | end 89 | 90 | # @api private 91 | def load_versions(assoc, model, transaction_id, version_at, base_class_name) 92 | version_table_name = model.class.paper_trail.version_class.table_name 93 | 94 | version_ids = model.class.paper_trail.version_association_class. 95 | joins(model.class.version_association_name). 96 | where("foreign_key_name = ?", assoc.foreign_key). 97 | where("foreign_key_id = ?", model.id). 98 | where("#{version_table_name}.item_type = ?", base_class_name). 99 | where("created_at >= ? OR transaction_id = ?", version_at, transaction_id). 100 | order("version_id ASC"). 101 | pluck("version_id") 102 | 103 | model.class.paper_trail.version_class.find(version_ids) 104 | end 105 | 106 | # @api private 107 | def noncreate_event(assoc, model, options, version) 108 | child = version.reify( 109 | options.merge( 110 | has_many: false, 111 | has_one: false, 112 | belongs_to: false, 113 | has_and_belongs_to_many: false 114 | ) 115 | ) 116 | model.paper_trail.appear_as_new_record do 117 | without_persisting(child) do 118 | model.send "#{assoc.name}=", child 119 | end 120 | end 121 | end 122 | 123 | # Temporarily suppress #save so we can reassociate with the reified 124 | # master of a has_one relationship. Since ActiveRecord 5 the related 125 | # object is saved when it is assigned to the association. ActiveRecord 126 | # 5 also happens to be the first version that provides #suppress. 127 | def without_persisting(record) 128 | if record.class.respond_to? :suppress 129 | record.class.suppress { yield } 130 | else 131 | yield 132 | end 133 | end 134 | end 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module Request 5 | module ClassMethods 6 | # @api private 7 | def clear_transaction_id 8 | self.transaction_id = nil 9 | end 10 | 11 | # @api private 12 | def transaction_id 13 | store[:transaction_id] 14 | end 15 | 16 | # @api private 17 | def transaction_id=(id) 18 | store[:transaction_id] = id 19 | end 20 | 21 | private 22 | 23 | def validate_public_options(options) 24 | if options.keys.include?(:transaction_id) 25 | raise ::PaperTrail::Request::InvalidOption, "Cannot set private option: transaction_id" 26 | else 27 | super 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | VERSION = "2.3.0".freeze 5 | end 6 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/version_association_concern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | # Functionality for `PaperTrail::VersionAssociation`. Exists in a module 5 | # for the same reasons outlined in version_concern.rb. 6 | module VersionAssociationConcern 7 | extend ::ActiveSupport::Concern 8 | 9 | included do 10 | belongs_to :version 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/paper_trail_association_tracking/version_concern.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module PaperTrailAssociationTracking 4 | module VersionConcern 5 | extend ::ActiveSupport::Concern 6 | 7 | included do 8 | # Since the test suite has test coverage for this, we want to declare the association when the test suite is running. 9 | # This makes it pass when DB is not initialized prior to test runs such as when we run on Travis CI 10 | # Ex. (there won't be a db in `spec/dummy_app/db/`). 11 | if ::PaperTrail.config.track_associations? 12 | has_many :version_associations, dependent: :destroy 13 | end 14 | 15 | scope :within_transaction, ->(id) { where(transaction_id: id) } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /paper_trail-association_tracking.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.unshift File.expand_path("../lib", __FILE__) 4 | require "paper_trail_association_tracking/version" 5 | 6 | Gem::Specification.new do |s| 7 | s.name = "paper_trail-association_tracking" 8 | s.version = PaperTrailAssociationTracking::VERSION 9 | s.platform = Gem::Platform::RUBY 10 | s.summary = "Plugin for the PaperTrail gem to track and reify associations" 11 | s.description = "Plugin for the PaperTrail gem to track and reify associations" 12 | s.homepage = "https://github.com/westonganger/paper_trail-association_tracking" 13 | s.authors = ["Weston Ganger", "Jared Beck", "Ben Atkins"] 14 | s.email = "weston@westonganger.com" 15 | s.license = "MIT" 16 | 17 | s.files = Dir.glob("{lib/**/*}") + ['LICENSE', 'README.md', 'Rakefile', 'CHANGELOG.md'] 18 | 19 | s.executables = [] 20 | s.require_paths = ["lib"] 21 | 22 | s.required_ruby_version = ">= 2.6.0" 23 | 24 | s.add_runtime_dependency "paper_trail", ">= 12.0" 25 | 26 | s.add_development_dependency "generator_spec" 27 | s.add_development_dependency "rake" 28 | s.add_development_dependency "rspec-rails" 29 | s.add_development_dependency "timecop" 30 | end 31 | -------------------------------------------------------------------------------- /spec/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/westonganger/paper_trail-association_tracking/ca695b9b135714fded4f8a8b3c0438a9475bcca7/spec/.DS_Store -------------------------------------------------------------------------------- /spec/controllers/widgets_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe WidgetsController, type: :controller, versioning: true do 6 | before do 7 | end 8 | 9 | after do 10 | RequestStore.store[:paper_trail] = nil 11 | end 12 | 13 | describe "#create" do 14 | context "PT enabled" do 15 | #it "stores information like IP address in version" do 16 | # post(:create, params_wrapper(widget: { name: "Flugel" })) 17 | # widget = assigns(:widget) 18 | # expect(widget.versions.length).to(eq(1)) 19 | # expect(widget.versions.last.whodunnit.to_i).to(eq(153)) 20 | # expect(widget.versions.last.ip).to(eq("127.0.0.1")) 21 | # expect(widget.versions.last.user_agent).to(eq("Rails Testing")) 22 | #end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy_app/Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require File.expand_path("../config/application", __FILE__) 7 | 8 | Dummy::Application.load_tasks 9 | -------------------------------------------------------------------------------- /spec/dummy_app/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | protect_from_forgery 5 | 6 | # Some applications and libraries modify `current_user`. Their changes need 7 | # to be reflected in `whodunnit`, so the `set_paper_trail_whodunnit` below 8 | # must happen after this. 9 | before_action :modify_current_user 10 | 11 | # PT used to add this callback automatically. Now people are required to add 12 | # it themsevles, like this, allowing them to control the order of callbacks. 13 | # The `modify_current_user` callback above shows why this control is useful. 14 | before_action :set_paper_trail_whodunnit 15 | 16 | def rescue_action(e) 17 | raise e 18 | end 19 | 20 | # Returns id of hypothetical current user 21 | attr_reader :current_user 22 | 23 | def info_for_paper_trail 24 | { ip: request.remote_ip, user_agent: request.user_agent } 25 | end 26 | 27 | private 28 | 29 | def modify_current_user 30 | @current_user = OpenStruct.new(id: 153) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dummy_app/app/controllers/widgets_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class WidgetsController < ApplicationController 4 | def create 5 | @widget = Widget.create widget_params 6 | head :ok 7 | end 8 | 9 | def update 10 | @widget = Widget.find params[:id] 11 | @widget.update widget_params 12 | head :ok 13 | end 14 | 15 | def destroy 16 | @widget = Widget.find params[:id] 17 | @widget.destroy 18 | head :ok 19 | end 20 | 21 | private 22 | 23 | def widget_params 24 | params[:widget].permit! 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/animal.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Animal < ActiveRecord::Base 4 | has_paper_trail 5 | self.inheritance_column = "species" 6 | has_many :prey, foreign_key: :predator_id, class_name: "Hunt" 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/authorship.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Authorship < ActiveRecord::Base 4 | belongs_to :book 5 | belongs_to :author, class_name: "Person" 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/bar_habtm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class BarHabtm < ActiveRecord::Base 4 | has_and_belongs_to_many :foo_habtms 5 | has_paper_trail 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/bicycle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Bicycle < Vehicle 4 | has_paper_trail 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/bizzo.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Bizzo < ActiveRecord::Base 4 | has_paper_trail 5 | 6 | belongs_to :widget 7 | has_many :notes, as: :object, dependent: :destroy 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/book.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Book < ActiveRecord::Base 4 | has_many :authorships, dependent: :destroy 5 | has_many :authors, through: :authorships 6 | 7 | has_many :editorships, dependent: :destroy 8 | has_many :editors, through: :editorships 9 | 10 | has_many :notes, as: :object 11 | 12 | has_paper_trail 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/car.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Car < Vehicle 4 | has_paper_trail 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/cat.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Cat < Animal 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/chapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Chapter < ActiveRecord::Base 4 | has_many :sections, dependent: :destroy 5 | has_many :paragraphs, through: :sections 6 | 7 | has_many :quotations, dependent: :destroy 8 | has_many :citations, through: :quotations 9 | 10 | has_paper_trail 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/citation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Citation < ActiveRecord::Base 4 | belongs_to :quotation 5 | 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/custom_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomVersion < PaperTrail::Version 4 | has_many :version_associations, class_name: "CustomVersionAssociation", foreign_key: :version_id, dependent: :destroy 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/custom_version_association.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CustomVersionAssociation < PaperTrail::VersionAssociation 4 | belongs_to :version, class_name: "CustomVersion", foreign_key: :version_id, optional: true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/customer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Customer < ActiveRecord::Base 4 | has_many :orders, dependent: :destroy 5 | has_paper_trail 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/document.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Demonstrates a "custom versions association name". Instead of the assication 4 | # being named `versions`, it will be named `paper_trail_versions`. 5 | class Document < ActiveRecord::Base 6 | has_paper_trail( 7 | versions: {name: :paper_trail_versions}, 8 | on: %i[create update] 9 | ) 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/dog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Dog < Animal 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/editor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # to demonstrate a has_through association that does not have paper_trail enabled 4 | class Editor < ActiveRecord::Base 5 | has_many :editorships, dependent: :destroy 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/editorship.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Editorship < ActiveRecord::Base 4 | belongs_to :book 5 | belongs_to :editor 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/elephant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Elephant < Animal 4 | end 5 | 6 | # Nice! We used to have `paper_trail.disable` inside the class, which was really 7 | # misleading because it looked like a permanent, global setting. It's so much 8 | # more obvious now that we are disabling the model for this request only. Of 9 | # course, we run the PT unit tests in a single thread, and I think this setting 10 | # will affect multiple unit tests, but in a normal application, this new API is 11 | # a huge improvement. 12 | 13 | PaperTrail.request.disable_model(Elephant) 14 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/family/family.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Family 4 | class Family < ActiveRecord::Base 5 | has_paper_trail 6 | 7 | has_many :familie_lines, class_name: "::Family::FamilyLine", foreign_key: :parent_id 8 | has_many :children, class_name: "::Family::Family", foreign_key: :parent_id 9 | has_many :grandsons, through: :familie_lines 10 | has_one :mentee, class_name: "::Family::Family", foreign_key: :partner_id 11 | belongs_to :parent, class_name: "::Family::Family", foreign_key: :parent_id, optional: true 12 | belongs_to :mentor, class_name: "::Family::Family", foreign_key: :partner_id, optional: true 13 | 14 | accepts_nested_attributes_for :mentee 15 | accepts_nested_attributes_for :children 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/family/family_line.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Family 4 | class FamilyLine < ActiveRecord::Base 5 | has_paper_trail 6 | 7 | belongs_to :parent, class_name: "::Family::Family", foreign_key: :parent_id, optional: true 8 | belongs_to :grandson, class_name: "::Family::Family", foreign_key: :grandson_id, optional: true 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/fluxor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Fluxor < ActiveRecord::Base 4 | belongs_to :widget, optional: true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/foo_habtm.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class FooHabtm < ActiveRecord::Base 4 | has_and_belongs_to_many :bar_habtms 5 | accepts_nested_attributes_for :bar_habtms 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/hunt.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Hunt < ActiveRecord::Base 4 | belongs_to :predator, class_name: "Animal" 5 | belongs_to :prey, class_name: "Animal" 6 | has_paper_trail 7 | end -------------------------------------------------------------------------------- /spec/dummy_app/app/models/line_item.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LineItem < ActiveRecord::Base 4 | belongs_to :order, dependent: :destroy 5 | has_paper_trail 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/note.rb: -------------------------------------------------------------------------------- 1 | class Note < ActiveRecord::Base 2 | belongs_to :object, polymorphic: true 3 | has_paper_trail 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Order < ActiveRecord::Base 4 | belongs_to :customer 5 | has_many :line_items 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/paragraph.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Paragraph < ActiveRecord::Base 4 | belongs_to :section 5 | 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/person.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Person < ActiveRecord::Base 4 | has_many :authorships, foreign_key: :author_id, dependent: :destroy 5 | has_many :books, through: :authorships 6 | 7 | has_many :pets, foreign_key: :owner_id, dependent: :destroy 8 | has_many :animals, through: :pets 9 | has_many :dogs, class_name: "Dog", through: :pets, source: :animal 10 | has_many :cats, class_name: "Cat", through: :pets, source: :animal 11 | 12 | has_one :car, foreign_key: :owner_id 13 | has_one :bicycle, foreign_key: :owner_id 14 | 15 | has_one :thing 16 | has_one :thing_2, class_name: "Thing" 17 | 18 | has_many :notes, as: :object 19 | 20 | belongs_to :mentor, class_name: "Person", foreign_key: :mentor_id, optional: true 21 | 22 | has_paper_trail 23 | 24 | # Convert strings to TimeZone objects when assigned 25 | def time_zone=(value) 26 | if value.is_a? ActiveSupport::TimeZone 27 | super 28 | else 29 | zone = ::Time.find_zone(value) # nil if can't find time zone 30 | super zone 31 | end 32 | end 33 | 34 | # Store TimeZone objects as strings when serialized to database 35 | class TimeZoneSerializer 36 | class << self 37 | def dump(zone) 38 | zone.try(:name) 39 | end 40 | 41 | def load(value) 42 | ::Time.find_zone(value) 43 | end 44 | end 45 | 46 | def dump(zone) 47 | self.class.dump(zone) 48 | end 49 | 50 | def load(value) 51 | self.class.load(value) 52 | end 53 | end 54 | 55 | if Rails::VERSION::STRING.to_f <= 6.0 56 | serialize :time_zone, TimeZoneSerializer 57 | else 58 | serialize :time_zone, coder: TimeZoneSerializer 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/pet.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Pet < ActiveRecord::Base 4 | belongs_to :owner, class_name: "Person", optional: true 5 | belongs_to :animal, optional: true 6 | 7 | has_paper_trail 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/quotation.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Quotation < ActiveRecord::Base 4 | belongs_to :chapter 5 | has_many :citations, dependent: :destroy 6 | has_paper_trail 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/section.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Section < ActiveRecord::Base 4 | belongs_to :chapter 5 | has_many :paragraphs, dependent: :destroy 6 | 7 | has_paper_trail 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/thing.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Thing < ActiveRecord::Base 4 | has_paper_trail save_changes: false 5 | 6 | belongs_to :person, optional: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ActiveRecord::Base 4 | has_paper_trail 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/vehicle.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Vehicle < ActiveRecord::Base 4 | # This STI parent class specifically does not call `has_paper_trail`. 5 | # Of its sub-classes, only `Car` and `Bicycle` are versioned. 6 | 7 | belongs_to :owner, class_name: "Person", optional: true 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/whatchamajigger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Whatchamajigger < ActiveRecord::Base 4 | has_paper_trail 5 | 6 | belongs_to :owner, polymorphic: true, optional: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/widget.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Widget < ActiveRecord::Base 4 | EXCLUDED_NAME = "Biglet" 5 | has_paper_trail 6 | has_one :wotsit 7 | has_one :bizzo, dependent: :destroy 8 | has_many(:fluxors, -> { order(:name) }) 9 | has_many :whatchamajiggers, as: :owner 10 | has_many :notes, through: :bizzo 11 | validates :name, exclusion: { in: [EXCLUDED_NAME] } 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy_app/app/models/wotsit.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Wotsit < ActiveRecord::Base 4 | has_paper_trail versions: { class_name: "CustomVersion" }, version_associations: { class_name: "CustomVersionAssociation" } 5 | 6 | belongs_to :widget, optional: true 7 | 8 | def created_on 9 | created_at.to_date 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy_app/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require ::File.expand_path("../config/environment", __FILE__) 6 | run Dummy::Application 7 | -------------------------------------------------------------------------------- /spec/dummy_app/config/application.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require File.expand_path("../boot", __FILE__) 4 | 5 | require "logger" # Fix for Rails 7.0 and below, https://github.com/rails/rails/pull/54264 6 | 7 | # Pick the frameworks you want: 8 | require "active_record/railtie" 9 | require "action_controller/railtie" 10 | 11 | Bundler.require(:default, Rails.env) 12 | require "paper_trail" 13 | 14 | module Dummy 15 | class Application < Rails::Application 16 | config.encoding = "utf-8" 17 | config.filter_parameters += [:password] 18 | config.active_support.escape_html_entities_in_json = true 19 | config.active_support.test_order = :sorted 20 | 21 | config.secret_key_base = "A fox regularly kicked the screaming pile of biscuits." 22 | 23 | config.active_record.use_yaml_unsafe_load = true 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/dummy_app/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Unlike a conventional Rails app, our "dummy" app is booted by our spec_helper. 2 | -------------------------------------------------------------------------------- /spec/dummy_app/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | <% if ENV["DB_GEM"] == "mysql2" %> 3 | adapter: mysql2 4 | database: paper_trail_association_tracking_test 5 | <% elsif ENV["DB_GEM"] == "pg" %> 6 | adapter: postgresql 7 | database: paper_trail_association_tracking_test 8 | <% else %> 9 | adapter: sqlite3 10 | database: db/test.sqlite3 11 | <% end %> 12 | 13 | development: 14 | <<: *default 15 | 16 | test: 17 | <<: *default 18 | -------------------------------------------------------------------------------- /spec/dummy_app/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the rails application 4 | require File.expand_path("../application", __FILE__) 5 | 6 | # Initialize the rails application 7 | Dummy::Application.initialize! 8 | -------------------------------------------------------------------------------- /spec/dummy_app/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dummy::Application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb 5 | 6 | # The test environment is used exclusively to run your application's 7 | # test suite. You never need to work with it otherwise. Remember that 8 | # your test database is "scratch space" for the test suite and is wiped 9 | # and recreated between test runs. Don't rely on the data there! 10 | config.cache_classes = true 11 | 12 | # Eager loads all registered namespaces 13 | config.eager_load = true 14 | 15 | if config.respond_to?(:public_file_server) 16 | config.public_file_server.enabled = true 17 | elsif config.respond_to?(:serve_static_files=) 18 | config.serve_static_files = true 19 | else 20 | config.serve_static_assets = true 21 | end 22 | 23 | if config.respond_to?(:public_file_server) 24 | config.public_file_server.headers = { 25 | "Cache-Control" => "public, max-age=3600" 26 | } 27 | else 28 | config.static_cache_control = "public, max-age=3600" 29 | end 30 | 31 | # Show full error reports and disable caching 32 | config.consider_all_requests_local = true 33 | config.action_controller.perform_caching = false 34 | 35 | # Raise exceptions instead of rendering exception templates 36 | config.action_dispatch.show_exceptions = false 37 | 38 | # Disable request forgery protection in test environment 39 | config.action_controller.allow_forgery_protection = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | # config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr 47 | config.active_support.deprecation = :stderr 48 | end 49 | -------------------------------------------------------------------------------- /spec/dummy_app/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # You can add backtrace silencers for libraries that you're using but don't wish 5 | # to see in your backtraces. 6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 7 | 8 | # You can also remove all the silencers if you're trying to debug a problem that 9 | # might stem from framework code. 10 | # Rails.backtrace_cleaner.remove_silencers! 11 | -------------------------------------------------------------------------------- /spec/dummy_app/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new inflection rules using the following format 5 | # (all these examples are active by default): 6 | # ActiveSupport::Inflector.inflections do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | -------------------------------------------------------------------------------- /spec/dummy_app/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | # Be sure to restart your server when you modify this file. 3 | 4 | # Add new mime types for use in respond_to blocks: 5 | # Mime::Type.register "text/richtext", :rtf 6 | # Mime::Type.register_alias "text/html", :iphone 7 | -------------------------------------------------------------------------------- /spec/dummy_app/config/initializers/paper_trail.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | PaperTrail.config.track_associations = true 4 | -------------------------------------------------------------------------------- /spec/dummy_app/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Be sure to restart your server when you modify this file. 4 | 5 | Dummy::Application.config.session_store :cookie_store, key: "_dummy_session" 6 | 7 | # Use the database for sessions instead of the cookie-based default, 8 | # which shouldn't be used to store highly confidential information 9 | # (create the session table with "rails generate session_migration") 10 | # Dummy::Application.config.session_store :active_record_store 11 | -------------------------------------------------------------------------------- /spec/dummy_app/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Sample localization file for English. Add more files in this directory for other locales. 2 | # See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. 3 | 4 | en: 5 | hello: "Hello world" 6 | -------------------------------------------------------------------------------- /spec/dummy_app/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Dummy::Application.routes.draw do 4 | resources :articles, only: [:create] 5 | resources :widgets, only: %i[create update destroy] 6 | end 7 | -------------------------------------------------------------------------------- /spec/dummy_app/db/migrate/20110208155312_set_up_test_tables.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Parts of this migration must be kept in sync with 4 | # `lib/generators/paper_trail/templates/create_versions.rb` 5 | # 6 | # Starting with AR 5.1, we must specify which version of AR we are using. 7 | # I tried using `const_get` but I got a `NameError`, then I learned about 8 | # `::ActiveRecord::Migration::Current`. 9 | class SetUpTestTables < ::ActiveRecord::Migration::Current 10 | MYSQL_ADAPTERS = [ 11 | "ActiveRecord::ConnectionAdapters::MysqlAdapter", 12 | "ActiveRecord::ConnectionAdapters::Mysql2Adapter" 13 | ].freeze 14 | TEXT_BYTES = 1_073_741_823 15 | 16 | def up 17 | # Classes: Vehicle, Car 18 | create_table :vehicles, force: true do |t| 19 | t.string :name, null: false 20 | t.string :type, null: false 21 | t.integer :owner_id 22 | t.timestamps null: false, limit: 6 23 | end 24 | 25 | create_table :widgets, force: true do |t| 26 | t.string :name 27 | t.text :a_text 28 | t.integer :an_integer 29 | t.float :a_float 30 | t.decimal :a_decimal, precision: 6, scale: 4 31 | t.datetime :a_datetime, limit: 6 32 | t.time :a_time 33 | t.date :a_date 34 | t.boolean :a_boolean 35 | t.string :type 36 | t.timestamps null: true, limit: 6 37 | end 38 | 39 | if ENV["DB"] == "postgres" 40 | create_table :postgres_users, force: true do |t| 41 | t.string :name 42 | t.integer :post_ids, array: true 43 | t.datetime :login_times, array: true, limit: 6 44 | t.timestamps null: true, limit: 6 45 | end 46 | end 47 | 48 | create_table :versions, **versions_table_options do |t| 49 | t.string :item_type, item_type_options 50 | t.integer :item_id, null: false 51 | t.string :event, null: false 52 | t.string :whodunnit 53 | t.text :object, limit: TEXT_BYTES 54 | t.text :object_changes, limit: TEXT_BYTES 55 | t.integer :transaction_id 56 | t.datetime :created_at, limit: 6 57 | 58 | # Metadata columns. 59 | t.integer :answer 60 | t.string :action 61 | t.string :question 62 | t.integer :article_id 63 | t.string :title 64 | 65 | # Controller info columns. 66 | t.string :ip 67 | t.string :user_agent 68 | end 69 | add_index :versions, %i[item_type item_id] 70 | 71 | create_table :version_associations do |t| 72 | t.integer :version_id 73 | t.string :foreign_key_name, null: false 74 | t.integer :foreign_key_id 75 | t.string :foreign_type 76 | end 77 | add_index :version_associations, [:version_id] 78 | add_index :version_associations, 79 | %i[foreign_key_name foreign_key_id foreign_type], 80 | name: "index_version_associations_on_foreign_key" 81 | 82 | create_table :wotsits, force: true do |t| 83 | t.integer :widget_id 84 | t.string :name 85 | t.timestamps null: true, limit: 6 86 | end 87 | 88 | create_table :bizzos, force: true do |t| 89 | t.integer :widget_id 90 | t.string :name 91 | end 92 | 93 | create_table :fluxors, force: true do |t| 94 | t.integer :widget_id 95 | t.string :name 96 | end 97 | 98 | create_table :whatchamajiggers, force: true do |t| 99 | t.string :owner_type 100 | t.integer :owner_id 101 | t.string :name 102 | end 103 | 104 | create_table :articles, force: true do |t| 105 | t.string :title 106 | t.string :content 107 | t.string :abstract 108 | t.string :file_upload 109 | end 110 | 111 | create_table :books, force: true do |t| 112 | t.string :title 113 | end 114 | 115 | create_table :authorships, force: true do |t| 116 | t.integer :book_id 117 | t.integer :author_id 118 | end 119 | 120 | create_table :people, force: true do |t| 121 | t.string :name 122 | t.string :time_zone 123 | t.integer :mentor_id 124 | end 125 | 126 | create_table :editorships, force: true do |t| 127 | t.integer :book_id 128 | t.integer :editor_id 129 | end 130 | 131 | create_table :editors, force: true do |t| 132 | t.string :name 133 | end 134 | 135 | create_table :animals, force: true do |t| 136 | t.string :name 137 | t.string :species # single table inheritance column 138 | end 139 | 140 | create_table :pets, force: true do |t| 141 | t.integer :owner_id 142 | t.integer :animal_id 143 | end 144 | 145 | create_table :things, force: true do |t| 146 | t.string :name 147 | t.references :person 148 | end 149 | 150 | create_table :gadgets, force: true do |t| 151 | t.string :name 152 | t.string :brand 153 | t.timestamps null: true, limit: 6 154 | end 155 | 156 | create_table :customers, force: true do |t| 157 | t.string :name 158 | end 159 | 160 | create_table :orders, force: true do |t| 161 | t.integer :customer_id 162 | t.string :order_date 163 | end 164 | 165 | create_table :line_items, force: true do |t| 166 | t.integer :order_id 167 | t.string :product 168 | end 169 | 170 | create_table :chapters, force: true do |t| 171 | t.string :name 172 | end 173 | 174 | create_table :sections, force: true do |t| 175 | t.integer :chapter_id 176 | t.string :name 177 | end 178 | 179 | create_table :paragraphs, force: true do |t| 180 | t.integer :section_id 181 | t.string :name 182 | end 183 | 184 | create_table :quotations, force: true do |t| 185 | t.integer :chapter_id 186 | end 187 | 188 | create_table :citations, force: true do |t| 189 | t.integer :quotation_id 190 | end 191 | 192 | create_table :foo_habtms, force: true do |t| 193 | t.string :name 194 | end 195 | 196 | create_table :bar_habtms, force: true do |t| 197 | t.string :name 198 | end 199 | 200 | create_table :bar_habtms_foo_habtms, force: true, id: false do |t| 201 | t.integer :foo_habtm_id 202 | t.integer :bar_habtm_id 203 | end 204 | add_index :bar_habtms_foo_habtms, [:foo_habtm_id] 205 | add_index :bar_habtms_foo_habtms, [:bar_habtm_id] 206 | 207 | create_table :family_lines do |t| 208 | t.integer :parent_id 209 | t.integer :grandson_id 210 | end 211 | 212 | create_table :families do |t| 213 | t.string :name 214 | t.integer :parent_id 215 | t.integer :partner_id 216 | end 217 | 218 | create_table :notes, force: true do |t| 219 | t.string :body 220 | t.string :object_type 221 | t.integer :object_id 222 | end 223 | 224 | create_table :hunts, force: true do |t| 225 | t.integer :predator_id 226 | t.integer :prey_id 227 | end 228 | end 229 | 230 | def down 231 | # Not actually irreversible, but there is no need to maintain this method. 232 | raise ActiveRecord::IrreversibleMigration 233 | end 234 | 235 | private 236 | 237 | def item_type_options 238 | opt = { null: false } 239 | opt[:limit] = 191 if mysql? 240 | opt 241 | end 242 | 243 | def mysql? 244 | MYSQL_ADAPTERS.include?(connection.class.name) 245 | end 246 | 247 | def versions_table_options 248 | if mysql? 249 | { options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" } 250 | else 251 | {} 252 | end 253 | end 254 | end 255 | -------------------------------------------------------------------------------- /spec/dummy_app/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 20110208155312) do 14 | 15 | create_table "animals", force: :cascade do |t| 16 | t.string "name" 17 | t.string "species" 18 | end 19 | 20 | create_table "articles", force: :cascade do |t| 21 | t.string "title" 22 | t.string "content" 23 | t.string "abstract" 24 | t.string "file_upload" 25 | end 26 | 27 | create_table "authorships", force: :cascade do |t| 28 | t.integer "book_id" 29 | t.integer "author_id" 30 | end 31 | 32 | create_table "banana_versions", force: :cascade do |t| 33 | t.string "item_type", null: false 34 | t.integer "item_id", null: false 35 | t.string "event", null: false 36 | t.string "whodunnit" 37 | t.text "object" 38 | t.datetime "created_at", limit: 6 39 | t.index ["item_type", "item_id"], name: "index_banana_versions_on_item_type_and_item_id" 40 | end 41 | 42 | create_table "bananas", force: :cascade do |t| 43 | t.datetime "created_at", limit: 6 44 | t.datetime "updated_at", limit: 6 45 | end 46 | 47 | create_table "bar_habtms", force: :cascade do |t| 48 | t.string "name" 49 | end 50 | 51 | create_table "bar_habtms_foo_habtms", id: false, force: :cascade do |t| 52 | t.integer "foo_habtm_id" 53 | t.integer "bar_habtm_id" 54 | t.index ["bar_habtm_id"], name: "index_bar_habtms_foo_habtms_on_bar_habtm_id" 55 | t.index ["foo_habtm_id"], name: "index_bar_habtms_foo_habtms_on_foo_habtm_id" 56 | end 57 | 58 | create_table "bizzos", force: :cascade do |t| 59 | t.integer "widget_id" 60 | t.string "name" 61 | end 62 | 63 | create_table "books", force: :cascade do |t| 64 | t.string "title" 65 | end 66 | 67 | create_table "boolits", force: :cascade do |t| 68 | t.string "name" 69 | t.boolean "scoped", default: true 70 | end 71 | 72 | create_table "callback_modifiers", force: :cascade do |t| 73 | t.string "some_content" 74 | t.boolean "deleted", default: false 75 | end 76 | 77 | create_table "chapters", force: :cascade do |t| 78 | t.string "name" 79 | end 80 | 81 | create_table "citations", force: :cascade do |t| 82 | t.integer "quotation_id" 83 | end 84 | 85 | create_table "custom_primary_key_record_versions", force: :cascade do |t| 86 | t.string "item_type", null: false 87 | t.string "item_id", null: false 88 | t.string "event", null: false 89 | t.string "whodunnit" 90 | t.text "object" 91 | t.datetime "created_at", limit: 6 92 | t.index ["item_type", "item_id"], name: "idx_cust_pk_item" 93 | end 94 | 95 | create_table "custom_primary_key_records", primary_key: "uuid", id: :string, force: :cascade do |t| 96 | t.string "name" 97 | t.index ["uuid"], unique: true 98 | t.datetime "created_at", limit: 6 99 | t.datetime "updated_at", limit: 6 100 | end 101 | 102 | create_table "customers", force: :cascade do |t| 103 | t.string "name" 104 | end 105 | 106 | create_table "documents", force: :cascade do |t| 107 | t.string "name" 108 | end 109 | 110 | create_table "editors", force: :cascade do |t| 111 | t.string "name" 112 | end 113 | 114 | create_table "editorships", force: :cascade do |t| 115 | t.integer "book_id" 116 | t.integer "editor_id" 117 | end 118 | 119 | create_table "families", force: :cascade do |t| 120 | t.string "name" 121 | t.integer "parent_id" 122 | t.integer "partner_id" 123 | end 124 | 125 | create_table "family_lines", force: :cascade do |t| 126 | t.integer "parent_id" 127 | t.integer "grandson_id" 128 | end 129 | 130 | create_table "fluxors", force: :cascade do |t| 131 | t.integer "widget_id" 132 | t.string "name" 133 | end 134 | 135 | create_table "foo_habtms", force: :cascade do |t| 136 | t.string "name" 137 | end 138 | 139 | create_table "fruits", force: :cascade do |t| 140 | t.string "name" 141 | t.string "color" 142 | end 143 | 144 | create_table "gadgets", force: :cascade do |t| 145 | t.string "name" 146 | t.string "brand" 147 | t.datetime "created_at", limit: 6 148 | t.datetime "updated_at", limit: 6 149 | end 150 | 151 | create_table "legacy_widgets", force: :cascade do |t| 152 | t.string "name" 153 | t.integer "version" 154 | end 155 | 156 | create_table "line_items", force: :cascade do |t| 157 | t.integer "order_id" 158 | t.string "product" 159 | end 160 | 161 | create_table "not_on_updates", force: :cascade do |t| 162 | t.datetime "created_at", limit: 6 163 | t.datetime "updated_at", limit: 6 164 | end 165 | 166 | create_table "on_create", force: :cascade do |t| 167 | t.string "name", null: false 168 | end 169 | 170 | create_table "on_destroy", force: :cascade do |t| 171 | t.string "name", null: false 172 | end 173 | 174 | create_table "on_empty_array", force: :cascade do |t| 175 | t.string "name", null: false 176 | end 177 | 178 | create_table "on_touch", force: :cascade do |t| 179 | t.string "name", null: false 180 | end 181 | 182 | create_table "on_update", force: :cascade do |t| 183 | t.string "name", null: false 184 | end 185 | 186 | create_table "orders", force: :cascade do |t| 187 | t.integer "customer_id" 188 | t.string "order_date" 189 | end 190 | 191 | create_table "paragraphs", force: :cascade do |t| 192 | t.integer "section_id" 193 | t.string "name" 194 | end 195 | 196 | create_table "people", force: :cascade do |t| 197 | t.string "name" 198 | t.string "time_zone" 199 | t.integer "mentor_id" 200 | end 201 | 202 | create_table "pets", force: :cascade do |t| 203 | t.integer "owner_id" 204 | t.integer "animal_id" 205 | end 206 | 207 | create_table "post_versions", force: :cascade do |t| 208 | t.string "item_type", null: false 209 | t.integer "item_id", null: false 210 | t.string "event", null: false 211 | t.string "whodunnit" 212 | t.text "object" 213 | t.datetime "created_at", limit: 6 214 | t.string "ip" 215 | t.string "user_agent" 216 | t.index ["item_type", "item_id"], name: "index_post_versions_on_item_type_and_item_id" 217 | end 218 | 219 | create_table "post_with_statuses", force: :cascade do |t| 220 | t.integer "status" 221 | t.datetime "created_at", null: false, limit: 6 222 | t.datetime "updated_at", null: false, limit: 6 223 | end 224 | 225 | create_table "posts", force: :cascade do |t| 226 | t.string "title" 227 | t.string "content" 228 | end 229 | 230 | create_table "quotations", force: :cascade do |t| 231 | t.integer "chapter_id" 232 | end 233 | 234 | create_table "sections", force: :cascade do |t| 235 | t.integer "chapter_id" 236 | t.string "name" 237 | end 238 | 239 | create_table "skippers", force: :cascade do |t| 240 | t.string "name" 241 | t.datetime "another_timestamp", limit: 6 242 | t.datetime "created_at", limit: 6 243 | t.datetime "updated_at", limit: 6 244 | end 245 | 246 | create_table "songs", force: :cascade do |t| 247 | t.integer "length" 248 | end 249 | 250 | create_table "things", force: :cascade do |t| 251 | t.string "name" 252 | end 253 | 254 | create_table "translations", force: :cascade do |t| 255 | t.string "headline" 256 | t.string "content" 257 | t.string "language_code" 258 | t.string "type" 259 | end 260 | 261 | create_table "vehicles", force: :cascade do |t| 262 | t.string "name", null: false 263 | t.string "type", null: false 264 | t.integer "owner_id" 265 | t.datetime "created_at", null: false, limit: 6 266 | t.datetime "updated_at", null: false, limit: 6 267 | end 268 | 269 | create_table "version_associations", force: :cascade do |t| 270 | t.integer "version_id" 271 | t.string "foreign_key_name", null: false 272 | t.integer "foreign_key_id" 273 | t.index ["foreign_key_name", "foreign_key_id"], name: "index_version_associations_on_foreign_key" 274 | t.index ["version_id"], name: "index_version_associations_on_version_id" 275 | end 276 | 277 | create_table "versions", force: :cascade do |t| 278 | t.string "item_type", null: false 279 | t.integer "item_id", null: false 280 | t.string "event", null: false 281 | t.string "whodunnit" 282 | t.text "object", limit: 1073741823 283 | t.text "object_changes", limit: 1073741823 284 | t.integer "transaction_id" 285 | t.datetime "created_at", limit: 6 286 | t.integer "answer" 287 | t.string "action" 288 | t.string "question" 289 | t.integer "article_id" 290 | t.string "title" 291 | t.string "ip" 292 | t.string "user_agent" 293 | t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" 294 | end 295 | 296 | create_table "whatchamajiggers", force: :cascade do |t| 297 | t.string "owner_type" 298 | t.integer "owner_id" 299 | t.string "name" 300 | end 301 | 302 | create_table "widgets", force: :cascade do |t| 303 | t.string "name" 304 | t.text "a_text" 305 | t.integer "an_integer" 306 | t.float "a_float" 307 | t.decimal "a_decimal", precision: 6, scale: 4 308 | t.datetime "a_datetime", limit: 6 309 | t.time "a_time" 310 | t.date "a_date" 311 | t.boolean "a_boolean" 312 | t.string "type" 313 | t.datetime "created_at", limit: 6 314 | t.datetime "updated_at", limit: 6 315 | end 316 | 317 | create_table "wotsits", force: :cascade do |t| 318 | t.integer "widget_id" 319 | t.string "name" 320 | t.datetime "created_at", limit: 6 321 | t.datetime "updated_at", limit: 6 322 | end 323 | 324 | end 325 | -------------------------------------------------------------------------------- /spec/generators/paper_trail_association_tracking/add_foreign_type_to_version_associations_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generator_spec/test_case" 5 | require File.expand_path("../../../../lib/generators/paper_trail_association_tracking/add_foreign_type_to_version_associations_generator", __FILE__) 6 | 7 | RSpec.describe PaperTrailAssociationTracking::AddForeignTypeToVersionAssociationsGenerator, type: :generator do 8 | include GeneratorSpec::TestCase 9 | destination File.expand_path("../tmp", __FILE__) 10 | 11 | after do 12 | prepare_destination # cleanup the tmp directory 13 | end 14 | 15 | describe "no options" do 16 | before do 17 | prepare_destination 18 | run_generator 19 | end 20 | 21 | it "generates a migration for adding the 'foreign_type' column to the 'version_associations' table" do 22 | expected_parent_class = lambda { 23 | ar_version = ActiveRecord::VERSION 24 | format("%s[%d.%d]", "ActiveRecord::Migration", ar_version::MAJOR, ar_version::MINOR) 25 | }.call 26 | 27 | expect(destination_root).to( 28 | have_structure { 29 | directory("db") { 30 | directory("migrate") { 31 | migration("add_foreign_type_to_version_associations") { 32 | contains("class AddForeignTypeToVersionAssociations < " + expected_parent_class) 33 | contains "def self.up" 34 | contains "add_column :version_associations, :foreign_type" 35 | } 36 | } 37 | } 38 | } 39 | ) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/generators/paper_trail_association_tracking/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "generator_spec/test_case" 5 | require File.expand_path("../../../../lib/generators/paper_trail_association_tracking/install_generator", __FILE__) 6 | 7 | RSpec.describe PaperTrailAssociationTracking::InstallGenerator, type: :generator do 8 | include GeneratorSpec::TestCase 9 | destination File.expand_path("../tmp", __FILE__) 10 | 11 | after do 12 | prepare_destination # cleanup the tmp directory 13 | end 14 | 15 | describe "no options" do 16 | before do 17 | prepare_destination 18 | run_generator 19 | end 20 | 21 | it "generates a migration for creating the 'versions' table" do 22 | expected_parent_class = lambda { 23 | ar_version = ActiveRecord::VERSION 24 | format("%s[%d.%d]", "ActiveRecord::Migration", ar_version::MAJOR, ar_version::MINOR) 25 | }.call 26 | 27 | expect(destination_root).to( 28 | have_structure { 29 | directory("db") { 30 | directory("migrate") { 31 | migration("create_version_associations") { 32 | contains("class CreateVersionAssociations < " + expected_parent_class) 33 | contains "def self.up" 34 | contains "create_table :version_associations" 35 | } 36 | } 37 | } 38 | } 39 | ) 40 | 41 | expect(destination_root).to( 42 | have_structure { 43 | directory("db") { 44 | directory("migrate") { 45 | migration("add_transaction_id_column_to_versions") { 46 | contains("class AddTransactionIdColumnToVersions < " + expected_parent_class) 47 | contains "def self.up" 48 | contains "add_column :versions, :transaction_id," 49 | } 50 | } 51 | } 52 | } 53 | ) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/models/family/family_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module Family 6 | RSpec.describe Family, type: :model, versioning: true do 7 | describe "#reify" do 8 | context "belongs_to" do 9 | it "uses the correct item_type in queries" do 10 | parent = described_class.new(name: "parent1") 11 | parent.children.build(name: "child1") 12 | parent.save! 13 | Timecop.travel(1.second.since) 14 | parent.update!( 15 | name: "parent2", 16 | children_attributes: { id: parent.children.first.id, name: "child2" } 17 | ) 18 | last_child_version = parent.children.first.versions.last 19 | 20 | # We expect `reify` to look for item_type 'Family::Family', not 21 | # '::Family::Family'. See PR #996 22 | previous_children = last_child_version.reify(belongs_to: true) 23 | expect(previous_children.parent.name).to eq "parent1" 24 | end 25 | end 26 | 27 | context "has_many" do 28 | it "uses the correct item_type in queries" do 29 | parent = described_class.new(name: "parent1") 30 | parent.children.build(name: "child1") 31 | parent.save! 32 | Timecop.travel(1.second.since) 33 | parent.name = "parent2" 34 | parent.children.build(name: "child2") 35 | parent.save! 36 | 37 | # We expect `reify` to look for item_type 'Family::Family', not 38 | # '::Family::Family'. See PR #996 39 | previous_parent = parent.versions.last.reify(has_many: true) 40 | previous_children = previous_parent.children 41 | expect(previous_children.size).to eq 1 42 | expect(previous_children.first.name).to eq "child1" 43 | end 44 | end 45 | 46 | context "has_many through" do 47 | it "uses the correct item_type in queries" do 48 | parent = described_class.new(name: "parent1") 49 | parent.grandsons.build(name: "grandson1") 50 | parent.save! 51 | Timecop.travel(1.second.since) 52 | parent.name = "parent2" 53 | parent.grandsons.build(name: "grandson2") 54 | parent.save! 55 | 56 | # We expect `reify` to look for item_type 'Family::Family', not 57 | # '::Family::Family'. See PR #996 58 | previous_parent = parent.versions.last.reify(has_many: true) 59 | previous_grandsons = previous_parent.grandsons 60 | expect(previous_grandsons.size).to eq 1 61 | expect(previous_grandsons.first.name).to eq "grandson1" 62 | end 63 | end 64 | 65 | context "has_one" do 66 | it "uses the correct item_type in queries" do 67 | parent = described_class.new(name: "parent1") 68 | parent.build_mentee(name: "partner1") 69 | parent.save! 70 | Timecop.travel(1.second.since) 71 | parent.update( 72 | name: "parent2", 73 | mentee_attributes: { id: parent.mentee.id, name: "partner2" } 74 | ) 75 | 76 | # We expect `reify` to look for item_type 'Family::Family', not 77 | # '::Family::Family'. See PR #996 78 | previous_parent = parent.versions.last.reify(has_one: true) 79 | previous_partner = previous_parent.mentee 80 | expect(previous_partner.name).to eq "partner1" 81 | end 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/models/note_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Note, type: :model, versioning: true do 6 | it "baseline test setup" do 7 | expect(Note.new).to be_versioned 8 | end 9 | 10 | describe "#object" do 11 | it "can be reified" do 12 | person = Person.create!(name: "Marielle") 13 | note = Note.create!(body: "Note on Marielle", object: person) 14 | 15 | note.update!(body: "Modified note") 16 | person.update!(name: "Modified") 17 | 18 | reified_note = note.versions.last.reify(belongs_to: true) 19 | expect(reified_note.body).to eq("Note on Marielle") 20 | expect(reified_note.object.name).to eq("Marielle") 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/models/person_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Person, type: :model, versioning: true do 6 | it "baseline test setup" do 7 | expect(Person.new).to be_versioned 8 | end 9 | 10 | describe "#cars and bicycles" do 11 | it "can be reified" do 12 | person = Person.create(name: "Frank") 13 | car = Car.create(name: "BMW 325") 14 | bicycle = Bicycle.create(name: "BMX 1.0") 15 | 16 | person.car = car 17 | person.bicycle = bicycle 18 | person.update(name: "Steve") 19 | 20 | car.update(name: "BMW 330") 21 | bicycle.update(name: "BMX 2.0") 22 | person.update(name: "Peter") 23 | 24 | expect(person.reload.versions.length).to(eq(3)) 25 | 26 | # See https://github.com/airblade/paper_trail/issues/594 27 | expect { 28 | person.reload.versions.second.reify(has_one: true) 29 | }.to( 30 | raise_error(::PaperTrailAssociationTracking::Reifiers::HasOne::FoundMoreThanOne) do |err| 31 | expect(err.message.squish).to match( 32 | /Expected to find one Vehicle, but found 2/ 33 | ) 34 | end 35 | ) 36 | end 37 | end 38 | 39 | describe "#notes" do 40 | it "can be reified" do 41 | person = Person.create!(name: "Jessica") 42 | book = Book.create!(title: "La Chute") 43 | person_note = Note.create!(body: "Some note on person", object: person) 44 | book_note = Note.create!(body: "Some note on book", object: book) 45 | 46 | person.update!(name: "Jennyfer") 47 | book_note.update!(body: "Modified note on book") 48 | person_note.update!(body: "Modified note on person") 49 | 50 | reified_person = person.versions.last.reify(has_many: true) 51 | expect(reified_person.notes.length).to eq(1) 52 | expect(reified_person.notes.first.body).to eq("Some note on person") 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /spec/models/pet_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Pet, type: :model, versioning: true do 6 | it "baseline test setup" do 7 | expect(Pet.new).to be_versioned 8 | end 9 | 10 | it "can be reified" do 11 | person = Person.create(name: "Frank") 12 | dog = Dog.create(name: "Snoopy") 13 | cat = Cat.create(name: "Garfield") 14 | 15 | person.pets << Pet.create(animal: dog) 16 | person.pets << Pet.create(animal: cat) 17 | person.update(name: "Steve") 18 | 19 | dog.update(name: "Beethoven") 20 | cat.update(name: "Sylvester") 21 | person.update(name: "Peter") 22 | 23 | expect(person.reload.versions.length).to(eq(3)) 24 | 25 | second_version = person.reload.versions.second.reify(has_many: true) 26 | expect(second_version.pets.length).to(eq(2)) 27 | expect(second_version.animals.length).to(eq(2)) 28 | expect(second_version.animals.map { |a| a.class.name }).to(eq(%w[Dog Cat])) 29 | expect(second_version.pets.map { |p| p.animal.class.name }).to(eq(%w[Dog Cat])) 30 | expect(second_version.animals.first.name).to(eq("Snoopy")) 31 | expect(second_version.dogs.first.name).to(eq("Snoopy")) 32 | expect(second_version.animals.second.name).to(eq("Garfield")) 33 | expect(second_version.cats.first.name).to(eq("Garfield")) 34 | 35 | last_version = person.reload.versions.last.reify(has_many: true) 36 | expect(last_version.pets.length).to(eq(2)) 37 | expect(last_version.animals.length).to(eq(2)) 38 | expect(last_version.animals.map { |a| a.class.name }).to(eq(%w[Dog Cat])) 39 | expect(last_version.pets.map { |p| p.animal.class.name }).to(eq(%w[Dog Cat])) 40 | expect(last_version.animals.first.name).to(eq("Beethoven")) 41 | expect(last_version.dogs.first.name).to(eq("Beethoven")) 42 | expect(last_version.animals.second.name).to(eq("Sylvester")) 43 | expect(last_version.cats.first.name).to(eq("Sylvester")) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/paper_trail/association_reify_error_behaviour/error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PaperTrail, versioning: true do 6 | it "baseline test setup" do 7 | expect(Person.new).to be_versioned 8 | end 9 | 10 | # See https://github.com/paper-trail-gem/paper_trail/issues/594 11 | describe "#association reify error behaviour" do 12 | it "association reify error behaviour = :error" do 13 | ::PaperTrail.config.association_reify_error_behaviour = :error 14 | 15 | person = Person.create(name: "Frank") 16 | car = Car.create(name: "BMW 325") 17 | bicycle = Bicycle.create(name: "BMX 1.0") 18 | 19 | person.car = car 20 | person.bicycle = bicycle 21 | person.update(name: "Steve") 22 | 23 | car.update(name: "BMW 330") 24 | bicycle.update(name: "BMX 2.0") 25 | person.update(name: "Peter") 26 | 27 | expect(person.reload.versions.length).to(eq(3)) 28 | 29 | expect { 30 | person.reload.versions.second.reify(has_one: true) 31 | }.to( 32 | raise_error(::PaperTrail::Reifiers::HasOne::FoundMoreThanOne) do |err| 33 | expect(err.message.squish).to match( 34 | /Expected to find one Vehicle, but found 2/ 35 | ) 36 | end 37 | ) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/paper_trail/association_reify_error_behaviour/ignore.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PaperTrail, versioning: true do 6 | it "baseline test setup" do 7 | expect(Person.new).to be_versioned 8 | end 9 | 10 | # See https://github.com/paper-trail-gem/paper_trail/issues/594 11 | describe "#association reify error behaviour" do 12 | it "association reify error behaviour = :ignore" do 13 | ::PaperTrail.config.association_reify_error_behaviour = :ignore 14 | 15 | person = Person.create(name: "Frank") 16 | thing = Thing.create(name: "BMW 325") 17 | thing2 = Thing.create(name: "BMX 1.0") 18 | 19 | person.thing = thing 20 | person.thing_2 = thing2 21 | person.update(name: "Steve") 22 | 23 | thing.update(name: "BMW 330") 24 | thing.update(name: "BMX 2.0") 25 | person.update(name: "Peter") 26 | 27 | expect(person.reload.versions.length).to(eq(3)) 28 | 29 | logger = person.versions.first.logger 30 | 31 | allow(logger).to receive(:warn) 32 | 33 | person.reload.versions.second.reify(has_one: true) 34 | 35 | expect(logger).not_to( 36 | have_received(:warn).with(/Unable to reify has_one association/) 37 | ) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/paper_trail/association_reify_error_behaviour/warn.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PaperTrail, versioning: true do 6 | it "baseline test setup" do 7 | expect(Person.new).to be_versioned 8 | end 9 | 10 | # See https://github.com/paper-trail-gem/paper_trail/issues/594 11 | describe "#association reify error behaviour" do 12 | it "association reify error behaviour = :warn" do 13 | ::PaperTrail.config.association_reify_error_behaviour = :warn 14 | 15 | person = Person.create(name: "Frank") 16 | thing = Thing.create(name: "BMW 325") 17 | thing2 = Thing.create(name: "BMX 1.0") 18 | 19 | person.thing = thing 20 | person.thing_2 = thing2 21 | person.update(name: "Steve") 22 | 23 | thing.update(name: "BMW 330") 24 | thing.update(name: "BMX 2.0") 25 | person.update(name: "Peter") 26 | 27 | expect(person.reload.versions.length).to(eq(3)) 28 | 29 | logger = person.versions.first.logger 30 | 31 | allow(logger).to receive(:warn) 32 | 33 | person.reload.versions.second.reify(has_one: true) 34 | 35 | expect(logger).to( 36 | have_received(:warn).with(/Unable to reify has_one association/).twice 37 | ) 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/paper_trail/associations/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | after do 7 | Timecop.return 8 | end 9 | 10 | context "wotsit belongs_to widget" do 11 | before { @widget = Widget.create(name: "widget_0") } 12 | 13 | context "where the association is created between model versions" do 14 | before do 15 | @wotsit = Wotsit.create(name: "wotsit_0") 16 | Timecop.travel(1.second.since) 17 | @wotsit.update(widget_id: @widget.id, name: "wotsit_1") 18 | end 19 | 20 | context "when reified" do 21 | before { @wotsit0 = @wotsit.versions.last.reify(belongs_to: true) } 22 | 23 | it "see the associated as it was at the time" do 24 | expect(@wotsit0.widget).to be_nil 25 | end 26 | 27 | it "not persist changes to the live association" do 28 | expect(@wotsit.reload.widget).to(eq(@widget)) 29 | end 30 | end 31 | 32 | context "and then the associated is updated between model versions" do 33 | before do 34 | @widget.update(name: "widget_1") 35 | @widget.update(name: "widget_2") 36 | Timecop.travel(1.second.since) 37 | @wotsit.update(name: "wotsit_2") 38 | @widget.update(name: "widget_3") 39 | end 40 | 41 | context "when reified" do 42 | before { @wotsit1 = @wotsit.versions.last.reify(belongs_to: true) } 43 | 44 | it "see the associated as it was at the time" do 45 | expect(@wotsit1.widget.name).to(eq("widget_2")) 46 | end 47 | 48 | it "not persist changes to the live association" do 49 | expect(@wotsit.reload.widget.name).to(eq("widget_3")) 50 | end 51 | end 52 | 53 | context "when reified opting out of belongs_to reification" do 54 | before { @wotsit1 = @wotsit.versions.last.reify(belongs_to: false) } 55 | 56 | it "see the associated as it is live" do 57 | expect(@wotsit1.widget.name).to(eq("widget_3")) 58 | end 59 | end 60 | end 61 | 62 | context "and then the associated is destroyed" do 63 | before do 64 | @wotsit.update(name: "wotsit_2") 65 | @widget.destroy 66 | end 67 | 68 | context "when reified with belongs_to: true" do 69 | before { @wotsit2 = @wotsit.versions.last.reify(belongs_to: true) } 70 | 71 | it "see the associated as it was at the time" do 72 | expect(@wotsit2.widget).to(eq(@widget)) 73 | end 74 | 75 | it "not persist changes to the live association" do 76 | expect(@wotsit.reload.widget).to be_nil 77 | end 78 | 79 | it "be able to persist the reified record" do 80 | expect { @wotsit2.save! }.not_to(raise_error) 81 | end 82 | end 83 | 84 | context "when reified with belongs_to: false" do 85 | before { @wotsit2 = @wotsit.versions.last.reify(belongs_to: false) } 86 | 87 | it "save should not re-create the widget record" do 88 | @wotsit2.save! 89 | expect(::Widget.find_by(id: @widget.id)).to be_nil 90 | end 91 | end 92 | 93 | context "and then the model is updated" do 94 | before do 95 | Timecop.travel(1.second.since) 96 | @wotsit.update(name: "wotsit_3") 97 | end 98 | 99 | context "when reified" do 100 | before { @wotsit2 = @wotsit.versions.last.reify(belongs_to: true) } 101 | 102 | it "see the associated as it was the time" do 103 | expect(@wotsit2.widget).to be_nil 104 | end 105 | end 106 | end 107 | end 108 | end 109 | 110 | context "where the association is changed between model versions" do 111 | before do 112 | @wotsit = @widget.create_wotsit(name: "wotsit_0") 113 | Timecop.travel(1.second.since) 114 | @new_widget = Widget.create(name: "new_widget") 115 | @wotsit.update(widget_id: @new_widget.id, name: "wotsit_1") 116 | end 117 | 118 | context "when reified" do 119 | before { @wotsit0 = @wotsit.versions.last.reify(belongs_to: true) } 120 | 121 | it "see the association as it was at the time" do 122 | expect(@wotsit0.widget.name).to(eq("widget_0")) 123 | end 124 | 125 | it "not persist changes to the live association" do 126 | expect(@wotsit.reload.widget).to(eq(@new_widget)) 127 | end 128 | end 129 | 130 | context "when reified with option mark_for_destruction" do 131 | before do 132 | @wotsit0 = @wotsit.versions.last.reify(belongs_to: true, mark_for_destruction: true) 133 | end 134 | 135 | it "does not mark the new associated for destruction" do 136 | expect(@new_widget.marked_for_destruction?).to(eq(false)) 137 | end 138 | end 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/paper_trail/associations/habtm_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | after do 7 | Timecop.return 8 | end 9 | 10 | context "foo and bar" do 11 | before do 12 | @foo = FooHabtm.create(name: "foo") 13 | Timecop.travel(1.second.since) 14 | end 15 | 16 | context "where the association is created between model versions" do 17 | before do 18 | @foo.update(name: "foo1", bar_habtms: [BarHabtm.create(name: "bar")]) 19 | end 20 | 21 | context "when reified" do 22 | before do 23 | @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) 24 | end 25 | 26 | it "see the associated as it was at the time" do 27 | expect(@reified.bar_habtms.length).to(eq(0)) 28 | end 29 | 30 | it "not persist changes to the live association" do 31 | expect(@foo.reload.bar_habtms).not_to(eq(@reified.bar_habtms)) 32 | end 33 | end 34 | end 35 | 36 | context "where the association is changed between model versions" do 37 | before do 38 | @foo.update(name: "foo2", bar_habtms: [BarHabtm.create(name: "bar2")]) 39 | Timecop.travel(1.second.since) 40 | @foo.update(name: "foo3", bar_habtms: [BarHabtm.create(name: "bar3")]) 41 | end 42 | 43 | context "when reified" do 44 | before do 45 | @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) 46 | end 47 | 48 | it "see the association as it was at the time" do 49 | expect(@reified.bar_habtms.first.name).to(eq("bar2")) 50 | end 51 | 52 | it "not persist changes to the live association" do 53 | expect(@foo.reload.bar_habtms.first).not_to(eq(@reified.bar_habtms.first)) 54 | end 55 | end 56 | 57 | context "when reified with has_and_belongs_to_many: false" do 58 | before { @reified = @foo.versions.last.reify } 59 | 60 | it "see the association as it is now" do 61 | expect(@reified.bar_habtms.first.name).to(eq("bar3")) 62 | end 63 | end 64 | end 65 | 66 | context "where the association is destroyed between model versions" do 67 | before do 68 | @foo.update(name: "foo2", bar_habtms: [BarHabtm.create(name: "bar2")]) 69 | Timecop.travel(1.second.since) 70 | @foo.update(name: "foo3", bar_habtms: []) 71 | end 72 | 73 | context "when reified" do 74 | before do 75 | @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) 76 | end 77 | 78 | it "see the association as it was at the time" do 79 | expect(@reified.bar_habtms.first.name).to(eq("bar2")) 80 | end 81 | 82 | it "not persist changes to the live association" do 83 | expect(@foo.reload.bar_habtms.first).not_to(eq(@reified.bar_habtms.first)) 84 | end 85 | end 86 | end 87 | 88 | context "where the unassociated model changes" do 89 | before do 90 | @bar = BarHabtm.create(name: "bar2") 91 | @foo.update(name: "foo2", bar_habtms: [@bar]) 92 | Timecop.travel(1.second.since) 93 | @foo.update(name: "foo3", bar_habtms: [BarHabtm.create(name: "bar4")]) 94 | Timecop.travel(1.second.since) 95 | @bar.update(name: "bar3") 96 | end 97 | 98 | context "when reified" do 99 | before do 100 | @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) 101 | end 102 | 103 | it "see the association as it was at the time" do 104 | expect(@reified.bar_habtms.first.name).to(eq("bar2")) 105 | end 106 | 107 | it "not persist changes to the live association" do 108 | expect(@foo.reload.bar_habtms.first).not_to(eq(@reified.bar_habtms.first)) 109 | end 110 | end 111 | end 112 | end 113 | 114 | context "updated via nested attributes" do 115 | before do 116 | @foo = FooHabtm.create(name: "foo", bar_habtms_attributes: [{ name: "bar" }]) 117 | Timecop.travel(1.second.since) 118 | @foo.update( 119 | name: "foo2", 120 | bar_habtms_attributes: [{ id: @foo.bar_habtms.first.id, name: "bar2" }] 121 | ) 122 | @reified = @foo.versions.last.reify(has_and_belongs_to_many: true) 123 | end 124 | 125 | it "see the associated object as it was at the time" do 126 | expect(@reified.bar_habtms.first.name).to(eq("bar")) 127 | end 128 | 129 | it "not persist changes to the live object" do 130 | expect(@foo.reload.bar_habtms.first.name).not_to(eq(@reified.bar_habtms.first.name)) 131 | end 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/paper_trail/associations/has_many_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | after do 7 | Timecop.return 8 | end 9 | 10 | describe "customer, reified from version before order created" do 11 | it "has no orders" do 12 | customer = Customer.create(name: "customer_0") 13 | customer.update!(name: "customer_1") 14 | customer.orders.create!(order_date: Date.today) 15 | customer0 = customer.versions.last.reify(has_many: true) 16 | expect(customer0.orders).to(eq([])) 17 | expect(customer.orders.reload).not_to(eq([])) 18 | end 19 | end 20 | 21 | describe "customer, reified with mark_for_destruction, from version before order" do 22 | it "has orders, but they are marked for destruction" do 23 | customer = Customer.create(name: "customer_0") 24 | customer.update!(name: "customer_1") 25 | customer.orders.create!(order_date: Date.today) 26 | customer0 = customer.versions.last.reify(has_many: true, mark_for_destruction: true) 27 | expect(customer0.orders.map(&:marked_for_destruction?)).to(eq([true])) 28 | end 29 | end 30 | 31 | describe "customer, reified from version after order created" do 32 | it "has the expected order" do 33 | customer = Customer.create(name: "customer_0") 34 | customer.orders.create!(order_date: "order_date_0") 35 | Timecop.travel(1.second.since) 36 | customer.update(name: "customer_1") 37 | customer0 = customer.versions.last.reify(has_many: true) 38 | expect(customer0.orders.map(&:order_date)).to(eq(["order_date_0"])) 39 | end 40 | end 41 | 42 | describe "customer, reified from version after order line_items created" do 43 | it "has the expected line item" do 44 | customer = Customer.create(name: "customer_0") 45 | order = customer.orders.create!(order_date: "order_date_0") 46 | Timecop.travel(1.second.since) 47 | customer.update(name: "customer_1") 48 | order.line_items.create!(product: "product_0") 49 | customer0 = customer.versions.last.reify(has_many: true) 50 | expect(customer0.orders.first.line_items.map(&:product)).to(eq(["product_0"])) 51 | end 52 | end 53 | 54 | describe "customer, reified from version after order is updated" do 55 | it "has the updated order_date" do 56 | customer = Customer.create(name: "customer_0") 57 | order = customer.orders.create!(order_date: "order_date_0") 58 | Timecop.travel(1.second.since) 59 | customer.update(name: "customer_1") 60 | order.update(order_date: "order_date_1") 61 | order.update(order_date: "order_date_2") 62 | Timecop.travel(1.second.since) 63 | customer.update(name: "customer_2") 64 | order.update(order_date: "order_date_3") 65 | customer1 = customer.versions.last.reify(has_many: true) 66 | expect(customer1.orders.map(&:order_date)).to(eq(["order_date_2"])) 67 | expect(customer.orders.reload.map(&:order_date)).to(eq(["order_date_3"])) 68 | end 69 | end 70 | 71 | describe "customer, reified with has_many: false" do 72 | it "has the latest order from the database" do 73 | # TODO: This can be tested with fewer db records 74 | customer = Customer.create(name: "customer_0") 75 | order = customer.orders.create!(order_date: "order_date_0") 76 | Timecop.travel(1.second.since) 77 | customer.update(name: "customer_1") 78 | order.update(order_date: "order_date_1") 79 | order.update(order_date: "order_date_2") 80 | Timecop.travel(1.second.since) 81 | customer.update(name: "customer_2") 82 | order.update(order_date: "order_date_3") 83 | customer1 = customer.versions.last.reify(has_many: false) 84 | expect(customer1.orders.map(&:order_date)).to(eq(["order_date_3"])) 85 | end 86 | end 87 | 88 | describe "customer, reified from version before order is destroyed" do 89 | it "has the order" do 90 | # TODO: This can be tested with fewer db records 91 | customer = Customer.create(name: "customer_0") 92 | order = customer.orders.create!(order_date: "order_date_0") 93 | Timecop.travel(1.second.since) 94 | customer.update(name: "customer_1") 95 | order.update(order_date: "order_date_1") 96 | order.update(order_date: "order_date_2") 97 | Timecop.travel(1.second.since) 98 | customer.update(name: "customer_2") 99 | order.update(order_date: "order_date_3") 100 | order.destroy 101 | customer1 = customer.versions.last.reify(has_many: true) 102 | expect(customer1.orders.map(&:order_date)).to(eq(["order_date_2"])) 103 | expect(customer.orders.reload).to(eq([])) 104 | end 105 | end 106 | 107 | describe "customer, reified from version before order is destroyed" do 108 | it "has the order" do 109 | customer = Customer.create(name: "customer_0") 110 | order = customer.orders.create!(order_date: "order_date_0") 111 | Timecop.travel(1.second.since) 112 | customer.update(name: "customer_1") 113 | order.destroy 114 | customer1 = customer.versions.last.reify(has_many: true) 115 | expect(customer1.orders.map(&:order_date)).to(eq([order.order_date])) 116 | expect(customer.orders.reload).to(eq([])) 117 | end 118 | end 119 | 120 | describe "customer, reified from version after order is destroyed" do 121 | it "does not have the order" do 122 | customer = Customer.create(name: "customer_0") 123 | order = customer.orders.create!(order_date: "order_date_0") 124 | Timecop.travel(1.second.since) 125 | customer.update(name: "customer_1") 126 | order.destroy 127 | Timecop.travel(1.second.since) 128 | customer.update(name: "customer_2") 129 | customer1 = customer.versions.last.reify(has_many: true) 130 | expect(customer1.orders).to(eq([])) 131 | end 132 | end 133 | 134 | describe "customer, reified from version before order was updated" do 135 | it "has the old order_date" do 136 | customer = Customer.create(name: "customer_0") 137 | customer.orders.create!(order_date: "order_date_0") 138 | Timecop.travel(1.second.since) 139 | customer.update(name: "customer_1") 140 | customer.orders.create!(order_date: "order_date_1") 141 | customer0 = customer.versions.last.reify(has_many: true) 142 | expect(customer0.orders.map(&:order_date)).to(eq(["order_date_0"])) 143 | expect( 144 | customer.orders.reload.map(&:order_date) 145 | ).to match_array(%w[order_date_0 order_date_1]) 146 | end 147 | end 148 | 149 | describe "customer, reified w/ mark_for_destruction, from version before 2nd order created" do 150 | it "has both orders, and the second is marked for destruction" do 151 | customer = Customer.create(name: "customer_0") 152 | customer.orders.create!(order_date: "order_date_0") 153 | Timecop.travel(1.second.since) 154 | customer.update(name: "customer_1") 155 | customer.orders.create!(order_date: "order_date_1") 156 | customer0 = customer.versions.last.reify(has_many: true, mark_for_destruction: true) 157 | order = customer0.orders.detect { |o| o.order_date == "order_date_1" } 158 | expect(order).to be_marked_for_destruction 159 | end 160 | end 161 | 162 | describe "predator, reified superclass associations from before prey is added" do 163 | it "has no prey" do 164 | predator = Cat.create(name: "cat_0") 165 | predator.update!(name: "cat_1") 166 | predator.prey.create!(prey: Dog.new(name: "dog_0")) 167 | predator0 = predator.versions.last.reify(has_many: true) 168 | expect(predator0.prey).to(eq([])) 169 | expect(predator.prey.reload).not_to(eq([])) 170 | end 171 | end 172 | end 173 | -------------------------------------------------------------------------------- /spec/paper_trail/associations/has_many_through_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | CHAPTER_NAMES = [ 7 | "Down the Rabbit-Hole", 8 | "The Pool of Tears", 9 | "A Caucus-Race and a Long Tale", 10 | "The Rabbit Sends in a Little Bill", 11 | "Advice from a Caterpillar", 12 | "Pig and Pepper", 13 | "A Mad Tea-Party", 14 | "The Queen's Croquet-Ground", 15 | "The Mock Turtle's Story", 16 | "The Lobster Quadrille", 17 | "Who Stole the Tarts?", 18 | "Alice's Evidence" 19 | ].freeze 20 | 21 | after do 22 | Timecop.return 23 | end 24 | 25 | context "Books, Authors, and Authorships" do 26 | before { @book = Book.create(title: "book_0") } 27 | 28 | context "updated before the associated was created" do 29 | before do 30 | @book.update!(title: "book_1") 31 | @book.authors.create!(name: "author_0") 32 | end 33 | 34 | context "when reified" do 35 | before { @book0 = @book.versions.last.reify(has_many: true) } 36 | 37 | it "see the associated as it was at the time" do 38 | expect(@book0.authors).to(eq([])) 39 | end 40 | 41 | it "not persist changes to the live association" do 42 | expect(@book.authors.reload.map(&:name)).to(eq(["author_0"])) 43 | end 44 | end 45 | 46 | context "when reified with option mark_for_destruction" do 47 | before do 48 | @book0 = @book.versions.last.reify(has_many: true, mark_for_destruction: true) 49 | end 50 | 51 | it "mark the associated for destruction" do 52 | expect(@book0.authors.map(&:marked_for_destruction?)).to(eq([true])) 53 | end 54 | 55 | it "mark the associated-through for destruction" do 56 | expect(@book0.authorships.map(&:marked_for_destruction?)).to(eq([true])) 57 | end 58 | end 59 | end 60 | 61 | context "updated before it is associated with an existing one" do 62 | before do 63 | person_existing = Person.create(name: "person_existing") 64 | Timecop.travel(1.second.since) 65 | @book.update!(title: "book_1") 66 | (@book.authors << person_existing) 67 | end 68 | 69 | context "when reified" do 70 | before { @book0 = @book.versions.last.reify(has_many: true) } 71 | 72 | it "see the associated as it was at the time" do 73 | expect(@book0.authors).to(eq([])) 74 | end 75 | end 76 | 77 | context "when reified with option mark_for_destruction" do 78 | before do 79 | @book0 = @book.versions.last.reify(has_many: true, mark_for_destruction: true) 80 | end 81 | 82 | it "not mark the associated for destruction" do 83 | expect(@book0.authors.map(&:marked_for_destruction?)).to(eq([false])) 84 | end 85 | 86 | it "mark the associated-through for destruction" do 87 | expect(@book0.authorships.map(&:marked_for_destruction?)).to(eq([true])) 88 | end 89 | end 90 | end 91 | 92 | context "where the association is created between model versions" do 93 | before do 94 | @author = @book.authors.create!(name: "author_0") 95 | @person_existing = Person.create(name: "person_existing") 96 | Timecop.travel(1.second.since) 97 | @book.update!(title: "book_1") 98 | end 99 | 100 | context "when reified" do 101 | before { @book0 = @book.versions.last.reify(has_many: true) } 102 | 103 | it "see the associated as it was at the time" do 104 | expect(@book0.authors.map(&:name)).to(eq(["author_0"])) 105 | end 106 | end 107 | 108 | context "and then the associated is updated between model versions" do 109 | before do 110 | @author.update(name: "author_1") 111 | @author.update(name: "author_2") 112 | Timecop.travel(1.second.since) 113 | @book.update(title: "book_2") 114 | @author.update(name: "author_3") 115 | end 116 | 117 | context "when reified" do 118 | before { @book1 = @book.versions.last.reify(has_many: true) } 119 | 120 | it "see the associated as it was at the time" do 121 | expect(@book1.authors.map(&:name)).to(eq(["author_2"])) 122 | end 123 | 124 | it "not persist changes to the live association" do 125 | expect(@book.authors.reload.map(&:name)).to(eq(["author_3"])) 126 | end 127 | end 128 | 129 | context "when reified opting out of has_many reification" do 130 | before { @book1 = @book.versions.last.reify(has_many: false) } 131 | 132 | it "see the associated as it is live" do 133 | expect(@book1.authors.map(&:name)).to(eq(["author_3"])) 134 | end 135 | end 136 | end 137 | 138 | context "and then the associated is destroyed" do 139 | before { @author.destroy } 140 | 141 | context "when reified" do 142 | before { @book1 = @book.versions.last.reify(has_many: true) } 143 | 144 | it "see the associated as it was at the time" do 145 | expect(@book1.authors.map(&:name)).to(eq([@author.name])) 146 | end 147 | 148 | it "not persist changes to the live association" do 149 | expect(@book.authors.reload).to(eq([])) 150 | end 151 | end 152 | end 153 | 154 | context "and then the associated is destroyed between model versions" do 155 | before do 156 | @author.destroy 157 | Timecop.travel(1.second.since) 158 | @book.update(title: "book_2") 159 | end 160 | 161 | context "when reified" do 162 | before { @book1 = @book.versions.last.reify(has_many: true) } 163 | 164 | it "see the associated as it was at the time" do 165 | expect(@book1.authors).to(eq([])) 166 | end 167 | end 168 | end 169 | 170 | context "and then the associated is dissociated between model versions" do 171 | before do 172 | @book.authors = [] 173 | Timecop.travel(1.second.since) 174 | @book.update(title: "book_2") 175 | end 176 | 177 | context "when reified" do 178 | before { @book1 = @book.versions.last.reify(has_many: true) } 179 | 180 | it "see the associated as it was at the time" do 181 | expect(@book1.authors).to(eq([])) 182 | end 183 | end 184 | end 185 | 186 | context "and then another associated is created" do 187 | before { @book.authors.create!(name: "author_1") } 188 | 189 | context "when reified" do 190 | before { @book0 = @book.versions.last.reify(has_many: true) } 191 | 192 | it "only see the first associated" do 193 | expect(@book0.authors.map(&:name)).to(eq(["author_0"])) 194 | end 195 | 196 | it "not persist changes to the live association" do 197 | expect(@book.authors.reload.map(&:name)).to(eq(%w[author_0 author_1])) 198 | end 199 | end 200 | 201 | context "when reified with option mark_for_destruction" do 202 | before do 203 | @book0 = @book.versions.last.reify(has_many: true, mark_for_destruction: true) 204 | end 205 | 206 | it "mark the newly associated for destruction" do 207 | author = @book0.authors.detect { |a| a.name == "author_1" } 208 | expect(author).to be_marked_for_destruction 209 | end 210 | 211 | it "mark the newly associated-through for destruction" do 212 | authorship = @book0.authorships.detect { |as| as.author.name == "author_1" } 213 | expect(authorship).to be_marked_for_destruction 214 | end 215 | end 216 | end 217 | 218 | context "and then an existing one is associated" do 219 | before { (@book.authors << @person_existing) } 220 | 221 | context "when reified" do 222 | before { @book0 = @book.versions.last.reify(has_many: true) } 223 | 224 | it "only see the first associated" do 225 | expect(@book0.authors.map(&:name)).to(eq(["author_0"])) 226 | end 227 | 228 | it "not persist changes to the live association" do 229 | expect(@book.authors.reload.map(&:name).sort).to(eq(%w[author_0 person_existing])) 230 | end 231 | end 232 | 233 | context "when reified with option mark_for_destruction" do 234 | before do 235 | @book0 = @book.versions.last.reify(has_many: true, mark_for_destruction: true) 236 | end 237 | 238 | it "not mark the newly associated for destruction" do 239 | author = @book0.authors.detect { |a| a.name == "person_existing" } 240 | expect(author).not_to be_marked_for_destruction 241 | end 242 | 243 | it "mark the newly associated-through for destruction" do 244 | authorship = @book0.authorships.detect { |as| as.author.name == "person_existing" } 245 | expect(authorship).to be_marked_for_destruction 246 | end 247 | end 248 | end 249 | end 250 | 251 | context "updated before the associated without paper_trail was created" do 252 | before do 253 | @book.update!(title: "book_1") 254 | @book.editors.create!(name: "editor_0") 255 | end 256 | 257 | context "when reified" do 258 | before { @book0 = @book.versions.last.reify(has_many: true) } 259 | 260 | it "see the live association" do 261 | expect(@book0.editors.map(&:name)).to(eq(["editor_0"])) 262 | end 263 | end 264 | end 265 | end 266 | 267 | context "Chapters, Sections, Paragraphs, Quotations, and Citations" do 268 | before { @chapter = Chapter.create(name: CHAPTER_NAMES[0]) } 269 | 270 | context "before any associations are created" do 271 | before { @chapter.update(name: CHAPTER_NAMES[1]) } 272 | 273 | it "not reify any associations" do 274 | chapter_v1 = @chapter.versions[1].reify(has_many: true) 275 | expect(chapter_v1.name).to(eq(CHAPTER_NAMES[0])) 276 | expect(chapter_v1.sections).to(eq([])) 277 | expect(chapter_v1.paragraphs).to(eq([])) 278 | end 279 | end 280 | 281 | context "after the first has_many through relationship is created" do 282 | before do 283 | @chapter.update(name: CHAPTER_NAMES[1]) 284 | Timecop.travel(1.second.since) 285 | @chapter.sections.create(name: "section 1") 286 | Timecop.travel(1.second.since) 287 | @chapter.sections.first.update(name: "section 2") 288 | Timecop.travel(1.second.since) 289 | @chapter.update(name: CHAPTER_NAMES[2]) 290 | Timecop.travel(1.second.since) 291 | @chapter.sections.first.update(name: "section 3") 292 | end 293 | 294 | context "version 1" do 295 | it "have no sections" do 296 | chapter_v1 = @chapter.versions[1].reify(has_many: true) 297 | expect(chapter_v1.sections).to(eq([])) 298 | end 299 | end 300 | 301 | context "version 2" do 302 | it "have one section" do 303 | chapter_v2 = @chapter.versions[2].reify(has_many: true) 304 | expect(chapter_v2.sections.size).to(eq(1)) 305 | expect(chapter_v2.sections.map(&:name)).to(eq(["section 2"])) 306 | expect(chapter_v2.name).to(eq(CHAPTER_NAMES[1])) 307 | end 308 | end 309 | 310 | context "version 2, before the section was destroyed" do 311 | before do 312 | @chapter.update(name: CHAPTER_NAMES[2]) 313 | Timecop.travel(1.second.since) 314 | @chapter.sections.destroy_all 315 | Timecop.travel(1.second.since) 316 | end 317 | 318 | it "have the one section" do 319 | chapter_v2 = @chapter.versions[2].reify(has_many: true) 320 | expect(chapter_v2.sections.map(&:name)).to(eq(["section 2"])) 321 | end 322 | end 323 | 324 | context "version 3, after the section was destroyed" do 325 | before do 326 | @chapter.sections.destroy_all 327 | Timecop.travel(1.second.since) 328 | @chapter.update(name: CHAPTER_NAMES[3]) 329 | Timecop.travel(1.second.since) 330 | end 331 | 332 | it "have no sections" do 333 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 334 | expect(chapter_v3.sections.size).to(eq(0)) 335 | end 336 | end 337 | 338 | context "after creating a paragraph" do 339 | before do 340 | @section = @chapter.sections.first 341 | Timecop.travel(1.second.since) 342 | @paragraph = @section.paragraphs.create(name: "para1") 343 | end 344 | 345 | context "new chapter version" do 346 | it "have one paragraph" do 347 | initial_section_name = @section.name 348 | initial_paragraph_name = @paragraph.name 349 | Timecop.travel(1.second.since) 350 | @chapter.update(name: CHAPTER_NAMES[4]) 351 | expect(@chapter.versions.size).to(eq(4)) 352 | Timecop.travel(1.second.since) 353 | @paragraph.update(name: "para3") 354 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 355 | expect(chapter_v3.sections.map(&:name)).to(eq([initial_section_name])) 356 | paragraphs = chapter_v3.sections.first.paragraphs 357 | expect(paragraphs.size).to(eq(1)) 358 | expect(paragraphs.map(&:name)).to(eq([initial_paragraph_name])) 359 | end 360 | end 361 | 362 | context "the version before a section is destroyed" do 363 | it "have the section and paragraph" do 364 | Timecop.travel(1.second.since) 365 | @chapter.update(name: CHAPTER_NAMES[3]) 366 | expect(@chapter.versions.size).to(eq(4)) 367 | Timecop.travel(1.second.since) 368 | @section.destroy 369 | expect(@chapter.versions.size).to(eq(4)) 370 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 371 | expect(chapter_v3.name).to(eq(CHAPTER_NAMES[2])) 372 | expect(chapter_v3.sections).to(eq([@section])) 373 | expect(chapter_v3.sections[0].paragraphs).to(eq([@paragraph])) 374 | expect(chapter_v3.paragraphs).to(eq([@paragraph])) 375 | end 376 | end 377 | 378 | context "the version after a section is destroyed" do 379 | it "not have any sections or paragraphs" do 380 | @section.destroy 381 | Timecop.travel(1.second.since) 382 | @chapter.update(name: CHAPTER_NAMES[5]) 383 | expect(@chapter.versions.size).to(eq(4)) 384 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 385 | expect(chapter_v3.sections.size).to(eq(0)) 386 | expect(chapter_v3.paragraphs.size).to(eq(0)) 387 | end 388 | end 389 | 390 | context "the version before a paragraph is destroyed" do 391 | it "have the one paragraph" do 392 | initial_paragraph_name = @section.paragraphs.first.name 393 | Timecop.travel(1.second.since) 394 | @chapter.update(name: CHAPTER_NAMES[5]) 395 | Timecop.travel(1.second.since) 396 | @paragraph.destroy 397 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 398 | paragraphs = chapter_v3.sections.first.paragraphs 399 | expect(paragraphs.size).to(eq(1)) 400 | expect(paragraphs.first.name).to(eq(initial_paragraph_name)) 401 | end 402 | end 403 | 404 | context "the version after a paragraph is destroyed" do 405 | it "have no paragraphs" do 406 | @paragraph.destroy 407 | Timecop.travel(1.second.since) 408 | @chapter.update(name: CHAPTER_NAMES[5]) 409 | chapter_v3 = @chapter.versions[3].reify(has_many: true) 410 | expect(chapter_v3.paragraphs.size).to(eq(0)) 411 | expect(chapter_v3.sections.first.paragraphs).to(eq([])) 412 | end 413 | end 414 | end 415 | end 416 | 417 | context "a chapter with one paragraph and one citation" do 418 | it "reify paragraphs and citations" do 419 | chapter = Chapter.create(name: CHAPTER_NAMES[0]) 420 | section = Section.create(name: "Section One", chapter: chapter) 421 | paragraph = Paragraph.create(name: "Paragraph One", section: section) 422 | quotation = Quotation.create(chapter: chapter) 423 | citation = Citation.create(quotation: quotation) 424 | Timecop.travel(1.second.since) 425 | chapter.update(name: CHAPTER_NAMES[1]) 426 | expect(chapter.versions.count).to(eq(2)) 427 | paragraph.destroy 428 | citation.destroy 429 | reified = chapter.versions[1].reify(has_many: true) 430 | expect(reified.sections.first.paragraphs).to(eq([paragraph])) 431 | expect(reified.quotations.first.citations).to(eq([citation])) 432 | end 433 | end 434 | end 435 | 436 | context "Widgets, bizzos, and notes" do 437 | before { @widget = Widget.create(name: 'widget_0') } 438 | 439 | context "before any associations are created" do 440 | before { @widget.update(name: 'widget_1') } 441 | 442 | it "not reify any associations" do 443 | widget_v1 = @widget.versions[1].reify(has_many: true) 444 | expect(widget_v1.name).to(eq('widget_0')) 445 | expect(widget_v1.bizzo).to(eq(nil)) 446 | expect(widget_v1.notes).to(eq([])) 447 | end 448 | end 449 | 450 | context "after the first has_many through relationship is created" do 451 | before do 452 | @widget.update(name: 'widget_1') 453 | Timecop.travel(1.second.since) 454 | @widget.create_bizzo(name: 'bizzo_1') 455 | Timecop.travel(1.second.since) 456 | @widget.bizzo.update(name: 'bizzo_2') 457 | Timecop.travel(1.second.since) 458 | @widget.update(name: 'widget_2') 459 | Timecop.travel(1.second.since) 460 | @widget.bizzo.update(name: "bizzo_3") 461 | end 462 | 463 | context "after creating a note" do 464 | before do 465 | @bizzo = @widget.bizzo 466 | Timecop.travel(1.second.since) 467 | @note = @bizzo.notes.create(body: "note1") 468 | end 469 | 470 | context "new widget version" do 471 | it "have one note" do 472 | initial_bizzo_name = @bizzo.name 473 | initial_note_body = @note.body 474 | Timecop.travel(1.second.since) 475 | @widget.update(name: 'widget_4') 476 | expect(@widget.versions.size).to(eq(4)) 477 | Timecop.travel(1.second.since) 478 | @note.update(body: "note3") 479 | widget_v3 = @widget.versions[3].reify(has_many: true) 480 | expect(widget_v3.bizzo.name).to(eq(initial_bizzo_name)) 481 | notes = widget_v3.bizzo.notes 482 | expect(notes.size).to(eq(1)) 483 | expect(notes.map(&:body)).to(eq([initial_note_body])) 484 | end 485 | end 486 | 487 | context "the version before a bizzo is destroyed" do 488 | it "have the bizzo and note" do 489 | Timecop.travel(1.second.since) 490 | @widget.update(name: 'widget_3') 491 | expect(@widget.versions.size).to(eq(4)) 492 | Timecop.travel(1.second.since) 493 | @bizzo.destroy 494 | expect(@widget.versions.size).to(eq(4)) 495 | widget_v3 = @widget.versions[3].reify(has_many: true) 496 | expect(widget_v3.name).to(eq('widget_2')) 497 | expect(widget_v3.bizzo).to(eq(@bizzo)) 498 | expect(widget_v3.bizzo.notes).to(eq([@note])) 499 | expect(widget_v3.notes).to(eq([@note])) 500 | end 501 | end 502 | 503 | context "the version after a bizzo is destroyed" do 504 | it "not have any bizzos or notes" do 505 | @bizzo.destroy 506 | Timecop.travel(1.second.since) 507 | @widget.update(name: 'widget_5') 508 | expect(@widget.versions.size).to(eq(4)) 509 | widget_v3 = @widget.versions[3].reify(has_many: true) 510 | expect(widget_v3.bizzo).to(be_nil) 511 | expect(widget_v3.notes.size).to(eq(0)) 512 | end 513 | end 514 | 515 | context "the version before a note is destroyed" do 516 | it "have the one note" do 517 | initial_note_body = @bizzo.notes.first.body 518 | Timecop.travel(1.second.since) 519 | @widget.update(name: 'widget_5') 520 | Timecop.travel(1.second.since) 521 | @note.destroy 522 | widget_v3 = @widget.versions[3].reify(has_many: true) 523 | notes = widget_v3.bizzo.notes 524 | expect(notes.size).to(eq(1)) 525 | expect(notes.first.body).to(eq(initial_note_body)) 526 | end 527 | end 528 | 529 | context "the version after a note is destroyed" do 530 | it "have no notes" do 531 | @note.destroy 532 | Timecop.travel(1.second.since) 533 | @widget.update(name: 'widget_5') 534 | widget_v3 = @widget.versions[3].reify(has_many: true) 535 | expect(widget_v3.notes.size).to(eq(0)) 536 | expect(widget_v3.bizzo.notes).to(eq([])) 537 | end 538 | end 539 | end 540 | end 541 | end 542 | end 543 | -------------------------------------------------------------------------------- /spec/paper_trail/associations/has_one_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | after do 7 | Timecop.return 8 | end 9 | 10 | describe "widget, reified from a version prior to creation of wotsit" do 11 | it "has a nil wotsit" do 12 | widget = Widget.create(name: "widget_0") 13 | widget.update(name: "widget_1") 14 | widget.create_wotsit(name: "wotsit_0") 15 | widget0 = widget.versions.last.reify(has_one: true) 16 | expect(widget0.wotsit).to be_nil 17 | end 18 | end 19 | 20 | describe "widget, reified from a version prior to creation of bizzo" do 21 | before do 22 | @widget = Widget.create(name: "widget_0") 23 | @widget.update(name: "widget_1") 24 | @widget.create_bizzo(name: "bizzo_0") 25 | @reified = @widget.versions.last.reify(has_one: true) 26 | end 27 | 28 | it "has a nil bizzo" do 29 | expect(@reified.bizzo).to be_nil 30 | end 31 | 32 | it 'does not destroy the live association' do 33 | expect(@widget.reload.bizzo).not_to be_nil 34 | end 35 | end 36 | 37 | describe "widget, reified from a version after creation of wotsit" do 38 | it "has the expected wotsit" do 39 | widget = Widget.create(name: "widget_0") 40 | wotsit = widget.create_wotsit(name: "wotsit_0") 41 | Timecop.travel(1.second.since) 42 | widget.update(name: "widget_1") 43 | widget0 = widget.versions.last.reify(has_one: true) 44 | expect(widget0.wotsit.name).to(eq("wotsit_0")) 45 | expect(widget.reload.wotsit).to(eq(wotsit)) 46 | end 47 | end 48 | 49 | describe "widget, reified from a version after its wotsit has been updated" do 50 | it "has the expected wotsit" do 51 | widget = Widget.create(name: "widget_0") 52 | wotsit = widget.create_wotsit(name: "wotsit_0") 53 | Timecop.travel(1.second.since) 54 | widget.update(name: "widget_1") 55 | wotsit.update(name: "wotsit_1") 56 | wotsit.update(name: "wotsit_2") 57 | Timecop.travel(1.second.since) 58 | widget.update(name: "widget_2") 59 | wotsit.update(name: "wotsit_3") 60 | widget1 = widget.versions.last.reify(has_one: true) 61 | expect(widget1.wotsit.name).to(eq("wotsit_2")) 62 | expect(widget.reload.wotsit.name).to(eq("wotsit_3")) 63 | end 64 | end 65 | 66 | describe "widget, reified with has_one: false" do 67 | it "has the latest wotsit in the database" do 68 | widget = Widget.create(name: "widget_0") 69 | wotsit = widget.create_wotsit(name: "wotsit_0") 70 | Timecop.travel(1.second.since) 71 | widget.update(name: "widget_1") 72 | wotsit.update(name: "wotsit_1") 73 | wotsit.update(name: "wotsit_2") 74 | Timecop.travel(1.second.since) 75 | widget.update(name: "widget_2") 76 | wotsit.update(name: "wotsit_3") 77 | widget1 = widget.versions.last.reify(has_one: false) 78 | expect(widget1.wotsit.name).to(eq("wotsit_3")) 79 | end 80 | end 81 | 82 | describe "widget, reified from a version prior to the destruction of its wotsit" do 83 | it "has the wotsit" do 84 | widget = Widget.create(name: "widget_0") 85 | wotsit = widget.create_wotsit(name: "wotsit_0") 86 | Timecop.travel(1.second.since) 87 | widget.update(name: "widget_1") 88 | wotsit.destroy 89 | widget1 = widget.versions.last.reify(has_one: true) 90 | expect(widget1.wotsit).to(eq(wotsit)) 91 | expect(widget.reload.wotsit).to be_nil 92 | end 93 | end 94 | 95 | describe "widget, refied from version after its wotsit was destroyed" do 96 | it "has a nil wotsit" do 97 | widget = Widget.create(name: "widget_0") 98 | wotsit = widget.create_wotsit(name: "wotsit_0") 99 | Timecop.travel(1.second.since) 100 | widget.update(name: "widget_1") 101 | wotsit.destroy 102 | Timecop.travel(1.second.since) 103 | widget.update(name: "widget_3") 104 | widget2 = widget.versions.last.reify(has_one: true) 105 | expect(widget2.wotsit).to be_nil 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /spec/paper_trail/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module PaperTrail 6 | ::RSpec.describe Config do 7 | describe "track_associations?" do 8 | context "@track_associations is nil" do 9 | it "returns false and prints a deprecation warning" do 10 | config = described_class.instance 11 | config.track_associations = nil 12 | expect(config.track_associations?).to eq(false) 13 | end 14 | 15 | after do 16 | PaperTrail.config.track_associations = true 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/paper_trail/model_config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module PaperTrail 6 | ::RSpec.describe ModelConfig, versioning: true do 7 | after do 8 | Timecop.return 9 | end 10 | 11 | describe "class_name" do 12 | before do 13 | @widget = Widget.create(name: "widget_0") 14 | @wotsit = Wotsit.create(widget_id: @widget.id, name: "wotsit_0") 15 | @version = @wotsit.versions.last 16 | @version_association = @version.version_associations.last 17 | end 18 | 19 | it "customize the version association class" do 20 | expect(@version).to be_a(CustomVersion) 21 | expect(@version_association).to be_a(CustomVersionAssociation) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/paper_trail/model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe(::PaperTrail, versioning: true) do 6 | context "a new record" do 7 | it "not have any previous versions" do 8 | expect(Widget.new.versions).to(eq([])) 9 | end 10 | 11 | it "be live" do 12 | expect(Widget.new.paper_trail.live?).to(eq(true)) 13 | end 14 | end 15 | 16 | context "a persisted record" do 17 | before do 18 | @widget = Widget.create(name: "Henry", created_at: (Time.now - 1.day)) 19 | end 20 | 21 | it "have one previous version" do 22 | expect(@widget.versions.length).to(eq(1)) 23 | end 24 | 25 | it "be nil in its previous version" do 26 | expect(@widget.versions.first.object).to(be_nil) 27 | expect(@widget.versions.first.reify).to(be_nil) 28 | end 29 | 30 | it "record the correct event" do 31 | expect(@widget.versions.first.event).to(match(/create/i)) 32 | end 33 | 34 | it "be live" do 35 | expect(@widget.paper_trail.live?).to(eq(true)) 36 | end 37 | 38 | it "use the widget `updated_at` as the version's `created_at`" do 39 | expect(@widget.versions.first.created_at.to_i).to(eq(@widget.updated_at.to_i)) 40 | end 41 | 42 | describe "#changeset" do 43 | it "has expected values" do 44 | changeset = @widget.versions.last.changeset 45 | expect(changeset["name"]).to eq([nil, "Henry"]) 46 | expect(changeset["id"]).to eq([nil, @widget.id]) 47 | # When comparing timestamps, round off to the nearest second, because 48 | # mysql doesn't do fractional seconds. 49 | expect(changeset["created_at"][0]).to be_nil 50 | expect(changeset["created_at"][1].to_i).to eq(@widget.created_at.to_i) 51 | expect(changeset["updated_at"][0]).to be_nil 52 | expect(changeset["updated_at"][1].to_i).to eq(@widget.updated_at.to_i) 53 | end 54 | end 55 | 56 | context "and then updated without any changes" do 57 | before { @widget.touch } 58 | 59 | it "to have two previous versions" do 60 | expect(@widget.versions.length).to(eq(2)) 61 | end 62 | end 63 | 64 | context "and then updated with changes" do 65 | before { @widget.update(name: "Harry") } 66 | 67 | it "have three previous versions" do 68 | expect(@widget.versions.length).to(eq(2)) 69 | end 70 | 71 | it "be available in its previous version" do 72 | expect(@widget.name).to(eq("Harry")) 73 | expect(@widget.versions.last.object).not_to(be_nil) 74 | widget = @widget.versions.last.reify 75 | expect(widget.name).to(eq("Henry")) 76 | expect(@widget.name).to(eq("Harry")) 77 | end 78 | 79 | it "have the same ID in its previous version" do 80 | expect(@widget.versions.last.reify.id).to(eq(@widget.id)) 81 | end 82 | 83 | it "record the correct event" do 84 | expect(@widget.versions.last.event).to(match(/update/i)) 85 | end 86 | 87 | it "have versions that are not live" do 88 | @widget.versions.map(&:reify).compact.each do |v| 89 | expect(v.paper_trail).not_to be_live 90 | end 91 | end 92 | 93 | it "have stored changes" do 94 | last_obj_changes = @widget.versions.last.object_changes 95 | actual = PaperTrail.serializer.load(last_obj_changes).reject do |k, _v| 96 | (k.to_sym == :updated_at) 97 | end 98 | expect(actual).to(eq("name" => %w[Henry Harry])) 99 | actual = @widget.versions.last.changeset.reject { |k, _v| (k.to_sym == :updated_at) } 100 | expect(actual).to(eq("name" => %w[Henry Harry])) 101 | end 102 | 103 | it "return changes with indifferent access" do 104 | expect(@widget.versions.last.changeset[:name]).to(eq(%w[Henry Harry])) 105 | expect(@widget.versions.last.changeset["name"]).to(eq(%w[Henry Harry])) 106 | end 107 | 108 | context "and has one associated object" do 109 | before { @wotsit = @widget.create_wotsit name: "John" } 110 | 111 | it "not copy the has_one association by default when reifying" do 112 | reified_widget = @widget.versions.last.reify 113 | expect(reified_widget.wotsit).to(eq(@wotsit)) 114 | expect(@widget.reload.wotsit).to(eq(@wotsit)) 115 | end 116 | 117 | it "copy the has_one association when reifying with :has_one => true" do 118 | reified_widget = @widget.versions.last.reify(has_one: true) 119 | expect(reified_widget.wotsit).to(be_nil) 120 | expect(@widget.reload.wotsit).to(eq(@wotsit)) 121 | end 122 | end 123 | 124 | context "and has many associated objects" do 125 | before do 126 | @f0 = @widget.fluxors.create(name: "f-zero") 127 | @f1 = @widget.fluxors.create(name: "f-one") 128 | @reified_widget = @widget.versions.last.reify 129 | end 130 | 131 | it "copy the has_many associations when reifying" do 132 | expect(@reified_widget.fluxors.length).to(eq(@widget.fluxors.length)) 133 | expect(@reified_widget.fluxors).to match_array(@widget.fluxors) 134 | expect(@reified_widget.versions.length).to(eq(@widget.versions.length)) 135 | expect(@reified_widget.versions).to match_array(@widget.versions) 136 | end 137 | end 138 | 139 | context "and has many associated polymorphic objects" do 140 | before do 141 | @f0 = @widget.whatchamajiggers.create(name: "f-zero") 142 | @f1 = @widget.whatchamajiggers.create(name: "f-zero") 143 | @reified_widget = @widget.versions.last.reify 144 | end 145 | 146 | it "copy the has_many associations when reifying" do 147 | expect(@reified_widget.whatchamajiggers.length).to eq(@widget.whatchamajiggers.length) 148 | expect(@reified_widget.whatchamajiggers).to match_array(@widget.whatchamajiggers) 149 | expect(@reified_widget.versions.length).to(eq(@widget.versions.length)) 150 | expect(@reified_widget.versions).to match_array(@widget.versions) 151 | end 152 | end 153 | 154 | context "polymorphic objects by themselves" do 155 | before { @widget = Whatchamajigger.new(name: "f-zero") } 156 | 157 | it "not fail with a nil pointer on the polymorphic association" do 158 | @widget.save! 159 | end 160 | 161 | context 'when polymorphic type is an empty string' do 162 | before do 163 | @widget.owner_type = '' 164 | end 165 | 166 | it 'not fail with an empty string as polymorphic type' do 167 | @widget.save! 168 | end 169 | end 170 | end 171 | 172 | context "and then destroyed" do 173 | before do 174 | @fluxor = @widget.fluxors.create(name: "flux") 175 | @widget.destroy 176 | @reified_widget = PaperTrail::Version.last.reify 177 | end 178 | 179 | it "record the correct event" do 180 | expect(PaperTrail::Version.last.event).to(match(/destroy/i)) 181 | end 182 | 183 | it "have three previous versions" do 184 | expect(PaperTrail::Version.with_item_keys("Widget", @widget.id).length).to(eq(3)) 185 | end 186 | 187 | describe "#attributes" do 188 | it "returns the expected attributes for the reified widget" do 189 | expect(@reified_widget.id).to(eq(@widget.id)) 190 | expected = @widget.attributes 191 | actual = @reified_widget.attributes 192 | expect(expected["id"]).to eq(actual["id"]) 193 | expect(expected["name"]).to eq(actual["name"]) 194 | expect(expected["a_text"]).to eq(actual["a_text"]) 195 | expect(expected["an_integer"]).to eq(actual["an_integer"]) 196 | expect(expected["a_float"]).to eq(actual["a_float"]) 197 | expect(expected["a_decimal"]).to eq(actual["a_decimal"]) 198 | expect(expected["a_datetime"]).to eq(actual["a_datetime"]) 199 | expect(expected["a_time"]).to eq(actual["a_time"]) 200 | expect(expected["a_date"]).to eq(actual["a_date"]) 201 | expect(expected["a_boolean"]).to eq(actual["a_boolean"]) 202 | expect(expected["type"]).to eq(actual["type"]) 203 | expect(expected["created_at"].to_i).to eq(actual["created_at"].to_i) 204 | expect(expected["updated_at"].to_i).to eq(actual["updated_at"].to_i) 205 | end 206 | end 207 | 208 | it "be re-creatable from its previous version" do 209 | expect(@reified_widget.save).to(be_truthy) 210 | end 211 | 212 | it "restore its associations on its previous version" do 213 | @reified_widget.save 214 | expect(@reified_widget.fluxors.length).to(eq(1)) 215 | end 216 | 217 | it "have nil item for last version" do 218 | expect(@widget.versions.last.item).to(be_nil) 219 | end 220 | 221 | it "have changes" do 222 | book = Book.create! title: "A" 223 | changes = YAML.load book.versions.last.attributes["object_changes"] 224 | expect(changes).to eq("id" => [nil, book.id], "title" => [nil, "A"]) 225 | 226 | book.update! title: "B" 227 | changes = YAML.load book.versions.last.attributes["object_changes"] 228 | expect(changes).to eq("title" => %w[A B]) 229 | 230 | if PaperTrail::VERSION::MAJOR >= 10 231 | book.destroy 232 | changes = YAML.load book.versions.last.attributes["object_changes"] 233 | expect(changes).to eq("id" => [book.id, nil], "title" => ["B", nil]) 234 | end 235 | end 236 | end 237 | end 238 | end 239 | 240 | context ":has_many :through" do 241 | before do 242 | @book = Book.create(title: "War and Peace") 243 | @dostoyevsky = Person.create(name: "Dostoyevsky") 244 | @solzhenitsyn = Person.create(name: "Solzhenitsyn") 245 | end 246 | 247 | it "store version on source <<" do 248 | count = PaperTrail::Version.count 249 | (@book.authors << @dostoyevsky) 250 | expect((PaperTrail::Version.count - count)).to(eq(1)) 251 | expect(@book.authorships.first.versions.first).to(eq(PaperTrail::Version.last)) 252 | end 253 | 254 | it "store version on source create" do 255 | count = PaperTrail::Version.count 256 | @book.authors.create(name: "Tolstoy") 257 | expect((PaperTrail::Version.count - count)).to(eq(2)) 258 | expect( 259 | [PaperTrail::Version.order(:id).to_a[-2].item, PaperTrail::Version.last.item] 260 | ).to match_array([Person.last, Authorship.last]) 261 | end 262 | 263 | it "store version on join destroy" do 264 | (@book.authors << @dostoyevsky) 265 | count = PaperTrail::Version.count 266 | @book.authorships.reload.last.destroy 267 | expect((PaperTrail::Version.count - count)).to(eq(1)) 268 | expect(PaperTrail::Version.last.reify.book).to(eq(@book)) 269 | expect(PaperTrail::Version.last.reify.author).to(eq(@dostoyevsky)) 270 | end 271 | 272 | it "store version on join clear" do 273 | (@book.authors << @dostoyevsky) 274 | count = PaperTrail::Version.count 275 | @book.authorships.reload.destroy_all 276 | expect((PaperTrail::Version.count - count)).to(eq(1)) 277 | expect(PaperTrail::Version.last.reify.book).to(eq(@book)) 278 | expect(PaperTrail::Version.last.reify.author).to(eq(@dostoyevsky)) 279 | end 280 | end 281 | end 282 | -------------------------------------------------------------------------------- /spec/paper_trail/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module PaperTrail 6 | ::RSpec.describe(Request, versioning: true) do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/paper_trail/version_concern_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PaperTrail::VersionConcern do 6 | end 7 | -------------------------------------------------------------------------------- /spec/paper_trail/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | module PaperTrail 6 | ::RSpec.describe(Version, versioning: true) do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/paper_trail_association_tracking_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe PaperTrailAssociationTracking do 6 | describe ".gem_version" do 7 | it "returns a Gem::Version" do 8 | v = described_class.gem_version 9 | expect(v).to be_a(::Gem::Version) 10 | expect(v.to_s).to eq(described_class::VERSION) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/paper_trail_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "paper_trail/frameworks/rspec" 5 | 6 | RSpec.describe PaperTrail do 7 | context "default" do 8 | it "has versioning off by default" do 9 | expect(described_class).not_to be_enabled 10 | end 11 | 12 | it "has versioning on in a `with_versioning` block" do 13 | expect(described_class).not_to be_enabled 14 | with_versioning do 15 | expect(described_class).to be_enabled 16 | end 17 | expect(described_class).not_to be_enabled 18 | end 19 | 20 | context "error within `with_versioning` block" do 21 | it "reverts the value of `PaperTrail.enabled?` to its previous state" do 22 | expect(described_class).not_to be_enabled 23 | expect { with_versioning { raise } }.to raise_error(RuntimeError) 24 | expect(described_class).not_to be_enabled 25 | end 26 | end 27 | end 28 | 29 | context "`versioning: true`", versioning: true do 30 | it "has versioning on by default" do 31 | expect(described_class).to be_enabled 32 | end 33 | 34 | it "keeps versioning on after a with_versioning block" do 35 | expect(described_class).to be_enabled 36 | with_versioning do 37 | expect(described_class).to be_enabled 38 | end 39 | expect(described_class).to be_enabled 40 | end 41 | end 42 | 43 | context "`with_versioning` block at class level" do 44 | it { expect(described_class).not_to be_enabled } 45 | 46 | with_versioning do 47 | it "has versioning on by default" do 48 | expect(described_class).to be_enabled 49 | end 50 | end 51 | it "does not leak the `enabled?` state into successive tests" do 52 | expect(described_class).not_to be_enabled 53 | end 54 | end 55 | 56 | end 57 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] = "test" 4 | 5 | require File.expand_path("../dummy_app/config/environment", __FILE__) 6 | 7 | RSpec.configure do |config| 8 | config.example_status_persistence_file_path = ".rspec_results" 9 | 10 | config.expect_with :rspec do |expectations| 11 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 12 | end 13 | 14 | config.mock_with :rspec do |mocks| 15 | mocks.verify_partial_doubles = true 16 | end 17 | 18 | # Support for disabling `verify_partial_doubles` on specific examples. 19 | config.around(:each, verify_stubs: false) do |ex| 20 | config.mock_with :rspec do |mocks| 21 | mocks.verify_partial_doubles = false 22 | ex.run 23 | mocks.verify_partial_doubles = true 24 | end 25 | end 26 | 27 | config.run_all_when_everything_filtered = true 28 | config.disable_monkey_patching! 29 | config.warnings = false 30 | 31 | if config.files_to_run.one? 32 | config.default_formatter = "doc" 33 | end 34 | config.order = :random 35 | 36 | Kernel.srand(config.seed) 37 | end 38 | 39 | #require "rspec/rails" 40 | #require "ffaker" 41 | require "timecop" 42 | 43 | # Run any available migration 44 | if ActiveRecord::VERSION::MAJOR == 6 45 | ActiveRecord::MigrationContext.new(File.expand_path("dummy_app/db/migrate/", __dir__), ActiveRecord::SchemaMigration).migrate 46 | else 47 | ActiveRecord::MigrationContext.new(File.expand_path("dummy_app/db/migrate/", __dir__)).migrate 48 | end 49 | 50 | require "rspec/core" 51 | require "rspec/matchers" 52 | 53 | RSpec::Matchers.define :have_a_version_with do |attributes| 54 | # check if the model has a version with the specified attributes 55 | match do |actual| 56 | versions_association = actual.class.versions_association_name 57 | actual.send(versions_association).where_object(attributes).any? 58 | end 59 | end 60 | 61 | RSpec::Matchers.define :have_a_version_with_changes do |attributes| 62 | # check if the model has a version changes with the specified attributes 63 | match do |actual| 64 | versions_association = actual.class.versions_association_name 65 | actual.send(versions_association).where_object_changes(attributes).any? 66 | end 67 | end 68 | --------------------------------------------------------------------------------