├── .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 |
4 |
5 |
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 |
--------------------------------------------------------------------------------