pax_global_header00006660000000000000000000000064140303710410014503gustar00rootroot0000000000000052 comment=4567568c74b8f04a19508e8fdc131e56532d8c68 paper_trail-12.0.0/000077500000000000000000000000001403037104100140655ustar00rootroot00000000000000paper_trail-12.0.0/.github/000077500000000000000000000000001403037104100154255ustar00rootroot00000000000000paper_trail-12.0.0/.github/CONTRIBUTING.md000066400000000000000000000100521403037104100176540ustar00rootroot00000000000000# Contributing Thanks for your interest in PaperTrail! ## Reporting Security Vulnerabilities Please email jared@jaredbeck.com and batkinz@gmail.com. Do not mention the vulnerability publicly until there's a fix. We will respond as soon as we can. Thank you for responsibly disclosing security vulnerabilities. ## Usage Questions Due to limited volunteers, we cannot answer *usage* questions. Please ask such questions on [StackOverflow](https://stackoverflow.com/tags/paper-trail-gem). ## Reporting Bugs You want to fix a bug, but need some help. > You are required to provide a script that reproduces the bug, using our > template. You are required to fix the bug. We're here to help, but no one else > will fix it for you. If you don't fix the bug in a reasonable amount of time, > your issue will be closed. > - From our [issue template][1]. Due to limited volunteers, we cannot fix everyone's bugs for them. We're happy to help, but we can only accept issues from people committed to working on their own problems. Different people use different parts of PaperTrail. You may have found a bug, but you might also be the only person affected by that bug. Don't hesitate to ask for whatever help you need, but it's your job to fix it. ## Development ```bash gem install bundler bundle bundle exec appraisal install bundle exec appraisal update # occasionally ``` Testing is a little awkward because the test suite: 1. Supports multiple versions of rails 1. Contains a "dummy" rails app with three databases (test, foo, and bar) 1. Supports three different RDBMS': sqlite, mysql, and postgres ### Test sqlite, AR 6 ``` DB=sqlite bundle exec appraisal rails-6.0 rake ``` ### Test sqlite, AR 5 ``` DB=sqlite bundle exec appraisal rails-5.2 rake ``` ### Test mysql, AR 5 ``` DB=mysql bundle exec appraisal rails-5.2 rake ``` ### Test postgres, AR 5 ``` createuser --superuser postgres DB=postgres bundle exec appraisal rails-5.2 rake ``` ## The dummy_app In the rare event you need a `console` in the `dummy_app`: ``` cd spec/dummy_app cp config/database.mysql.yml config/database.yml BUNDLE_GEMFILE='../../gemfiles/rails_5.2.gemfile' bin/rails console -e test ``` ## Adding new schema Edit `spec/dummy_app/db/migrate/20110208155312_set_up_test_tables.rb`. Migration will be performed by `rake`, so you can just run it as shown above. Also, `spec/dummy_app/db/schema.rb` is deliberately `.gitignore`d, we don't use it. ## Lowest supported ruby version Set in three files: - .gemspec (required_ruby_version) - .rubocop.yml (TargetRubyVersion) - .github/workflows/test.yml, in two places Practically, this is only changed when dropping support for an EoL ruby version. We `.gitignore` `.ruby-version` because it is variable, and only used by contributors. ## Documentation ### Generate the Table of Contents ``` yarn global add markdown-toc markdown-toc -i --maxdepth 3 --bullets='-' README.md ``` ## Releases 1. Prepare the appropriate "stable" branch for release, eg. `10-stable` 1. git checkout 10-stable 1. Checkout a new branch, eg. `release-10.3.0` 1. Merge the relevant changes from `master`. This could be a plain merge, or it could be cherry-picking. The later is more common in backports. 1. Set the version in `lib/paper_trail/version_number.rb` 1. In the changelog, - Replace "Unreleased" with the date in ISO-8601 format - Add a new "Unreleased" section 1. In the readme, update references to version number, including - documentation links table - compatability table, if necessary 1. git commit -am 'Release 10.3.0' 1. git push origin release-10.3.0 1. Pull request into `10-stable`, CI pass, merge PR 1. Release 1. git checkout 10-stable && git pull 1. gem build paper_trail.gemspec 1. gem push paper_trail-10.3.0.gem 1. git tag -a -m "v10.3.0" "v10.3.0" # or whatever number 1. git push --tags origin 1. Cleanup 1. git checkout master 1. cherry-pick the "Release 10.3.0" commit from the `10-stable` branch 1. git push origin master [1]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/ISSUE_TEMPLATE/bug_report.md paper_trail-12.0.0/.github/ISSUE_TEMPLATE/000077500000000000000000000000001403037104100176105ustar00rootroot00000000000000paper_trail-12.0.0/.github/ISSUE_TEMPLATE/bug-report.md000066400000000000000000000044631403037104100222270ustar00rootroot00000000000000--- name: Bug report about: You must provide a script that reproduces the bug, using our template. title: '' labels: '' assignees: '' --- Thank you for your contribution! Due to limited volunteers, issues that do not follow these instructions will be closed without comment. Check the following boxes: - [ ] This is not a usage question, this is a bug report - [ ] This bug can be reproduced with the script I provide below - [ ] This bug can be reproduced in the latest release of the `paper_trail` gem Due to limited volunteers, we cannot answer *usage* questions. Please ask such questions on [StackOverflow](https://stackoverflow.com/tags/paper-trail-gem). Bug reports must use the following template: ```ruby # frozen_string_literal: true # Use this template to report PaperTrail bugs. # Please include only the minimum code necessary to reproduce your issue. require "bundler/inline" # STEP ONE: What versions are you using? gemfile(true) do ruby "2.5.1" source "https://rubygems.org" gem "activerecord", "5.2.0" gem "minitest", "5.11.3" gem "paper_trail", "9.2.0", require: false gem "sqlite3", "1.3.13" end require "active_record" require "minitest/autorun" require "logger" # Please use sqlite for your bug reports, if possible. ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:") ActiveRecord::Base.logger = nil ActiveRecord::Schema.define do # STEP TWO: Define your tables here. create_table :users, force: true do |t| t.text :first_name, null: false t.timestamps null: false end create_table :versions do |t| t.string :item_type, null: false t.integer :item_id, null: false t.string :event, null: false t.string :whodunnit t.text :object, limit: 1_073_741_823 t.text :object_changes, limit: 1_073_741_823 t.datetime :created_at end add_index :versions, %i[item_type item_id] end ActiveRecord::Base.logger = Logger.new(STDOUT) require "paper_trail" # STEP FOUR: Define your AR models here. class User < ActiveRecord::Base has_paper_trail end # STEP FIVE: Please write a test that demonstrates your issue. class BugTest < ActiveSupport::TestCase def test_1 assert_difference(-> { PaperTrail::Version.count }, +1) { User.create(first_name: "Jane") } end end # STEP SIX: Run this script using `ruby my_bug_report.rb` ``` paper_trail-12.0.0/.github/ISSUE_TEMPLATE/config.yml000066400000000000000000000006461403037104100216060ustar00rootroot00000000000000blank_issues_enabled: false contact_links: - name: I have a usage question url: https://stackoverflow.com/tags/paper-trail-gem about: Due to limited volunteers, we cannot answer usage questions on GitHub. - name: I found a security vulnerability url: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/CONTRIBUTING.md about: Please email the maintainers as described in CONTRIBUTING.md paper_trail-12.0.0/.github/ISSUE_TEMPLATE/feature-suggestion.md000066400000000000000000000013731403037104100237560ustar00rootroot00000000000000--- name: Feature suggestion about: Suggest a feature that you'd like to build title: '' labels: '' assignees: '' --- Thank you for your contribution! Due to limited volunteers, issues that do not follow this template will be closed without comment. **Is your feature suggestion related to a problem? Please describe.** A clear and concise description of the problem. You may find the [bug report template](https://github.com/paper-trail-gem/paper_trail/blob/master/.github/ISSUE_TEMPLATE/bug_report.md) helpful. **Describe the solution you'd like to build** A clear and concise description of what you want to build. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. paper_trail-12.0.0/.github/PULL_REQUEST_TEMPLATE.md000066400000000000000000000010201403037104100212170ustar00rootroot00000000000000Thank you for your contribution! Check the following boxes: - [ ] Wrote [good commit messages][1]. - [ ] Feature branch is up-to-date with `master` (if not - rebase it). - [ ] Squashed related commits together. - [ ] Added tests. - [ ] Added an entry to the [Changelog](../blob/master/CHANGELOG.md) if the new code introduces user-observable changes. - [ ] The PR relates to *only* one subject with a clear title and description in grammatically correct, complete sentences. [1]: http://chris.beams.io/posts/git-commit/ paper_trail-12.0.0/.github/workflows/000077500000000000000000000000001403037104100174625ustar00rootroot00000000000000paper_trail-12.0.0/.github/workflows/stale.yml000066400000000000000000000017641403037104100213250ustar00rootroot00000000000000name: 'Close stale items' on: schedule: - cron: '30 1 * * *' jobs: stale: runs-on: ubuntu-latest steps: - uses: actions/stale@v3 with: stale-issue-message: > This issue has been automatically marked as stale due to inactivity. The resources of our volunteers are limited. Bug reports must provide a script that reproduces the bug, using our template. Feature suggestions must include a promise to build the feature yourself. Thank you for all your contributions. stale-pr-message: This PR has been automatically marked as stale due to inactivity. The resources of our volunteers are limited. If this is something you are committed to continue working on, please address any concerns raised by review and/or ping us again. Thank you for all your contributions. days-before-stale: 90 days-before-close: 7 paper_trail-12.0.0/.github/workflows/test.yml000066400000000000000000000105431403037104100211670ustar00rootroot00000000000000name: gha-workflow-pt-test on: [push, pull_request] jobs: # Linting is a separate job, primary because it only needs to be done once, # and secondarily because jobs are performed concurrently. gha-job-pt-lint: name: Lint runs-on: ubuntu-18.04 steps: - name: Checkout source uses: actions/checkout@v2 - name: Setup ruby uses: ruby/setup-ruby@v1 with: # See "Lowest supported ruby version" in CONTRIBUTING.md ruby-version: '2.5' - name: Bundle run: | gem install bundler bundle install --jobs 4 --retry 3 - name: Lint run: bundle exec rubocop # The test job is a matrix of ruby/rails versions. gha-job-pt-test: name: Ruby ${{ matrix.ruby }}, ${{ matrix.gemfile }}.gemfile runs-on: ubuntu-18.04 services: gha-service-pt-mysql: env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: paper_trail_test image: mysql:8.0 options: >- --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 ports: - 3306:3306 gha-service-pt-postgres: env: POSTGRES_PASSWORD: asdfasdf image: postgres options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 strategy: # Unlike TravisCI, the database will not be part of the matrix. Each # sub-job in the matrix tests all three databases. Alternatively, we could # have set this up with each database as a separate job, but then we'd be # duplicating the matrix configuration three times. matrix: gemfile: [ 'rails_5.2', 'rails_6.0', 'rails_6.1' ] # To keep matrix size down, only test highest and lowest rubies. # Ruby 3.0 is an exception. For now, let's continue to test against 2.7 # in case it still produces any deprecation warnings. # # See "Lowest supported ruby version" in CONTRIBUTING.md ruby: [ '2.5', '2.7', '3.0' ] exclude: # rails 5.2 requires ruby < 3.0 # https://github.com/rails/rails/issues/40938 - ruby: '3.0' gemfile: 'rails_5.2' steps: - name: Checkout source uses: actions/checkout@v2 - name: Setup ruby uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - name: Bundle run: | gem install bundler bundle install --jobs 4 --retry 3 env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile # MySQL db was created above, sqlite will be created during test suite, # when migrations occur, so we only need to create the postgres db. I # tried something like `cd .....dummy_app && ....db:create`, but couldn't # get that to work. - name: Create postgres database run: | createdb \ --host=$POSTGRES_HOST \ --port=$POSTGRES_PORT \ --username=postgres \ paper_trail_test env: PGPASSWORD: asdfasdf POSTGRES_HOST: localhost POSTGRES_PORT: 5432 # The following three steps finally run the tests. We use `rake # install_database_yml spec` instead of `rake` (default) because the # default includes rubocop, which we run (once) as a separate job. See # above. - name: Test, sqlite run: bundle exec rake install_database_yml spec env: BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile DB: sqlite - name: Test, mysql run: bundle exec rake install_database_yml spec env: BACKTRACE: 1 BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile DB: mysql PT_CI_DATABASE: paper_trail PT_CI_DB_USER: root PT_CI_DB_HOST: 127.0.0.1 PT_CI_DB_PORT: 3306 - name: Test, postgres run: bundle exec rake install_database_yml spec env: BACKTRACE: 1 BUNDLE_GEMFILE: gemfiles/${{ matrix.gemfile }}.gemfile DB: postgres PT_CI_DATABASE: paper_trail PT_CI_DB_USER: postgres PT_CI_DB_PASSWORD: asdfasdf PT_CI_DB_HOST: 127.0.0.1 PT_CI_DB_PORT: 5432 paper_trail-12.0.0/.gitignore000066400000000000000000000006361403037104100160620ustar00rootroot00000000000000*.gem *.sqlite3-journal .bundle .byebug_history .idea .rbenv-gemsets .rbenv-version .rspec_results .ruby-gemset .ruby-version .rvmrc .tags .tags_sorted_by_file Gemfile.lock NOTES coverage gemfiles/*.lock pkg/* spec/dummy/ spec/dummy_app/config/database.yml spec/dummy_app/db/schema.rb spec/dummy_app/db/*.sqlite3 spec/dummy_app/log/* spec/dummy_app/tmp/* test/debug.log test/paper_trail_plugin.sqlite3.db vendor/* paper_trail-12.0.0/.rspec000066400000000000000000000000521403037104100151770ustar00rootroot00000000000000--backtrace --color --require spec_helper paper_trail-12.0.0/.rubocop.yml000066400000000000000000000140031403037104100163350ustar00rootroot00000000000000require: - rubocop-packaging - rubocop-performance - rubocop-rails - rubocop-rspec inherit_from: .rubocop_todo.yml # Please: # # - Comment any deviations from the Ruby Style Guide # - Alphabetize cops # - Only include permanent config; temporary goes in .rubocop_todo.yml AllCops: Exclude: - gemfiles/vendor/bundle/**/* # This dir only shows up on travis ¯\_(ツ)_/¯ - spec/dummy_app/db/schema.rb # Generated, out of our control # Enable pending cops so we can adopt the code before they are switched on. NewCops: enable # See "Lowest supported ruby version" in CONTRIBUTING.md TargetRubyVersion: 2.5 Bundler/OrderedGems: Exclude: - gemfiles/* # generated by Appraisal Layout/ArgumentAlignment: EnforcedStyle: with_fixed_indentation Layout/DotPosition: EnforcedStyle: trailing # Avoid blank lines inside methods. They are a sign that the method is too big. Layout/EmptyLineAfterGuardClause: Enabled: false Layout/HeredocIndentation: Exclude: - paper_trail.gemspec # The Ruby Style Guide recommends to "Limit lines to 80 characters." # (https://github.com/bbatsov/ruby-style-guide#80-character-limits) # Please aim for 80, but up to 100 is OK. Layout/LineLength: Max: 100 Layout/MultilineMethodCallIndentation: EnforcedStyle: indented Layout/MultilineOperationIndentation: EnforcedStyle: indented Layout/ParameterAlignment: EnforcedStyle: with_fixed_indentation Layout/SpaceAroundMethodCallOperator: Enabled: true # Use exactly one space on each side of an operator. Do not align operators # because it makes the code harder to edit, and makes lines unnecessarily long. Layout/SpaceAroundOperators: AllowForAlignment: false Lint/RaiseException: Enabled: true Lint/StructNewOverride: Enabled: true # Migrations often contain long up/down methods, and extracting smaller methods # from these is of questionable value. Metrics/AbcSize: Exclude: - 'spec/dummy_app/db/migrate/*' # Not a useful metric compared to, e.g. `AbcSize`. Metrics/BlockLength: Enabled: false # Not a useful metric compared to, e.g. `AbcSize`. Metrics/ClassLength: Enabled: false # The number of lines in a method is not a useful metric compared to `AbcSize`. # It's common to have very long methods (> 50 lines) which are quite simple. For # example, a method that returns a long string with only a few interpolations. Metrics/MethodLength: Enabled: false # Not a useful metric compared to, e.g. `AbcSize`. Metrics/ModuleLength: Enabled: false Naming/FileName: Exclude: - Appraisals # Heredocs are usually assigned to a variable or constant, which already has a # name, so naming the heredoc doesn't add much value. Feel free to name # heredocs that are used as anonymous values (not a variable, constant, or # named parameter). # # All heredocs containing SQL should be named SQL, to support editor syntax # highlighting. Naming/HeredocDelimiterNaming: Enabled: false Naming/PredicateName: AllowedMethods: has_paper_trail # Too subtle to lint. # Two-letter param names are OK. Consider `send_email(to:, cc:)`. # Even one-letter names are OK. Consider `draw_point(x, y)`. Naming/MethodParameterName: Enabled: false # This cop does not seem to work in rubocop-rspec 1.28.0 RSpec/DescribeClass: Enabled: false # This cop has a bug in 1.35.0 # https://github.com/rubocop-hq/rubocop-rspec/issues/799 RSpec/DescribedClass: Enabled: false # Yes, ideally examples would be short. Is it possible to pick a limit and say, # "no example will ever be longer than this"? Hard to say. Sometimes they're # quite long. RSpec/ExampleLength: Enabled: false # In an ideal world, each example has a single expectation. In the real world, # sometimes setup is a pain and you don't want to duplicate setup in multiple # examples, or make the specs more confusing with many nested `context`s, and # the practical thing is to have multiple expectations. RSpec/MultipleExpectations: Enabled: false # Please use semantic style, e.g. `do` when there's a side-effect, else `{}`. # The semantic style is too nuanced to lint, so the cop is disabled. Style/BlockDelimiters: Enabled: false # Use double negation wherever it would otherwise be impractical to convert # a value to an actual boolean. Style/DoubleNegation: Enabled: false # This cop is unimportant in this repo. Style/ExponentialNotation: Enabled: false # Avoid annotated tokens except in desperately complicated format strings. # In 99% of format strings they actually make it less readable. Style/FormatStringToken: Enabled: false Style/FrozenStringLiteralComment: Exclude: - gemfiles/* # generated by Appraisal # The decision of when to use a guard clause to improve readability is subtle, # and it's not clear that it can be linted. Certainly, the default # `MinBodyLength` of 1 can actually hurt readability. Style/GuardClause: Enabled: false # `hash.keys.each` is totally fine. Style/HashEachMethods: Enabled: false # Only use postfix (modifier) conditionals for utterly simple statements. # As a rule of thumb, the entire statement should not exceed 60 chars. # Rubocop used to support this level of configuration, but no longer does. Style/IfUnlessModifier: Enabled: false # Using `module_function` instead of `extend self` would make the instance # methods in these modules private. That would be a breaking change, so these # modules are excluded. See discussion in: # - https://github.com/paper-trail-gem/paper_trail/pull/756 # - https://github.com/bbatsov/ruby-style-guide/issues/556 Style/ModuleFunction: Exclude: - 'lib/paper_trail/serializers/json.rb' - 'lib/paper_trail/serializers/yaml.rb' # Too subtle to lint. Use `format` for multiple variables. For single variables, # use either interpolation or concatenation, whichever is easier to read. Style/StringConcatenation: Enabled: false # The Ruby Style Guide does not prescribe a particular quote character, only # that a project should pick one and be consistent. The decision has no # performance implications. Double quotes are slightly easier to read. Style/StringLiterals: EnforcedStyle: double_quotes paper_trail-12.0.0/.rubocop_todo.yml000066400000000000000000000074421403037104100173730ustar00rootroot00000000000000# This configuration was generated by # `rubocop --auto-gen-config` # on 2021-03-21 03:46:53 UTC using RuboCop version 1.11.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 1 # Cop supports --auto-correct. # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. # Include: **/*.gemspec Gemspec/OrderedDependencies: Exclude: - 'paper_trail.gemspec' # Offense count: 5 # Configuration parameters: IgnoredMethods, CountRepeatedAttributes. Metrics/AbcSize: Max: 19 # Goal: the default (17?) # Offense count: 1 # Configuration parameters: IgnoredMethods. Metrics/CyclomaticComplexity: Max: 8 # Goal: 7 # Offense count: 1 # Configuration parameters: IgnoredMethods. Metrics/PerceivedComplexity: Max: 9 # Goal: 7 # Offense count: 1 # Cop supports --auto-correct. Performance/BlockGivenWithExplicitBlock: Exclude: - 'lib/paper_trail.rb' # Offense count: 1 # Configuration parameters: MinSize. Performance/CollectionLiteralInLoop: Exclude: - 'spec/models/version_spec.rb' # Offense count: 115 # Configuration parameters: Prefixes. # Prefixes: when, with, without RSpec/ContextWording: Enabled: false # It may be possible for us to use safe_load, but we'd have to pass the # safelists, like `whitelist_classes` into our serializer, and the serializer # interface is a public API, so that would be a breaking change. Security/YAMLLoad: Exclude: - 'lib/paper_trail/serializers/yaml.rb' - 'spec/models/gadget_spec.rb' - 'spec/models/no_object_spec.rb' - 'spec/models/person_spec.rb' - 'spec/models/version_spec.rb' - 'spec/paper_trail/events/destroy_spec.rb' - 'spec/paper_trail/model_spec.rb' - 'spec/paper_trail/serializer_spec.rb' # Offense count: 1 # Cop supports --auto-correct. Rails/ApplicationController: Exclude: - 'spec/dummy_app/app/controllers/test_controller.rb' # Offense count: 56 # Cop supports --auto-correct. Rails/ApplicationRecord: Enabled: false # Offense count: 1 # Cop supports --auto-correct. Rails/NegateInclude: Exclude: - 'lib/paper_trail/events/base.rb' # Offense count: 1 # Cop supports --auto-correct. Rails/Presence: Exclude: - 'lib/paper_trail/reifier.rb' # Offense count: 2 # Cop supports --auto-correct. # Configuration parameters: Include. # Include: **/Rakefile, **/*.rake Rails/RakeEnvironment: Exclude: - 'lib/capistrano/tasks/**/*.rake' - 'Rakefile' # Offense count: 8 # Cop supports --auto-correct. Rails/RedundantForeignKey: Exclude: - 'spec/dummy_app/app/models/family/family.rb' - 'spec/dummy_app/app/models/family/family_line.rb' - 'spec/dummy_app/app/models/person.rb' # Offense count: 28 # Configuration parameters: ForbiddenMethods, AllowedMethods. # ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all Rails/SkipsModelValidations: Exclude: - 'lib/paper_trail/record_trail.rb' - 'spec/models/gadget_spec.rb' - 'spec/models/on/create_spec.rb' - 'spec/models/on/empty_array_spec.rb' - 'spec/models/on/touch_spec.rb' - 'spec/models/on/update_spec.rb' - 'spec/models/widget_spec.rb' - 'spec/paper_trail/cleaner_spec.rb' - 'spec/paper_trail/config_spec.rb' - 'spec/paper_trail/model_spec.rb' # Offense count: 1 # Cop supports --auto-correct. Rails/WhereNot: Exclude: - 'lib/paper_trail/version_concern.rb' RSpec/FilePath: Exclude: - 'spec/paper_trail/model_spec.rb' - 'spec/paper_trail/serializer_spec.rb' paper_trail-12.0.0/Appraisals000066400000000000000000000013611403037104100161100ustar00rootroot00000000000000# frozen_string_literal: true # Specify here only version constraints that differ from # `paper_trail.gemspec`. # # > The dependencies in your Appraisals file are combined with dependencies in # > your Gemfile, so you don't need to repeat anything that's the same for each # > appraisal. If something is specified in both the Gemfile and an appraisal, # > the version from the appraisal takes precedence. # > https://github.com/thoughtbot/appraisal # # appraise "rails-5.2" do gem "rails", "~> 5.2.4" gem "rails-controller-testing", "~> 1.0.2" end appraise "rails-6.0" do gem "rails", "~> 6.0.3" gem "rails-controller-testing", "~> 1.0.3" end appraise "rails-6.1" do gem "rails", "~> 6.1.0" gem "rails-controller-testing", "~> 1.0.5" end paper_trail-12.0.0/CHANGELOG.md000066400000000000000000001375421403037104100157120ustar00rootroot00000000000000# Changelog This project follows [semver 2.0.0](http://semver.org/spec/v2.0.0.html) and the recommendations of [keepachangelog.com](http://keepachangelog.com/). ## Unreleased ### Breaking Changes - None ### Added - None ### Fixed - None ## 12.0.0 (2021-03-29) ### Breaking Changes - Rails: The deprecated `config.paper_trail` configuration technique has been removed. This configuration object was deprecated in 10.2.0. It only had one key, `config.paper_trail.enabled`. Please review docs section [2.d. Turning PaperTrail Off](https://github.com/paper-trail-gem/paper_trail/#2d-turning-papertrail-off) for alternatives. ### Added - `where_object_changes_from` queries for versions where the object's attributes changed from one set of known values to any other set of values. ### Fixed - [#1281](https://github.com/paper-trail-gem/paper_trail/pull/1281) Rails: Instead of an `Engine`, PT now provides a `Railtie`, which is simpler. - Expand kwargs passed to `save_with_version` using double splat operator - Rails 6.1 compatibility - [#1287](https://github.com/paper-trail-gem/paper_trail/issues/1287) - Fix 'rails db:migrate' error when run against an app with mysql2 adapter ### Dependencies - Drop support for ruby 2.4 (reached EoL on 2020-03-31) ## 11.1.0 (2020-12-16) ### Breaking Changes - None ### Added - [#1272](https://github.com/paper-trail-gem/paper_trail/issues/1272) - Rails 6.1 compatibility ### Fixed - None ## 11.0.0 (2020-08-24) ### Breaking Changes - [#1221](https://github.com/paper-trail-gem/paper_trail/pull/1221) If you use the experimental association-tracking feature, and you forget to install the `paper_trail-association_tracking` gem, then, when you call `track_associations=` you will get a `NoMethodError` instead of the previous detailed error. Normally the removal of such a temporary warning would not be treated as a breaking change, but since this relates to PT-AT, it seemed warranted. - `VersionConcern#sibling_versions` is now private, and its arity has changed. ### Added - None ### Fixed - [#1242](https://github.com/paper-trail-gem/paper_trail/issues/1242) - Generator make wrong migration for Oracle database - [#1238](https://github.com/paper-trail-gem/paper_trail/pull/1238) - Query optimization in `reify` - [#1256](https://github.com/paper-trail-gem/paper_trail/pull/1256) - Skip version for timestamp when changed attributed is ignored via Hash ### Dependencies - Drop support for rails <= 5.1 (reached EOL when 6.0 was released, per https://guides.rubyonrails.org/maintenance_policy.html) - Drop support for ruby 2.3 (reached EOL on 2019-04-01) ## 10.3.1 (2019-07-31) ### Breaking Changes - None ### Added - None ### Fixed - None ### Dependencies - [#1213](https://github.com/paper-trail-gem/paper_trail/pull/1213) - Allow contributors to install incompatible versions of ActiveRecord. See discussion in paper_trail/compatibility.rb ## 10.3.0 (2019-04-09) ### Breaking Changes - None ### Added - [#1194](https://github.com/paper-trail-gem/paper_trail/pull/1194) - Added a 'limit' option to has_paper_trail, allowing models to override the global `PaperTrail.config.version_limit` setting. ### Fixed - [#1196](https://github.com/paper-trail-gem/paper_trail/pull/1196) - In the installation migration, change `versions.item_id` from 4 byte integer to 8 bytes (bigint). ## 10.2.1 (2019-03-14) ### Breaking Changes - None ### Added - None ### Fixed - [#1184](https://github.com/paper-trail-gem/paper_trail/pull/1184) - No need to calculate previous values of skipped attributes - [#1188](https://github.com/paper-trail-gem/paper_trail/pull/1188) - Optimized the memory allocations during the building of every particular Version object. That can help a lot for heavy bulk processing. In additional we advise to use `json[b]` DB types for `object` and `object_changes` Version columns, in order to reach best possible RAM performance. ## 10.2.0 (2019-01-31) ### Breaking Changes - None ### Added - Support ruby 2.6.0 - [#1182](https://github.com/paper-trail-gem/paper_trail/pull/1182) - Support rails 6.0.0.beta1 ### Fixed - [#1177](https://github.com/paper-trail-gem/paper_trail/pull/1177) - Do not store ignored and skipped attributes in `object_changes` on destroy. ### Deprecated - [#1176](https://github.com/paper-trail-gem/paper_trail/pull/1176) - `config.paper_trail.enabled` ## 10.1.0 (2018-12-04) ### Breaking Changes - None ### Deprecated - [#1158](https://github.com/paper-trail-gem/paper_trail/pull/1158) - Passing association name as `versions:` option or Version class name as `class_name:` options directly to `has_paper_trail`. Use `has_paper_trail versions: {name: :my_name, class_name: "MyVersionModel"}` instead. ### Added - [#1166](https://github.com/paper-trail-gem/paper_trail/pull/1166) - New global option `has_paper_trail_defaults`, defaults for `has_paper_trail` - [#1158](https://github.com/paper-trail-gem/paper_trail/pull/1158) — Add the ability to pass options, such as `scope` or `extend:` to the `has_many :versions` association macro. - [#1172](https://github.com/paper-trail-gem/paper_trail/pull/1172) - Support rails 6.0.0.alpha ### Fixed - None ## 10.0.1 (2018-09-01) ### Breaking Changes - None ### Added - None ### Fixed - [#1150](https://github.com/paper-trail-gem/paper_trail/pull/1150) - When PT-AT is not loaded, and someone sets `track_associations = false`, it should `warn`, not `raise`. ## 10.0.0 (2018-09-01) PT 10 tackles some tough issues that required breaking changes. We fixed a rare issue with STI, and saved major disk space in databases with tens of millions of version records. Special thanks to @lorint and @seanlinsley, respectively. ### Breaking changes affecting most people - [#1132](https://github.com/paper-trail-gem/paper_trail/pull/1132) - Removed a dozen methods deprecated in PT 9. Make sure you've addressed all deprecation warnings before upgrading. ### Breaking changes affecting fewer people - [db9c392d](https://github.com/paper-trail-gem/paper_trail/commit/db9c392d) - `paper_trail-association_tracking` is no longer a runtime dependency. If you use it (`track_associations = true`) you must now add it to your own `Gemfile`. See also [PT-AT #7](https://github.com/westonganger/paper_trail-association_tracking/issues/7) - [#1130](https://github.com/paper-trail-gem/paper_trail/pull/1130) - Removed `save_changes`. For those wanting to save space, it's more effective to drop the `object` column. If you need ultimate control over the `object_changes` column, you can write your own `object_changes_adapter`. ### Breaking changes most people won't care about - [#1121](https://github.com/paper-trail-gem/paper_trail/issues/1121) - `touch` now always inserts `null` in `object_changes`. - [#1123](https://github.com/paper-trail-gem/paper_trail/pull/1123) - `object_changes` is now populated on destroy in order to make `where_object_changes` usable when you've dropped the `object` column. Sean is working on an optional backport migration and will post about it in [#1099](https://github.com/paper-trail-gem/paper_trail/issues/1099) when he's done. ### Added - [#1099](https://github.com/paper-trail-gem/paper_trail/issues/1099) - Ability to save ~50% storage space by making the `object` column optional. Note that this disables `reify` and `where_object`. ### Fixed - [#594](https://github.com/paper-trail-gem/paper_trail/issues/594) - A rare issue with reification of STI subclasses, affecting only PT-AT users who have a model with mutliple associations, whose foreign keys are named the same, and whose foreign models are STI with the same parent class. This fix requires a schema change. See [docs section 4.b.1 The optional `item_subtype` column](https://github.com/paper-trail-gem/paper_trail#4b-associations) for instructions. ## 9.2.0 (2018-06-09) ### Breaking Changes - None ### Added - [#1070](https://github.com/paper-trail-gem/paper_trail/issues/1070) - The experimental associations tracking feature has been moved to a separate gem, [paper_trail-association_tracking](https://github.com/westonganger/paper_trail-association_tracking). PT will, for now, have a runtime dependency on this new gem. So, assuming the gem extraction goes well, no breaking changes are anticipated. - [#1093](https://github.com/paper-trail-gem/paper_trail/pull/1093) - `PaperTrail.config.object_changes_adapter` - Expert users can write their own adapter to control how the changes for each version are stored in the object_changes column. An example of this implementation using the hashdiff gem can be found here: [paper_trail-hashdiff](https://github.com/hashwin/paper_trail-hashdiff) ### Fixed - None ## 9.1.1 (2018-05-30) ### Breaking Changes - None ### Added - None ### Fixed - [#1098](https://github.com/paper-trail-gem/paper_trail/pull/1098) - Fix regression in 9.1.0 re: generator `--with-associations` ## 9.1.0 (2018-05-23) ### Breaking Changes - None ### Added - [#1091](https://github.com/paper-trail-gem/paper_trail/pull/1091) - `PaperTrail.config.association_reify_error_behaviour` - For users of the experimental association tracking feature. Starting with PT 9.0.0, reification of `has_one` associations is stricter. This option gives users some choices for how to handle the `PaperTrail::Reifiers::HasOne::FoundMoreThanOne` error introduced in PT 9. See README section 4.b.1. "Known Issues" for more details. ### Fixed - None ## 9.0.2 (2018-05-14) ### Breaking Changes - None ### Added - None ### Fixed - [#1084](https://github.com/paper-trail-gem/paper_trail/pull/1084) The `touch` callback (added in 9.0.0) now inserts the correct value into the `versions.object` column. ### Other - Stop testing against rails 5.0, which reached EoL on 2018-04-15, when 5.2 was released, per the [rails maintenance policy](http://guides.rubyonrails.org/maintenance_policy.html) ## 9.0.1 (2018-04-23) ### Breaking Changes - None ### Added - [#1076](https://github.com/paper-trail-gem/paper_trail/issues/1076) Add `save_with_version`, a replacement for deprecated method `touch_with_version`. Not exactly the same, it's a save, not a touch. - [#1074](https://github.com/paper-trail-gem/paper_trail/pull/1074) `PaperTrail.request do ... end` now returns the value the given block. ### Fixed - None ## 9.0.0 (2018-03-26) ### Breaking Changes, Major - [#1063](https://github.com/paper-trail-gem/paper_trail/pull/1063) - `touch` will now create a version. This can be configured with the `:on` option. See documentation section 2.a. "Choosing Lifecycle Events To Monitor". - Drop support for ruby 2.2, [whose EoL is the end of March, 2018](https://www.ruby-lang.org/en/news/2017/09/14/ruby-2-2-8-released/) - PaperTrail now uses `frozen_string_literal`, so you should assume that all strings it returns are frozen. - Using `where_object_changes` to read YAML from a text column will now raise error, was deprecated in 8.1.0. ### Breaking Changes, Minor - Removed deprecated `Version#originator`, use `#paper_trail_originator` - Using paper_trail.on_destroy(:after) with ActiveRecord's belongs_to_required_by_default will produce an error instead of a warning. - Removed the `warn_about_not_setting_whodunnit` controller method. This will only be a problem for you if you are skipping it, eg. `skip_after_action :warn_about_not_setting_whodunnit`, which few people did. ### Deprecated - [#1063](https://github.com/paper-trail-gem/paper_trail/pull/1063) - `paper_trail.touch_with_version` is deprecated in favor of `touch`. - [#1033](https://github.com/paper-trail-gem/paper_trail/pull/1033) - Request variables are now set using eg. `PaperTrail.request.whodunnit=` and the old way, `PaperTrail.whodunnit=` is deprecated. ### Added - [#1067](https://github.com/paper-trail-gem/paper_trail/pull/1033) - Add support to Rails 5.2. - [#1033](https://github.com/paper-trail-gem/paper_trail/pull/1033) - Set request variables temporarily using a block, eg. `PaperTrail.request(whodunnit: 'Jared') do .. end` - [#1037](https://github.com/paper-trail-gem/paper_trail/pull/1037) Add `paper_trail.update_columns` - [#961](https://github.com/paper-trail-gem/paper_trail/issues/961) - Instead of crashing when misconfigured Custom Version Classes are used, an error will be raised earlier, with a much more helpful message. - Failing to set PaperTrail.config.track_associations will no longer produce a warning. The default (false) will remain the same. ### Fixed - [#1051](https://github.com/paper-trail-gem/paper_trail/issues/1051) - `touch_with_version` should always create a version, regardles of the `:only` option - [#1047](https://github.com/paper-trail-gem/paper_trail/issues/1047) - A rare issue where `touch_with_version` saved less data than expected, but only when the update callback was not installed, eg. `has_paper_trail(on: [])` - [#1042](https://github.com/paper-trail-gem/paper_trail/issues/1042) - A rare issue with load order when using PT outside of rails - [#594](https://github.com/paper-trail-gem/paper_trail/issues/594) - Improved the error message for a very rare issue in the experimental association tracking feature involving two has_one associations, referencing STI models with the same base class, and the same foreign_key. ## 8.1.2 (2017-12-22) ### Breaking Changes - None ### Added - None ### Fixed - [#1028](https://github.com/paper-trail-gem/paper_trail/pull/1028) Reifying associations will now use `base_class` name instead of class name to reify STI models corrrectly. ## 8.1.1 (2017-12-10) ### Breaking Changes - None ### Added - None ### Fixed - [#1018](https://github.com/paper-trail-gem/paper_trail/pull/1018) Serializing postgres arrays ## 8.1.0 (2017-11-30) ### Breaking Changes - None ### Added - [#997](https://github.com/paper-trail-gem/paper_trail/pull/997) Deprecate `where_object_changes` when reading YAML from a text column ### Fixed - [#1009](https://github.com/paper-trail-gem/paper_trail/pull/1009) End generated `config/initializers/paper_trail.rb` with newline. ## 8.0.1 (2017-10-25) ### Breaking Changes - None ### Added - None ### Fixed - [#1003](https://github.com/paper-trail-gem/paper_trail/pull/1003) - Warn when PT cannot be loaded because rails is not loaded yet. ## 8.0.0 (2017-10-04) ### Breaking Changes - Drop support for rails 4.0 and 4.1, whose EoL was [2016-06-30](http://weblog.rubyonrails.org/2016/6/30/Rails-5-0-final/) - Drop support for ruby 2.1, whose EoL was [2017-04-01](http://bit.ly/2ppWDYa) - [#803](https://github.com/paper-trail-gem/paper_trail/issues/803) - where_object_changes no longer supports reading json from a text column ### Added - None ### Fixed - [#996](https://github.com/paper-trail-gem/paper_trail/pull/996) - Incorrect item_type in association reification query ## 7.1.3 (2017-09-19) ### Breaking Changes - None ### Added - None ### Fixed - [#988](https://github.com/paper-trail-gem/paper_trail/pull/988) - Fix ActiveRecord version check in `VersionConcern` for Rails 4.0 ## 7.1.2 (2017-08-30) ### Breaking Changes - None ### Added - None ### Fixed - [#985](https://github.com/paper-trail-gem/paper_trail/pull/985) - Fix RecordInvalid error on nil item association when belongs_to_required_by_default is enabled. ## 7.1.1 (2017-08-18) ### Breaking Changes - None ### Added - None ### Fixed - Stop including unnecessary files in released gem. Reduces .gem file size from 100K to 30K. - [#984](https://github.com/paper-trail-gem/paper_trail/pull/984) - Fix NameError suspected to be caused by autoload race condition. ## 7.1.0 (2017-07-09) ### Breaking Changes - None ### Added - [#803](https://github.com/paper-trail-gem/paper_trail/issues/803) Deprecate `where_object_changes` when reading json from a text column - [#976](https://github.com/paper-trail-gem/paper_trail/pull/976) `PaperTrail.whodunnit` accepts a `Proc` ### Fixed - None ## 7.0.3 (2017-06-01) ### Breaking Changes - None ### Added - None ### Fixed - [#959](https://github.com/paper-trail-gem/paper_trail/pull/959) - Add migration version (eg. `[5.1]`) to all migration generators. ## 7.0.2 (2017-04-26) ### Breaking Changes - None ### Added - [#932](https://github.com/paper-trail-gem/paper_trail/pull/932) - `PaperTrail.whodunnit` now accepts a block. ### Fixed - [#956](https://github.com/paper-trail-gem/paper_trail/pull/956) - Fix ActiveRecord >= 5.1 version check ## 7.0.1 (2017-04-10) ### Breaking Changes - None ### Added - Generate cleaner migrations for databases other than MySQL ### Fixed - [#949](https://github.com/paper-trail-gem/paper_trail/issues/949) - Inherit from the new versioned migration class, e.g. `ActiveRecord::Migration[5.1]` ## 7.0.0 (2017-04-01) ### Breaking Changes - Drop support for ruby 1.9.3, whose EOL was 2015-02-23 - Drop support for ruby 2.0.0, whose EOL was 2016-02-24 - Remove deprecated config methods: - PaperTrail.serialized_attributes? - PaperTrail.config.serialized_attributes - PaperTrail.config.serialized_attributes= - Sinatra integration moved to [paper_trail-sinatra](https://github.com/jaredbeck/paper_trail-sinatra) gem ### Added - `PaperTrail.gem_version` returns a `Gem::Version`, nice for comparisons. ### Fixed - [#925](https://github.com/paper-trail-gem/paper_trail/pull/925) - Update RSpec matchers to work with custom version association names - [#929](https://github.com/paper-trail-gem/paper_trail/pull/929) - Fix error calling private method in rails 4.0 - [#938](https://github.com/paper-trail-gem/paper_trail/pull/938) - Fix bug where non-standard foreign key names broke belongs_to associations - [#940](https://github.com/paper-trail-gem/paper_trail/pull/940) - When destroying versions to stay under version_limit, don't rely on the database to implicitly return the versions in the right order ## 6.0.2 (2016-12-13) ### Breaking Changes - None ### Added - None ### Fixed - `88e513f` - Surprise argument modification bug in `where_object_changes` - `c7efd62` - Column type-detection bug in `where_object_changes` - [#905](https://github.com/paper-trail-gem/paper_trail/pull/905) - Only invoke `logger.warn` if `logger` instance exists ### Code Quality - Improve Metrics/AbcSize from 30 to 22 - Improve Metrics/PerceivedComplexity from 10 to 9 ## 6.0.1 (2016-12-04) ### Breaking Changes - None ### Added - None ### Fixed - Remove rails 3 features that are no longer supported, most notably, `protected_attributes`. ## 6.0.0 (2016-12-03) Now with rails 5.1 support, and less model pollution! About 40 methods that were polluting your models' namespaces have been removed, reducing the chances of a name conflict with your methods. ### Breaking Changes - [#898](https://github.com/paper-trail-gem/paper_trail/pull/898) - Dropped support for rails 3 - [#864](https://github.com/paper-trail-gem/paper_trail/pull/864) - The model methods deprecated in 5.2.0 have been removed. Use `paper_trail.x` instead of `x`. - [#861](https://github.com/paper-trail-gem/paper_trail/pull/861) - `timestamp_field=` removed without replacement. It is no longer configurable. The timestamp field in the `versions` table must now be named `created_at`. ### Deprecated - None ### Added - [#900](https://github.com/paper-trail-gem/paper_trail/pull/900/files) - Support for rails 5.1 - [#881](https://github.com/paper-trail-gem/paper_trail/pull/881) - Add RSpec matcher `have_a_version_with_changes` for easier testing. ### Fixed - None ## 5.2.3 (2016-11-29) ### Breaking Changes - None ### Deprecated - None ### Added - None ### Fixed - [#889](https://github.com/paper-trail-gem/paper_trail/pull/889) - Fix warning message in instances when a version can't be persisted due to validation errors. - [#868](https://github.com/paper-trail-gem/paper_trail/pull/868) Fix usage of find_by_id when primary key is not id, affecting reifying certain records. ## 5.2.2 (2016-09-08) ### Breaking Changes - None ### Deprecated - [#863](https://github.com/paper-trail-gem/paper_trail/pull/863) - PaperTrail.timestamp_field= deprecated without replacement. See [#861](https://github.com/paper-trail-gem/paper_trail/pull/861) for discussion. ### Added - None ### Fixed - None ## 5.2.1 (2016-09-02) ### Breaking Changes - None ### Deprecated - None ### Added - None ### Fixed - [#857](https://github.com/paper-trail-gem/paper_trail/pull/857) - Fix deserialization of enums written by PT 4. - [#798](https://github.com/paper-trail-gem/paper_trail/issues/798) - Fix a rare bug with serialization of enums in rails 4.2 only when using `touch_with_version`. ## 5.2.0 (2016-06-27) ### Breaking Changes - None ### Deprecated - [#719](https://github.com/paper-trail-gem/paper_trail/pull/719) - The majority of model methods. Use paper_trail.x instead of x. Why? Your models are a crowded namespace, and we want to get out of your way! ### Added - None ### Fixed - None ## 5.1.1 (2016-05-31) ### Breaking Changes - None ### Added - None ### Fixed - [#813](https://github.com/paper-trail-gem/paper_trail/pull/813) - Warning for paper_trail_on_destroy(:after) for pre-releases of AR 5 - [#651](https://github.com/paper-trail-gem/paper_trail/issues/651) - Bug with installing PT on MySQL <= 5.6 ## 5.1.0 (2016-05-20) ### Breaking Changes - None ### Added - [#809](https://github.com/paper-trail-gem/paper_trail/pull/809) - Print warning if version cannot be saved. ### Fixed - [#812](https://github.com/paper-trail-gem/paper_trail/pull/812) - Issue with saving HABTM associated objects using accepts_nested_attributes_for - [#811](https://github.com/paper-trail-gem/paper_trail/pull/811) - Avoid unnecessary query in #record_destroy - Improvements to documentation ## 5.0.1 (2016-05-04) ### Breaking Changes - None ### Added - None ### Fixed - [#791](https://github.com/paper-trail-gem/paper_trail/issues/791) - A rare issue in applications that override `warn`. - [#789](https://github.com/paper-trail-gem/paper_trail/issues/789) - A potentially common issue, in applications with initializers that use versioned models. ## 5.0.0 (2016-05-02) ### Breaking Changes - [#758](https://github.com/paper-trail-gem/paper_trail/pull/758) - `PaperTrail.config.track_associations` getter method removed, use `track_associations?` instead. - [#740](https://github.com/paper-trail-gem/paper_trail/issues/740) - `PaperTrail.config.track_associations?` now defaults to false - [#723](https://github.com/paper-trail-gem/paper_trail/pull/723) - `PaperTrail.enabled=` now affects all threads - [#556](https://github.com/paper-trail-gem/paper_trail/pull/556) / [#301](https://github.com/paper-trail-gem/paper_trail/issues/301) - If you are tracking who is responsible for changes with `whodunnit`, be aware that PaperTrail no longer adds the `set_paper_trail_whodunnit` before_action for you. Please add this before_action to your ApplicationController to continue recording whodunnit. See the readme for an example. - [#683](https://github.com/paper-trail-gem/paper_trail/pull/683) / [#682](https://github.com/paper-trail-gem/paper_trail/issues/682) - Destroy callback default changed to :before to accommodate ActiveRecord 5 option `belongs_to_required_by_default` and new Rails 5 default. ### Added - [#771](https://github.com/paper-trail-gem/paper_trail/pull/771) - Added support for has_and_belongs_to_many associations - [#741](https://github.com/paper-trail-gem/paper_trail/issues/741) / [#681](https://github.com/paper-trail-gem/paper_trail/pull/681) MySQL unicode support in migration generator - [#689](https://github.com/paper-trail-gem/paper_trail/pull/689) - Rails 5 compatibility - Added a rails config option: `config.paper_trail.enabled` - [#503](https://github.com/paper-trail-gem/paper_trail/pull/730) - Support for reifying belongs_to associations. ### Fixed - [#777](https://github.com/paper-trail-gem/paper_trail/issues/777) - Support HMT associations with `:source` option. - [#738](https://github.com/paper-trail-gem/paper_trail/issues/738) - Rare bug where a non-versioned STI parent caused `changeset` to return an empty hash. - [#731](https://github.com/paper-trail-gem/paper_trail/pull/731) - Map enums to database values before storing in `object_changes` column. - [#715](https://github.com/paper-trail-gem/paper_trail/issues/715) - Optimize post-rollback association reset. - [#701](https://github.com/paper-trail-gem/paper_trail/pull/701) / [#699](https://github.com/paper-trail-gem/paper_trail/issues/699) - Cleaning old versions explicitly preserves the most recent versions instead of relying on database result ordering. - [#635](https://github.com/paper-trail-gem/paper_trail/issues/635) - A bug where it was not possible to disable PT when using a multi-threaded webserver. - [#584](https://github.com/paper-trail-gem/paper_trail/issues/584) - Fixed deprecation warning for Active Record after_callback / after_commit ## 4.2.0 (2016-05-31) ### Breaking Changes - None ### Added - [#808](https://github.com/paper-trail-gem/paper_trail/pull/808) - Warn when destroy callback is set to :after with ActiveRecord 5 option `belongs_to_required_by_default` set to `true`. ### Fixed - None ## 4.1.0 (2016-01-30) ### Known Issues - Version changesets now store ENUM values incorrectly (as nulls). Previously the values were stored as strings. This only affects Rails 4, not Rails 5. See [#926](https://github.com/paper-trail-gem/paper_trail/pull/926) ### Breaking Changes - None ### Added - A way to control the order of AR callbacks. [#614](https://github.com/paper-trail-gem/paper_trail/pull/614) - Added `unversioned_attributes` option to `reify`. [#579](https://github.com/paper-trail-gem/paper_trail/pull/579) ### Fixed - None ## 4.0.2 (2016-01-19) ### Breaking Changes - None ### Added - None ### Fixed - [#696](https://github.com/paper-trail-gem/paper_trail/issues/696) / [#697](https://github.com/paper-trail-gem/paper_trail/pull/697) Bind JSON query parameters in `where_object` and `where_object_changes`. ## 4.0.1 (2015-12-14) ### Breaking Changes - None ### Added - None ### Fixed - [#636](https://github.com/paper-trail-gem/paper_trail/issues/636) - Should compile assets without a db connection - [#589](https://github.com/paper-trail-gem/paper_trail/pull/589) / [#588](https://github.com/paper-trail-gem/paper_trail/issues/588) - Fixes timestamp for "create" versions ## 4.0.0 (2015-07-30) This major release adds JSON column support in PostgreSQL, limited support for versioning associations, various new configuration options, and a year's worth of bug fixes. Thanks to everyone who helped test the two betas and two release candidates. ### Breaking Changes - Using a Rails initializer to reopen PaperTrail::Version or otherwise extend PaperTrail is no longer recommended. An alternative is described in the readme. See https://github.com/paper-trail-gem/paper_trail/pull/557 and https://github.com/paper-trail-gem/paper_trail/pull/492. - If you depend on the `RSpec` or `Cucumber` helpers, you must [require them in your test helper](https://github.com/paper-trail-gem/paper_trail#testing). - [#566](https://github.com/paper-trail-gem/paper_trail/pull/566) - Removed deprecated methods `paper_trail_on` and `paper_trail_off`. Use `paper_trail_on!` and `paper_trail_off!` instead. - [#458](https://github.com/paper-trail-gem/paper_trail/pull/458) - Version metadata (the `:meta` option) from AR attributes for `create` events will now save the current value instead of `nil`. - [#391](https://github.com/paper-trail-gem/paper_trail/issues/391) - `object_changes` value should dump to `YAML` as a normal `Hash` instead of an `ActiveSupport::HashWithIndifferentAccess`. - [#375](https://github.com/paper-trail-gem/paper_trail/pull/375) / [#374](https://github.com/paper-trail-gem/paper_trail/issues/374) / [#354](https://github.com/paper-trail-gem/paper_trail/issues/354) / [#131](https://github.com/paper-trail-gem/paper_trail/issues/131) - Versions are now saved with an `after_` callback, instead of a `before_` callback. This ensures that the timestamp field for a version matches the corresponding timestamp in the model. - `3da1f104` - `PaperTrail.config` and `PaperTrail.configure` are now identical: both return the `PaperTrail::Config` instance and also yield it if a block is provided. ### Added - [#525](https://github.com/paper-trail-gem/paper_trail/issues/525) / [#512](https://github.com/paper-trail-gem/paper_trail/pull/512) - Support for virtual accessors and redefined setter and getter methods. - [#518](https://github.com/paper-trail-gem/paper_trail/pull/518) - Support for querying against PostgreSQL's [`JSON` and `JSONB` column types](http://www.postgresql.org/docs/9.4/static/datatype-json.html) via `PaperTrail::VersionConcern#where_object` and `PaperTrail::VersionConcern#where_object_changes` - [#507](https://github.com/paper-trail-gem/paper_trail/pull/507) - New option: `:save_changes` controls whether or not to save changes to the `object_changes` column (if it exists). - [#500](https://github.com/paper-trail-gem/paper_trail/pull/500) - Support for passing an empty array to the `on` option (`on: []`) to disable all automatic versioning. - [#494](https://github.com/paper-trail-gem/paper_trail/issues/494) - The install generator will warn the user if the migration they are attempting to generate already exists. - [#484](https://github.com/paper-trail-gem/paper_trail/pull/484) - Support for [PostgreSQL's `JSONB` Type](http://www.postgresql.org/docs/9.4/static/datatype-json.html) for storing `object` and `object_changes`. - [#439](https://github.com/paper-trail-gem/paper_trail/pull/439) / [#12](https://github.com/paper-trail-gem/paper_trail/issues/12) - Support for versioning associations (has many, has one, etc.) one level deep. - [#420](https://github.com/paper-trail-gem/paper_trail/issues/420) - Add `VersionConcern#where_object_changes` instance method; acts as a helper for querying against the `object_changes` column in versions table. - [#416](https://github.com/paper-trail-gem/paper_trail/issues/416) - Added a `config` option for enabling/disabling utilization of `serialized_attributes` for `ActiveRecord`, necessary because `serialized_attributes` has been deprecated in `ActiveRecord` version `4.2` and will be removed in version `5.0` - [#399](https://github.com/paper-trail-gem/paper_trail/pull/399) - Add `:dup` argument for options hash to `reify` which forces a new model instance. - [#394](https://github.com/paper-trail-gem/paper_trail/pull/394) - Add RSpec matcher `have_a_version_with` for easier testing. - [#347](https://github.com/paper-trail-gem/paper_trail/pull/347) - Autoload `ActiveRecord` models in via a `Rails::Engine` when the gem is used with `Rails`. ### Fixed - [#563](https://github.com/paper-trail-gem/paper_trail/pull/563) - Fixed a bug in `touch_with_version` so that it will still create a version even when the `on` option is, e.g. `[:create]`. - [#541](https://github.com/paper-trail-gem/paper_trail/pull/541) - `PaperTrail.config.enabled` should be Thread Safe - [#451](https://github.com/paper-trail-gem/paper_trail/issues/451) - Fix `reify` method in context of model where the base class has a default scope, and the live instance is not scoped within that default scope. - [#440](https://github.com/paper-trail-gem/paper_trail/pull/440) - `versions` association should clear/reload after a transaction rollback. - [#438](https://github.com/paper-trail-gem/paper_trail/issues/438) - `ModelKlass.paper_trail_enabled_for_model?` should return `false` if `has_paper_trail` has not been declared on the class. - [#404](https://github.com/paper-trail-gem/paper_trail/issues/404) / [#428](https://github.com/paper-trail-gem/paper_trail/issues/428) - `model_instance.dup` does not need to be invoked when examining what the instance looked like before changes were persisted, which avoids issues if a 3rd party has overriden the `dup` behavior. Also fixes errors occuring when a user attempts to update the inheritance column on an STI model instance in `ActiveRecord` 4.1.x - [#427](https://github.com/paper-trail-gem/paper_trail/pull/427) - Fix `reify` method in context of model where a column has been removed. - [#414](https://github.com/paper-trail-gem/paper_trail/issues/414) - Fix functionality `ignore` argument to `has_paper_trail` in `ActiveRecord` 4. - [#413](https://github.com/paper-trail-gem/paper_trail/issues/413) - Utilize [RequestStore](https://github.com/steveklabnik/request_store) to ensure that the `PaperTrail.whodunnit` is set in a thread safe manner within Rails and Sinatra. - [#381](https://github.com/paper-trail-gem/paper_trail/issues/381) - Fix `irb` warning: `can't alias context from irb_context`. `Rspec` and `Cucumber` helpers should not be loaded by default, regardless of whether those libraries are loaded. - [#248](https://github.com/paper-trail-gem/paper_trail/issues/248) - In MySQL, to prevent truncation, generated migrations now use `longtext` instead of `text`. - Methods handling serialized attributes should fallback to the currently set Serializer instead of always falling back to `PaperTrail::Serializers::YAML`. ### Deprecated - [#479](https://github.com/paper-trail-gem/paper_trail/issues/479) - Deprecated `originator` method, use `paper_trail_originator`. ## 3.0.9 - [#479](https://github.com/paper-trail-gem/paper_trail/issues/479) - Deprecated `originator` method in favor of `paper_trail_originator` Deprecation warning informs users that the `originator` of the methods will be removed in version `4.0`. (Backported from v4) - Updated deprecation warnings for `Model.paper_trail_on` and `Model.paper_trail_off` to have display correct version number the methods will be removed (`4.0`) ## 3.0.8 - [#525](https://github.com/paper-trail-gem/paper_trail/issues/525) / [#512](https://github.com/paper-trail-gem/paper_trail/pull/512) - Support for virtual accessors and redefined setter and getter methods. ## 3.0.7 - [#404](https://github.com/paper-trail-gem/paper_trail/issues/404) / [#428](https://github.com/paper-trail-gem/paper_trail/issues/428) - Fix errors occuring when a user attempts to update the inheritance column on an STI model instance in `ActiveRecord` 4.1.x ## 3.0.6 - [#414](https://github.com/paper-trail-gem/paper_trail/issues/414) - Backport fix for `ignore` argument to `has_paper_trail` in `ActiveRecord` 4. ## 3.0.5 - [#401](https://github.com/paper-trail-gem/paper_trail/issues/401) / [#406](https://github.com/paper-trail-gem/paper_trail/issues/406) - `PaperTrail::Version` class is not loaded via a `Rails::Engine`, even when the gem is used within Rails. This feature has will be re-introduced in version `4.0`. - [#398](https://github.com/paper-trail-gem/paper_trail/pull/398) - Only require the `RSpec` helper if `RSpec::Core` is required. ## 3.0.3 *This version was yanked from RubyGems and has been replaced by version `3.0.5`, which is almost identical, but does not eager load in the `PaperTrail::Version` class through a `Rails::Engine` when the gem is used on Rails since it was causing issues for some users.* - [#386](https://github.com/paper-trail-gem/paper_trail/issues/386) - Fix eager loading of `versions` association with custom class name in `ActiveRecord` 4.1. - [#384](https://github.com/paper-trail-gem/paper_trail/issues/384) - Fix `VersionConcern#originator` instance method. - [#383](https://github.com/paper-trail-gem/paper_trail/pull/383) - Make gem compatible with `ActiveRecord::Enum` (available in `ActiveRecord` 4.1+). - [#380](https://github.com/paper-trail-gem/paper_trail/pull/380) / [#377](https://github.com/paper-trail-gem/paper_trail/issues/377) - Add `VersionConcern#where_object` instance method; acts as a helper for querying against the `object` column in versions table. - [#373](https://github.com/paper-trail-gem/paper_trail/pull/373) - Fix default sort order for the `versions` association in `ActiveRecord` 4.1. - [#372](https://github.com/paper-trail-gem/paper_trail/pull/372) - Use [Arel](https://github.com/rails/arel) for SQL construction. - [#365](https://github.com/paper-trail-gem/paper_trail/issues/365) - `VersionConcern#version_at` should return `nil` when receiving a timestamp that occured after the object was destroyed. - Expand `PaperTrail::VERSION` into a module, mimicking the form used by Rails to give it some additional modularity & versatility. - Fixed `VersionConcern#index` instance method so that it conforms to using the primary key for ordering when possible. ## 3.0.2 - [#357](https://github.com/paper-trail-gem/paper_trail/issues/357) - If a `Version` instance is reified and then persisted at that state, it's timestamp attributes for update should still get `touch`ed. - [#351](https://github.com/paper-trail-gem/paper_trail/pull/351) / [#352](https://github.com/paper-trail-gem/paper_trail/pull/352) - `PaperTrail::Rails::Controller` should hook into all controller types, and should not get loaded unless `ActionController` is. - [#346](https://github.com/paper-trail-gem/paper_trail/pull/346) - `user_for_paper_trail` method should accommodate different types for return values from `current_user` method. - [#344](https://github.com/paper-trail-gem/paper_trail/pull/344) - Gem is now tested against `MySQL` and `PostgreSQL` in addition to `SQLite`. - [#317](https://github.com/paper-trail-gem/paper_trail/issues/317) / [#314](https://github.com/paper-trail-gem/paper_trail/issues/314) - `versions` should default to ordering via the primary key if it is an integer to avoid timestamp comparison issues. - `PaperTrail::Cleaner.clean_versions!` should group versions by `PaperTrail.timestamp_field` when deciding which ones to keep / destroy, instead of always grouping by the `created_at` field. - If a `Version` instance is reified and then persisted at that state, it's source version (`model_instance#version_association_name`, usually `model_instance#version`) will get cleared since persisting it causes it to become the live instance. - If `destroy` actions are tracked for a versioned model, invoking `destroy` on the model will cause the corresponding version that gets generated to be assigned as the source version (`model_instance#version_association_name`, usually `model_instance#version`). ## 3.0.1 - [#340](https://github.com/paper-trail-gem/paper_trail/issues/340) - Prevent potential error encountered when using the `InstallGenerator` with Rails `4.1.0.rc1`. - [#334](https://github.com/paper-trail-gem/paper_trail/pull/334) - Add small-scope `whodunnit` method to `PaperTrail::Model::InstanceMethods`. - [#329](https://github.com/paper-trail-gem/paper_trail/issues/329) - Add `touch_with_version` method to `PaperTrail::Model::InstanceMethods`, to allow for generating a version while `touch`ing a model. - [#328](https://github.com/paper-trail-gem/paper_trail/pull/328) / [#326](https://github.com/paper-trail-gem/paper_trail/issues/326) / [#307](https://github.com/paper-trail-gem/paper_trail/issues/307) - `Model.paper_trail_enabled_for_model?` and `model_instance.without_versioning` is now thread-safe. - [#316](https://github.com/paper-trail-gem/paper_trail/issues/316) - `user_for_paper_trail` should default to `current_user.try(:id)` instead of `current_user` (if `current_user` is defined). - [#313](https://github.com/paper-trail-gem/paper_trail/pull/313) - Make the `Rails::Controller` helper compatible with `ActionController::API` for compatibility with the [`rails-api`](https://github.com/rails-api/rails-api) gem. - [#312](https://github.com/paper-trail-gem/paper_trail/issues/312) - Fix RSpec `with_versioning` class level helper method. - `model_instance.without_versioning` now yields the `model_instance`, enabling syntax like this: `model_instance.without_versioning { |obj| obj.update(:name => 'value') }`. - Deprecated `Model.paper_trail_on` and `Model.paper_trail_off` in favor of bang versions of the methods. Deprecation warning informs users that the non-bang versions of the methods will be removed in version `4.0` ## 3.0.0 - [#305](https://github.com/paper-trail-gem/paper_trail/pull/305) - `PaperTrail::VERSION` should be loaded at runtime. - [#295](https://github.com/paper-trail-gem/paper_trail/issues/295) - Explicitly specify table name for version class when querying attributes. Prevents `AmbiguousColumn` errors on certain `JOIN` statements. - [#289](https://github.com/paper-trail-gem/paper_trail/pull/289) - Use `ActiveSupport::Concern` for implementation of base functionality on `PaperTrail::Version` class. Increases flexibility and makes it easier to use custom version classes with multiple `ActiveRecord` connections. - [#288](https://github.com/paper-trail-gem/paper_trail/issues/288) - Change all scope declarations to class methods on the `PaperTrail::Version` class. Fixes usability when `PaperTrail::Version.abstract_class? == true`. - [#287](https://github.com/paper-trail-gem/paper_trail/issues/287) - Support for [PostgreSQL's JSON Type](http://www.postgresql.org/docs/9.2/static/datatype-json.html) for storing `object` and `object_changes`. - [#281](https://github.com/paper-trail-gem/paper_trail/issues/281) - `Rails::Controller` helper will return `false` for the `paper_trail_enabled_for_controller` method if `PaperTrail.enabled? == false`. - [#280](https://github.com/paper-trail-gem/paper_trail/pull/280) - Don't track virtual timestamp attributes. - [#278](https://github.com/paper-trail-gem/paper_trail/issues/278) / [#272](https://github.com/paper-trail-gem/paper_trail/issues/272) - Make RSpec and Cucumber helpers usable with [Spork](https://github.com/sporkrb/spork) and [Zeus](https://github.com/burke/zeus). - [#273](https://github.com/paper-trail-gem/paper_trail/pull/273) - Make the `only` and `ignore` options accept `Hash` arguments; allows for conditional tracking. - [#264](https://github.com/paper-trail-gem/paper_trail/pull/264) - Allow unwrapped symbol to be passed in to the `on` option. - [#224](https://github.com/paper-trail-gem/paper_trail/issues/224)/[#236](https://github.com/paper-trail-gem/paper_trail/pull/236) - Fixed compatibility with [ActsAsTaggableOn](https://github.com/mbleigh/acts-as-taggable-on). - [#235](https://github.com/paper-trail-gem/paper_trail/pull/235) - Dropped unnecessary secondary sort on `versions` association. - [#216](https://github.com/paper-trail-gem/paper_trail/pull/216) - Added helper & extension for [RSpec](https://github.com/rspec/rspec), and helper for [Cucumber](http://cukes.info). - [#212](https://github.com/paper-trail-gem/paper_trail/pull/212) - Added `PaperTrail::Cleaner` module, useful for discarding draft versions. - [#207](https://github.com/paper-trail-gem/paper_trail/issues/207) - Versions for `'create'` events are now created with `create!` instead of `create` so that an exception gets raised if it is appropriate to do so. - [#199](https://github.com/paper-trail-gem/paper_trail/pull/199) - Rails 4 compatibility. - [#165](https://github.com/paper-trail-gem/paper_trail/pull/165) - Namespaced the `Version` class under the `PaperTrail` module. - [#119](https://github.com/paper-trail-gem/paper_trail/issues/119) - Support for [Sinatra](http://www.sinatrarb.com/); decoupled gem from `Rails`. - Renamed the default serializers from `PaperTrail::Serializers::Yaml` and `PaperTrail::Serializers::Json` to the capitalized forms, `PaperTrail::Serializers::YAML` and `PaperTrail::Serializers::JSON`. - Removed deprecated `set_whodunnit` method from Rails Controller scope. ## 2.7.2 - [#228](https://github.com/paper-trail-gem/paper_trail/issues/228) - Refactored default `user_for_paper_trail` method implementation so that `current_user` only gets invoked if it is defined. - [#219](https://github.com/paper-trail-gem/paper_trail/pull/219) - Fixed issue where attributes stored with `nil` value might not get reified properly depending on the way the serializer worked. - [#213](https://github.com/paper-trail-gem/paper_trail/issues/213) - Added a `version_limit` option to the `PaperTrail::Config` options that can be used to restrict the number of versions PaperTrail will store per object instance. - [#187](https://github.com/paper-trail-gem/paper_trail/pull/187) - Confirmed JRuby support. - [#174](https://github.com/paper-trail-gem/paper_trail/pull/174) - The `event` field on the versions table can now be customized. ## 2.7.1 - [#206](https://github.com/paper-trail-gem/paper_trail/issues/206) - Fixed Ruby 1.8.7 compatibility for tracking `object_changes`. - [#200](https://github.com/paper-trail-gem/paper_trail/issues/200) - Fixed `next_version` method so that it returns the live model when called on latest reified version of a model prior to the live model. - [#197](https://github.com/paper-trail-gem/paper_trail/issues/197) - PaperTrail now falls back on using YAML for serialization of serialized model attributes for storage in the `object` and `object_changes` columns in the `Version` table. This fixes compatibility for `Rails 3.0.x` for projects that employ the `serialize` declaration on a model. - [#194](https://github.com/paper-trail-gem/paper_trail/issues/194) - A JSON serializer is now included in the gem. - [#192](https://github.com/paper-trail-gem/paper_trail/pull/192) - `object_changes` should store serialized representation of serialized attributes for `create` actions (in addition to `update` actions, which had already been patched by [#180](https://github.com/paper-trail-gem/paper_trail/pull/180)). - [#190](https://github.com/paper-trail-gem/paper_trail/pull/190) - Fixed compatibility with [SerializedAttributes](https://github.com/technoweenie/serialized_attributes) gem. - [#189](https://github.com/paper-trail-gem/paper_trail/pull/189) - Provided support for a `configure` block initializer. - Added `setter` method for the `serializer` config option. ## 2.7.0 - [#183](https://github.com/paper-trail-gem/paper_trail/pull/183) - Fully qualify the `Version` class to help prevent namespace resolution errors within other gems / plugins. - [#180](https://github.com/paper-trail-gem/paper_trail/pull/180) - Store serialized representation of serialized attributes on the `object` and `object_changes` columns in the `Version` table. - [#164](https://github.com/paper-trail-gem/paper_trail/pull/164) - Allow usage of custom serializer for storage of object attributes. ## 2.6.4 - [#181](https://github.com/paper-trail-gem/paper_trail/issues/181)/[#182](https://github.com/paper-trail-gem/paper_trail/pull/182) - Controller metadata methods should only be evaluated when `paper_trail_enabled_for_controller == true`. - [#177](https://github.com/paper-trail-gem/paper_trail/issues/177)/[#178](https://github.com/paper-trail-gem/paper_trail/pull/178) - Factored out `version_key` into it's own method to prevent `ConnectionNotEstablished` error from getting thrown in instances where `has_paper_trail` is declared on class prior to ActiveRecord establishing a connection. - [#176](https://github.com/paper-trail-gem/paper_trail/pull/176) - Force metadata calls for attributes to use current value if attribute value is changing. - [#173](https://github.com/paper-trail-gem/paper_trail/pull/173) - Update link to [diff-lcs](https://github.com/halostatue/diff-lcs). - [#172](https://github.com/paper-trail-gem/paper_trail/pull/172) - Save `object_changes` on creation. - [#168](https://github.com/paper-trail-gem/paper_trail/pull/168) - Respect conditional `:if` or `:unless` arguments to the `has_paper_trail` method for `destroy` events. - [#167](https://github.com/paper-trail-gem/paper_trail/pull/167) - Fix `originator` method so that it works with subclasses and STI. - [#160](https://github.com/paper-trail-gem/paper_trail/pull/160) - Fixed failing tests and resolved out of date dependency issues. - [#157](https://github.com/paper-trail-gem/paper_trail/pull/157) - Refactored `class_attribute` names on the `ClassMethods` module for names that are not obviously pertaining to PaperTrail to prevent method name collision. paper_trail-12.0.0/Gemfile000066400000000000000000000001051403037104100153540ustar00rootroot00000000000000# frozen_string_literal: true source "https://rubygems.org" gemspec paper_trail-12.0.0/LICENSE000066400000000000000000000020701403037104100150710ustar00rootroot00000000000000Copyright (c) 2009 Andy Stewart, AirBlade Software Ltd. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. paper_trail-12.0.0/README.md000066400000000000000000001562031403037104100153530ustar00rootroot00000000000000# PaperTrail [![Build Status][4]][5] [![Gem Version][53]][54] [![SemVer][55]][56] Track changes to your models, for auditing or versioning. See how a model looked at any stage in its lifecycle, revert it to any version, or restore it after it has been destroyed. ## Documentation | Version | Documentation | | -------------- | ------------- | | Unreleased | https://github.com/paper-trail-gem/paper_trail/blob/master/README.md | | 12.0.0 | https://github.com/paper-trail-gem/paper_trail/blob/v12.0.0/README.md | | 11.1.0 | https://github.com/paper-trail-gem/paper_trail/blob/v11.1.0/README.md | | 10.3.1 | https://github.com/paper-trail-gem/paper_trail/blob/v10.3.1/README.md | | 9.2.0 | https://github.com/paper-trail-gem/paper_trail/blob/v9.2.0/README.md | | 8.1.2 | https://github.com/paper-trail-gem/paper_trail/blob/v8.1.2/README.md | | 7.1.3 | https://github.com/paper-trail-gem/paper_trail/blob/v7.1.3/README.md | | 6.0.2 | https://github.com/paper-trail-gem/paper_trail/blob/v6.0.2/README.md | | 5.2.3 | https://github.com/paper-trail-gem/paper_trail/blob/v5.2.3/README.md | | 4.2.0 | https://github.com/paper-trail-gem/paper_trail/blob/v4.2.0/README.md | | 3.0.9 | https://github.com/paper-trail-gem/paper_trail/blob/v3.0.9/README.md | | 2.7.2 | https://github.com/paper-trail-gem/paper_trail/blob/v2.7.2/README.md | | 1.6.5 | https://github.com/paper-trail-gem/paper_trail/blob/v1.6.5/README.md | ## Table of Contents - [1. Introduction](#1-introduction) - [1.a. Compatibility](#1a-compatibility) - [1.b. Installation](#1b-installation) - [1.c. Basic Usage](#1c-basic-usage) - [1.d. API Summary](#1d-api-summary) - [1.e. Configuration](#1e-configuration) - [2. Limiting What is Versioned, and When](#2-limiting-what-is-versioned-and-when) - [2.a. Choosing Lifecycle Events To Monitor](#2a-choosing-lifecycle-events-to-monitor) - [2.b. Choosing When To Save New Versions](#2b-choosing-when-to-save-new-versions) - [2.c. Choosing Attributes To Monitor](#2c-choosing-attributes-to-monitor) - [2.d. Turning PaperTrail Off](#2d-turning-papertrail-off) - [2.e. Limiting the Number of Versions Created](#2e-limiting-the-number-of-versions-created) - [3. Working With Versions](#3-working-with-versions) - [3.a. Reverting And Undeleting A Model](#3a-reverting-and-undeleting-a-model) - [3.b. Navigating Versions](#3b-navigating-versions) - [3.c. Diffing Versions](#3c-diffing-versions) - [3.d. Deleting Old Versions](#3d-deleting-old-versions) - [4. Saving More Information About Versions](#4-saving-more-information-about-versions) - [4.a. Finding Out Who Was Responsible For A Change](#4a-finding-out-who-was-responsible-for-a-change) - [4.b. Associations](#4b-associations) - [4.c. Storing Metadata](#4c-storing-metadata) - [5. ActiveRecord](#5-activerecord) - [5.a. Single Table Inheritance (STI)](#5a-single-table-inheritance-sti) - [5.b. Configuring the `versions` Association](#5b-configuring-the-versions-association) - [5.c. Generators](#5c-generators) - [5.d. Protected Attributes](#5d-protected-attributes) - [6. Extensibility](#6-extensibility) - [6.a. Custom Version Classes](#6a-custom-version-classes) - [6.b. Custom Serializer](#6b-custom-serializer) - [6.c. Custom Object Changes](#6c-custom-object-changes) - [7. Testing](#7-testing) - [7.a. Minitest](#7a-minitest) - [7.b. RSpec](#7b-rspec) - [7.c. Cucumber](#7c-cucumber) - [7.d. Spork](#7d-spork) - [7.e. Zeus or Spring](#7e-zeus-or-spring) - [8. PaperTrail Plugins](#8-papertrail-plugins) - [9. Integration with Other Libraries](#9-integration-with-other-libraries) - [10. Related Libraries and Ports](#10-related-libraries-and-ports) - [Articles](#articles) - [Problems](#problems) - [Contributors](#contributors) - [Contributing](#contributing) - [Inspirations](#inspirations) - [Intellectual Property](#intellectual-property) ## 1. Introduction ### 1.a. Compatibility | paper_trail | branch | ruby | activerecord | | -------------- | ---------- | -------- | ------------- | | unreleased | master | >= 2.5.0 | >= 5.2, < 6.2 | | 12 | master | >= 2.5.0 | >= 5.2, < 6.2 | | 11 | master | >= 2.4.0 | >= 5.2, < 6.1 | | 10 | 10-stable | >= 2.3.0 | >= 4.2, < 6.1 | | 9 | 9-stable | >= 2.3.0 | >= 4.2, < 5.3 | | 8 | 8-stable | >= 2.2.0 | >= 4.2, < 5.2 | | 7 | 7-stable | >= 2.1.0 | >= 4.0, < 5.2 | | 6 | 6-stable | >= 1.9.3 | >= 4.0, < 5.2 | | 5 | 5-stable | >= 1.9.3 | >= 3.0, < 5.1 | | 4 | 4-stable | >= 1.8.7 | >= 3.0, < 5.1 | | 3 | 3.0-stable | >= 1.8.7 | >= 3.0, < 5 | | 2 | 2.7-stable | >= 1.8.7 | >= 3.0, < 4 | | 1 | rails2 | >= 1.8.7 | >= 2.3, < 3 | Experts: to install incompatible versions of activerecord, see `paper_trail/compatibility.rb`. ### 1.b. Installation 1. Add PaperTrail to your `Gemfile`. `gem 'paper_trail'` 1. Add a `versions` table to your database: ``` bundle exec rails generate paper_trail:install [--with-changes] ``` For more information on this generator, see [section 5.c. Generators](#5c-generators). If using [rails_admin][38], you must enable the experimental [Associations](#4b-associations) feature. If you're getting "Could not find generator 'paper_trail:install'" errors from recent Ruby/Rails versions, try running `spring stop` (see [this thread](https://github.com/paper-trail-gem/paper_trail/issues/459) for more details). ``` bundle exec rake db:migrate ``` 1. Add `has_paper_trail` to the models you want to track. ```ruby class Widget < ActiveRecord::Base has_paper_trail end ``` 1. If your controllers have a `current_user` method, you can easily [track who is responsible for changes](#4a-finding-out-who-was-responsible-for-a-change) by adding a controller callback. ```ruby class ApplicationController before_action :set_paper_trail_whodunnit end ``` ### 1.c. Basic Usage Your models now have a `versions` method which returns the "paper trail" of changes to your model. ```ruby widget = Widget.find 42 widget.versions # [, , ...] ``` Once you have a version, you can find out what happened: ```ruby v = widget.versions.last v.event # 'update', 'create', 'destroy'. See also: Custom Event Names v.created_at v.whodunnit # ID of `current_user`. Requires `set_paper_trail_whodunnit` callback. widget = v.reify # The widget as it was before the update (nil for a create event) ``` PaperTrail stores the pre-change version of the model, unlike some other auditing/versioning plugins, so you can retrieve the original version. This is useful when you start keeping a paper trail for models that already have records in the database. ```ruby widget = Widget.find 153 widget.name # 'Doobly' # Add has_paper_trail to Widget model. widget.versions # [] widget.update name: 'Wotsit' widget.versions.last.reify.name # 'Doobly' widget.versions.last.event # 'update' ``` This also means that PaperTrail does not waste space storing a version of the object as it currently stands. The `versions` method gives you previous versions; to get the current one just call a finder on your `Widget` model as usual. Here's a helpful table showing what PaperTrail stores: | *Event* | *create* | *update* | *destroy* | | -------------- | -------- | -------- | --------- | | *Model Before* | nil | widget | widget | | *Model After* | widget | widget | nil | PaperTrail stores the values in the Model Before row. Most other auditing/versioning plugins store the After row. ### 1.d. API Summary An introductory sample of common features. When you declare `has_paper_trail` in your model, you get these methods: ```ruby class Widget < ActiveRecord::Base has_paper_trail end # Returns this widget's versions. You can customise the name of the # association, but overriding this method is not supported. widget.versions # Return the version this widget was reified from, or nil if it is live. # You can customise the name of the method. widget.version # Returns true if this widget is the current, live one; or false if it is from # a previous version. widget.paper_trail.live? # Returns who put the widget into its current state. widget.paper_trail.originator # Returns the widget (not a version) as it looked at the given timestamp. widget.paper_trail.version_at(timestamp) # Returns the widget (not a version) as it was most recently. widget.paper_trail.previous_version # Returns the widget (not a version) as it became next. widget.paper_trail.next_version ``` And a `PaperTrail::Version` instance (which is just an ordinary ActiveRecord instance, with all the usual methods) has methods such as: ```ruby # Returns the item restored from this version. version.reify(options = {}) # Return a new item from this version version.reify(dup: true) # Returns who put the item into the state stored in this version. version.paper_trail_originator # Returns who changed the item from the state it had in this version. version.terminator version.whodunnit version.version_author # Returns the next version. version.next # Returns the previous version. version.previous # Returns the index of this version in all the versions. version.index # Returns the event that caused this version (create|update|destroy). version.event ``` This is just a sample of common features. Keep reading for more. ### 1.e. Configuration Many aspects of PaperTrail are configurable for individual models; typically this is achieved by passing options to the `has_paper_trail` method within a given model. Some aspects of PaperTrail are configured globally for all models. These settings are assigned directly on the `PaperTrail.config` object. A common place to put these settings is in a Rails initializer file such as `config/initializers/paper_trail.rb` or in an environment-specific configuration file such as `config/environments/test.rb`. #### 1.e.1 Global Global configuration options affect all threads. - association_reify_error_behaviour - enabled - has_paper_trail_defaults - object_changes_adapter - serializer - version_limit Syntax example: (options described in detail later) ```ruby # config/initializers/paper_trail.rb PaperTrail.config.enabled = true PaperTrail.config.has_paper_trail_defaults = { on: %i[create update destroy] } PaperTrail.config.version_limit = 3 ```` These options are intended to be set only once, during app initialization (eg. in `config/initializers`). It is unsafe to change them while the app is running. In contrast, `PaperTrail.request` has various options that only apply to a single HTTP request and thus are safe to use while the app is running. ## 2. Limiting What is Versioned, and When ### 2.a. Choosing Lifecycle Events To Monitor You can choose which events to track with the `on` option. For example, if you only want to track `update` events: ```ruby class Article < ActiveRecord::Base has_paper_trail on: [:update] end ``` `has_paper_trail` installs [callbacks][52] for the specified lifecycle events. There are four potential callbacks, and the default is to install all four, ie. `on: [:create, :destroy, :touch, :update]`. #### The `versions.event` Column Your `versions` table has an `event` column with three possible values: | *event* | *callback* | | ------- | ------------- | | create | create | | destroy | destroy | | update | touch, update | You may also have the `PaperTrail::Version` model save a custom string in its `event` field instead of the typical `create`, `update`, `destroy`. PaperTrail adds an `attr_accessor` to your model named `paper_trail_event`, and will insert it, if present, in the `event` column. ```ruby a = Article.create a.versions.size # 1 a.versions.last.event # 'create' a.paper_trail_event = 'update title' a.update title: 'My Title' a.versions.size # 2 a.versions.last.event # 'update title' a.paper_trail_event = nil a.update title: 'Alternate' a.versions.size # 3 a.versions.last.event # 'update' ``` #### Controlling the Order of AR Callbacks If there are other callbacks in your model, their order relative to those installed by `has_paper_trail` may matter. If you need to control their order, use the `paper_trail_on_*` methods. ```ruby class Article < ActiveRecord::Base # Include PaperTrail, but do not install any callbacks. Passing the # empty array to `:on` omits callbacks. has_paper_trail on: [] # Add callbacks in the order you need. paper_trail.on_destroy # add destroy callback paper_trail.on_update # etc. paper_trail.on_create paper_trail.on_touch end ``` The `paper_trail.on_destroy` method can be further configured to happen `:before` or `:after` the destroy event. In PaperTrail 4, the default is `:after`. In PaperTrail 5, the default will be `:before`, to support ActiveRecord 5. (see https://github.com/paper-trail-gem/paper_trail/pull/683) ### 2.b. Choosing When To Save New Versions You can choose the conditions when to add new versions with the `if` and `unless` options. For example, to save versions only for US non-draft translations: ```ruby class Translation < ActiveRecord::Base has_paper_trail if: Proc.new { |t| t.language_code == 'US' }, unless: Proc.new { |t| t.type == 'DRAFT' } end ``` #### Choosing Based on Changed Attributes Starting with PaperTrail 4.0, versions are saved during an after-callback. If you decide whether to save a new version based on changed attributes, use attribute_name_was instead of attribute_name. #### Saving a New Version Manually You may want to save a new version regardless of options like `:on`, `:if`, or `:unless`. Or, in rare situations, you may want to save a new version even if the record has not changed. ```ruby my_model.paper_trail.save_with_version ``` ### 2.c. Choosing Attributes To Monitor #### Ignore You can `ignore` changes to certain attributes: ```ruby class Article < ActiveRecord::Base has_paper_trail ignore: [:title, :rating] end ``` Changes to just the `title` or `rating` will not create a version record. Changes to other attributes will create a version record. ```ruby a = Article.create a.versions.length # 1 a.update title: 'My Title', rating: 3 a.versions.length # 1 a.update title: 'Greeting', content: 'Hello' a.versions.length # 2 a.paper_trail.previous_version.title # 'My Title' ``` The `:ignore` option can also accept `Hash` arguments that we are considering deprecating. ```ruby class Article < ActiveRecord::Base has_paper_trail ignore: [:title, { color: proc { |obj| obj.color == "Yellow" } }] end ``` #### Only Or, you can specify a list of the `only` attributes you care about: ```ruby class Article < ActiveRecord::Base has_paper_trail only: [:title] end ``` Only changes to the `title` will create a version record. ```ruby a = Article.create a.versions.length # 1 a.update title: 'My Title' a.versions.length # 2 a.update content: 'Hello' a.versions.length # 2 a.paper_trail.previous_version.content # nil ``` The `:only` option can also accept `Hash` arguments that we are considering deprecating. ```ruby class Article < ActiveRecord::Base has_paper_trail only: [{ title: Proc.new { |obj| !obj.title.blank? } }] end ``` If the `title` is not blank, then only changes to the `title` will create a version record. ```ruby a = Article.create a.versions.length # 1 a.update content: 'Hello' a.versions.length # 2 a.update title: 'Title One' a.versions.length # 3 a.update content: 'Hai' a.versions.length # 3 a.paper_trail.previous_version.content # "Hello" a.update title: 'Title Two' a.versions.length # 4 a.paper_trail.previous_version.content # "Hai" ``` Configuring both `:ignore` and `:only` is not recommended, but it should work as expected. Passing both `:ignore` and `:only` options will result in the article being saved if a changed attribute is included in `:only` but not in `:ignore`. #### Skip You can skip attributes completely with the `:skip` option. As with `:ignore`, updates to these attributes will not create a version record. In addition, if a version record is created for some other reason, these attributes will not be persisted. ```ruby class Article < ActiveRecord::Base has_paper_trail skip: [:file_upload] end ``` ### 2.d. Turning PaperTrail Off PaperTrail is on by default, but sometimes you don't want to record versions. #### Per Process Turn PaperTrail off for **all threads** in a `ruby` process. ```ruby PaperTrail.enabled = false ``` **Do not use this in production** unless you have a good understanding of threads vs. processes. A legitimate use case is to speed up tests. See [Testing](#7-testing) below. #### Per HTTP Request ```ruby PaperTrail.request(enabled: false) do # no versions created end ``` or, ```ruby PaperTrail.request.enabled = false # no versions created PaperTrail.request.enabled = true ``` #### Per Class In the rare case that you need to disable versioning for one model while keeping versioning enabled for other models, use: ```ruby PaperTrail.request.disable_model(Banana) # changes to Banana model do not create versions, # but eg. changes to Kiwi model do. PaperTrail.request.enable_model(Banana) PaperTrail.request.enabled_for_model?(Banana) # => true ``` This setting, as with all `PaperTrail.request` settings, affects only the current request, not all threads. For this rare use case, there is no convenient way to pass a block. ##### In a Rails Controller Callback (Not Recommended) PaperTrail installs a callback in your rails controllers. The installed callback will call `paper_trail_enabled_for_controller`, which you can override. ```ruby class ApplicationController < ActionController::Base def paper_trail_enabled_for_controller # Don't omit `super` without a good reason. super && request.user_agent != 'Disable User-Agent' end end ``` Because you are unable to control the order of callback execution, this technique is not recommended, but is preserved for backwards compatibility. It would be better to install your own callback and use `PaperTrail.request.enabled=` as you see fit. #### Per Method (Removed) The `widget.paper_trail.without_versioning` method was removed in v10, without an exact replacement. To disable versioning, use the [Per Class](#per-class) or [Per HTTP Request](#per-http-request) methods. ### 2.e. Limiting the Number of Versions Created Configure `version_limit` to cap the number of versions saved per record. This does not apply to `create` events. ```ruby # Limit: 4 versions per record (3 most recent, plus a `create` event) PaperTrail.config.version_limit = 3 # Remove the limit PaperTrail.config.version_limit = nil ``` #### 2.e.1 Per-model limit Models can override the global `PaperTrail.config.version_limit` setting. Example: ``` # initializer PaperTrail.config.version_limit = 10 # At most 10 versions has_paper_trail # At most 3 versions (2 updates, 1 create). Overrides global version_limit. has_paper_trail limit: 2 # Infinite versions has_paper_trail limit: nil ``` To use a per-model limit, your `versions` table must have an `item_subtype` column. See [Section 4.b.1](https://github.com/paper-trail-gem/paper_trail#4b1-the-optional-item_subtype-column). ## 3. Working With Versions ### 3.a. Reverting And Undeleting A Model PaperTrail makes reverting to a previous version easy: ```ruby widget = Widget.find 42 widget.update name: 'Blah blah' # Time passes.... widget = widget.paper_trail.previous_version # the widget as it was before the update widget.save # reverted ``` Alternatively you can find the version at a given time: ```ruby widget = widget.paper_trail.version_at(1.day.ago) # the widget as it was one day ago widget.save # reverted ``` Note `version_at` gives you the object, not a version, so you don't need to call `reify`. Undeleting is just as simple: ```ruby widget = Widget.find(42) widget.destroy # Time passes.... widget = Widget.new(id:42) # creating a new object with the same id, re-establishes the link versions = widget.versions # versions ordered by versions.created_at, ascending widget = versions.last.reify # the widget as it was before destruction widget.save # the widget lives! ``` You could even use PaperTrail to implement an undo system; [Ryan Bates has!][3] If your model uses [optimistic locking][1] don't forget to [increment your `lock_version`][2] before saving or you'll get a `StaleObjectError`. ### 3.b. Navigating Versions You can call `previous_version` and `next_version` on an item to get it as it was/became. Note that these methods reify the item for you. ```ruby live_widget = Widget.find 42 live_widget.versions.length # 4, for example widget = live_widget.paper_trail.previous_version # => widget == live_widget.versions.last.reify widget = widget.paper_trail.previous_version # => widget == live_widget.versions[-2].reify widget = widget.paper_trail.next_version # => widget == live_widget.versions.last.reify widget.paper_trail.next_version # live_widget ``` If instead you have a particular `version` of an item you can navigate to the previous and next versions. ```ruby widget = Widget.find 42 version = widget.versions[-2] # assuming widget has several versions previous_version = version.previous next_version = version.next ``` You can find out which of an item's versions yours is: ```ruby current_version_number = version.index # 0-based ``` If you got an item by reifying one of its versions, you can navigate back to the version it came from: ```ruby latest_version = Widget.find(42).versions.last widget = latest_version.reify widget.version == latest_version # true ``` You can find out whether a model instance is the current, live one -- or whether it came instead from a previous version -- with `live?`: ```ruby widget = Widget.find 42 widget.paper_trail.live? # true widget = widget.paper_trail.previous_version widget.paper_trail.live? # false ``` And you can perform `WHERE` queries for object versions based on attributes: ```ruby # Find versions that meet these criteria. PaperTrail::Version.where_object(content: 'Hello', title: 'Article') # Find versions before and after attribute `atr` had value `v`: PaperTrail::Version.where_object_changes(atr: 'v') ``` Using `where_object_changes` to read YAML from a text column was deprecated in 8.1.0, and will now raise an error. ### 3.c. Diffing Versions There are two scenarios: diffing adjacent versions and diffing non-adjacent versions. The best way to diff adjacent versions is to get PaperTrail to do it for you. If you add an `object_changes` text column to your `versions` table, either at installation time with the `rails generate paper_trail:install --with-changes` option or manually, PaperTrail will store the `changes` diff (excluding any attributes PaperTrail is ignoring) in each `update` version. You can use the `version.changeset` method to retrieve it. For example: ```ruby widget = Widget.create name: 'Bob' widget.versions.last.changeset # { # "name"=>[nil, "Bob"], # "created_at"=>[nil, 2015-08-10 04:10:40 UTC], # "updated_at"=>[nil, 2015-08-10 04:10:40 UTC], # "id"=>[nil, 1] # } widget.update name: 'Robert' widget.versions.last.changeset # { # "name"=>["Bob", "Robert"], # "updated_at"=>[2015-08-10 04:13:19 UTC, 2015-08-10 04:13:19 UTC] # } widget.destroy widget.versions.last.changeset # {} ``` Prior to 10.0.0, the `object_changes` were only stored for create and update events. As of 10.0.0, they are stored for all three events. Please be aware that PaperTrail doesn't use diffs internally. When I designed PaperTrail I wanted simplicity and robustness so I decided to make each version of an object self-contained. A version stores all of its object's data, not a diff from the previous version. This means you can delete any version without affecting any other. To diff non-adjacent versions you'll have to write your own code. These libraries may help: For diffing two strings: * [htmldiff][19]: expects but doesn't require HTML input and produces HTML output. Works very well but slows down significantly on large (e.g. 5,000 word) inputs. * [differ][20]: expects plain text input and produces plain text/coloured/HTML/any output. Can do character-wise, word-wise, line-wise, or arbitrary-boundary-string-wise diffs. Works very well on non-HTML input. * [diff-lcs][21]: old-school, line-wise diffs. For diffing two ActiveRecord objects: * [Jeremy Weiskotten's PaperTrail fork][22]: uses ActiveSupport's diff to return an array of hashes of the changes. * [activerecord-diff][23]: rather like ActiveRecord::Dirty but also allows you to specify which columns to compare. ### 3.d. Deleting Old Versions Over time your `versions` table will grow to an unwieldy size. Because each version is self-contained (see the Diffing section above for more) you can simply delete any records you don't want any more. For example: ```sql sql> delete from versions where created_at < '2010-06-01'; ``` ```ruby PaperTrail::Version.where('created_at < ?', 1.day.ago).delete_all ``` ## 4. Saving More Information About Versions ### 4.a. Finding Out Who Was Responsible For A Change Set `PaperTrail.request.whodunnit=`, and that value will be stored in the version's `whodunnit` column. ```ruby PaperTrail.request.whodunnit = 'Andy Stewart' widget.update name: 'Wibble' widget.versions.last.whodunnit # Andy Stewart ``` #### Setting `whodunnit` to a `Proc` `whodunnit=` also accepts a `Proc`, in the rare case that lazy evaluation is required. ```ruby PaperTrail.request.whodunnit = proc do caller.find { |c| c.starts_with? Rails.root.to_s } end ``` Because lazy evaluation can be hard to troubleshoot, this is not recommended for common use. #### Setting `whodunnit` Temporarily To set whodunnit temporarily, for the duration of a block, use `PaperTrail.request`: ```ruby PaperTrail.request(whodunnit: 'Dorian Marié') do widget.update name: 'Wibble' end ``` #### Setting `whodunnit` with a controller callback If your controller has a `current_user` method, PaperTrail provides a callback that will assign `current_user.id` to `whodunnit`. ```ruby class ApplicationController before_action :set_paper_trail_whodunnit end ``` You may want `set_paper_trail_whodunnit` to call a different method to find out who is responsible. To do so, override the `user_for_paper_trail` method in your controller like this: ```ruby class ApplicationController def user_for_paper_trail logged_in? ? current_member.id : 'Public user' # or whatever end end ``` See also: [Setting whodunnit in the rails console][33] #### Terminator and Originator A version's `whodunnit` column tells us who changed the object, causing the `version` to be stored. Because a version stores the object as it looked before the change (see the table above), `whodunnit` tells us who *stopped* the object looking like this -- not who made it look like this. Hence `whodunnit` is aliased as `terminator`. To find out who made a version's object look that way, use `version.paper_trail_originator`. And to find out who made a "live" object look like it does, call `paper_trail_originator` on the object. ```ruby widget = Widget.find 153 # assume widget has 0 versions PaperTrail.request.whodunnit = 'Alice' widget.update name: 'Yankee' widget.paper_trail.originator # 'Alice' PaperTrail.request.whodunnit = 'Bob' widget.update name: 'Zulu' widget.paper_trail.originator # 'Bob' first_version, last_version = widget.versions.first, widget.versions.last first_version.whodunnit # 'Alice' first_version.paper_trail_originator # nil first_version.terminator # 'Alice' last_version.whodunnit # 'Bob' last_version.paper_trail_originator # 'Alice' last_version.terminator # 'Bob' ``` #### Storing an ActiveRecord globalid in whodunnit If you would like `whodunnit` to return an `ActiveRecord` object instead of a string, please try the [paper_trail-globalid][37] gem. ### 4.b. Associations To track and reify associations, use [paper_trail-association_tracking][6] (PT-AT). From 2014 to 2018, association tracking was an experimental feature, but many issues were discovered. To attract new volunteers to address these issues, PT-AT was extracted (see https://github.com/paper-trail-gem/paper_trail/issues/1070). Even though it had always been an experimental feature, we didn't want the extraction of PT-AT to be a breaking change, so great care was taken to remove it slowly. - In PT 9, PT-AT was kept as a runtime dependency. - In PT 10, it became a development dependency (If you use it you must add it to your own `Gemfile`) and we kept running all of its tests. - In PT 11, it will no longer be a development dependency, and it is responsible for its own tests. #### 4.b.1 The optional `item_subtype` column As of PT 10, users may add an `item_subtype` column to their `versions` table. When storing versions for STI models, rails stores the base class in `item_type` (that's just how polymorphic associations like `item` work) In addition, PT will now store the subclass in `item_subtype`. If this column is present PT-AT will use it to fix a rare issue with reification of STI subclasses. ```ruby add_column :versions, :item_subtype, :string, null: true ``` So, if you use PT-AT and STI, the addition of this column is recommended. - https://github.com/paper-trail-gem/paper_trail/issues/594 - https://github.com/paper-trail-gem/paper_trail/pull/1143 - https://github.com/westonganger/paper_trail-association_tracking/pull/5 ### 4.c. Storing Metadata You can add your own custom columns to your `versions` table. Values can be given using **Model Metadata** or **Controller Metadata**. #### Model Metadata You can specify metadata in the model using `has_paper_trail(meta:)`. ```ruby class Article < ActiveRecord::Base belongs_to :author has_paper_trail( meta: { author_id: :author_id, # model attribute word_count: :count_words, # arbitrary model method answer: 42, # scalar value editor: proc { |article| article.editor.full_name } # a Proc } ) def count_words 153 end end ``` #### Metadata from Controllers You can also store any information you like from your controller. Override the `info_for_paper_trail` method in your controller to return a hash whose keys correspond to columns in your `versions` table. ```ruby class ApplicationController def info_for_paper_trail { ip: request.remote_ip, user_agent: request.user_agent } end end ``` #### Advantages of Metadata Why would you do this? In this example, `author_id` is an attribute of `Article` and PaperTrail will store it anyway in a serialized form in the `object` column of the `version` record. But let's say you wanted to pull out all versions for a particular author; without the metadata you would have to deserialize (reify) each `version` object to see if belonged to the author in question. Clearly this is inefficient. Using the metadata you can find just those versions you want: ```ruby PaperTrail::Version.where(author_id: author_id) ``` #### Metadata can Override PaperTrail Columns **Experts only**. Metadata will override the normal values that PT would have inserted into its own columns. | *PT Column* | *How bad of an idea?* | *Alternative* | | -------------- | --------------------- | ----------------------------- | | item_type | terrible idea | | | item_id | terrible idea | | | event | meh | paper_trail_event | | whodunnit | meh | PaperTrail.request.whodunnit= | | object | a little dangerous | | | object_changes | a little dangerous | | ## 5. ActiveRecord ### 5.a. Single Table Inheritance (STI) PaperTrail supports [Single Table Inheritance][39], and even supports an un-versioned base model, as of `23ffbdc7e1`. ```ruby class Fruit < ActiveRecord::Base # un-versioned base model end class Banana < Fruit has_paper_trail end ``` However, there is a known issue when reifying [associations](#associations), see https://github.com/paper-trail-gem/paper_trail/issues/594 ### 5.b. Configuring the `versions` Association #### 5.b.1. `versions` association You may configure the name of the `versions` association by passing a different name (default is `:versions`) in the `versions:` options hash: ```ruby class Post < ActiveRecord::Base has_paper_trail versions: { name: :drafts } end Post.new.versions # => NoMethodError ``` You may pass a [scope](https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many-label-Scopes) to the `versions` association with the `scope:` option: ```ruby class Post < ActiveRecord::Base has_paper_trail versions: { scope: -> { order("id desc") } } # Equivalent to: has_many :versions, -> { order("id desc") }, class_name: 'PaperTrail::Version', as: :item end ``` Any other [options supported by `has_many`](https://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#method-i-has_many-label-Options) can be passed along to the `has_many` macro via the `versions:` options hash. ```ruby class Post < ActiveRecord::Base has_paper_trail versions: { extend: VersionsExtensions, autosave: false } end ``` Overriding (instead of configuring) the `versions` method is not supported. Overriding associations is not recommended in general. #### 5.b.2. `item` association A `PaperTrail::Version` object `belongs_to` an `item`, the relevant record. The `item` association is first defined in `PaperTrail::VersionConcern`, but associations can be redefined. ##### Example: adding a `counter_cache` to `item` association ```ruby # app/models/paper_trail/version.rb module PaperTrail class Version < ActiveRecord::Base belongs_to :item, polymorphic: true, counter_cache: true end end ``` When redefining an association, its options are _replaced_ not _merged_, so don't forget to specify the options from `PaperTrail::VersionConcern`, like `polymorphic`. Be advised that redefining an association is an undocumented feature of Rails. ### 5.c. Generators PaperTrail has one generator, `paper_trail:install`. It writes, but does not run, a migration file. The migration adds (at least) the `versions` table. The most up-to-date documentation for this generator can be found by running `rails generate paper_trail:install --help`, but a copy is included here for convenience. ``` Usage: rails generate paper_trail:install [options] Options: [--with-changes], [--no-with-changes] # Store changeset (diff) with each version Runtime options: -f, [--force] # Overwrite files that already exist -p, [--pretend], [--no-pretend] # Run but do not make any changes -q, [--quiet], [--no-quiet] # Suppress status output -s, [--skip], [--no-skip] # Skip files that already exist Generates (but does not run) a migration to add a versions table. ``` ### 5.d. Protected Attributes As of version 6, PT no longer supports rails 3 or the [protected_attributes][17] gem. If you are still using them, you may use PT 5 or lower. We recommend upgrading to [strong_parameters][18] as soon as possible. If you must use [protected_attributes][17] for now, and want to use PT > 5, you can reopen `PaperTrail::Version` and add the following `attr_accessible` fields: ```ruby # app/models/paper_trail/version.rb module PaperTrail class Version < ActiveRecord::Base include PaperTrail::VersionConcern attr_accessible :item_type, :item_id, :event, :whodunnit, :object, :object_changes, :created_at end end ``` This *unsupported workaround* has been tested with protected_attributes 1.0.9 / rails 4.2.8 / paper_trail 7.0.3. ## 6. Extensibility ### 6.a. Custom Version Classes You can specify custom version subclasses with the `:class_name` option: ```ruby class PostVersion < PaperTrail::Version # custom behaviour, e.g: self.table_name = :post_versions end class Post < ActiveRecord::Base has_paper_trail versions: { class_name: 'PostVersion' } end ``` Unlike ActiveRecord's `class_name`, you'll have to supply the complete module path to the class (e.g. `Foo::BarVersion` if your class is inside the module `Foo`). #### Advantages 1. For models which have a lot of versions, storing each model's versions in a separate table can improve the performance of certain database queries. 1. Store different version [metadata](#4c-storing-metadata) for different models. #### Configuration If you are using Postgres, you should also define the sequence that your custom version class will use: ```ruby class PostVersion < PaperTrail::Version self.table_name = :post_versions self.sequence_name = :post_versions_id_seq end ``` If you only use custom version classes and don't have a `versions` table, you must let ActiveRecord know that the `PaperTrail::Version` class is an `abstract_class`. ```ruby # app/models/paper_trail/version.rb module PaperTrail class Version < ActiveRecord::Base include PaperTrail::VersionConcern self.abstract_class = true end end ``` You can also specify custom names for the versions and version associations. This is useful if you already have `versions` or/and `version` methods on your model. For example: ```ruby class Post < ActiveRecord::Base has_paper_trail versions: { name: :paper_trail_versions }, version: :paper_trail_version # Existing versions method. We don't want to clash. def versions # ... end # Existing version method. We don't want to clash. def version # ... end end ``` ### 6.b. Custom Serializer By default, PaperTrail stores your changes as a `YAML` dump. You can override this with the serializer config option: ```ruby PaperTrail.serializer = MyCustomSerializer ``` A valid serializer is a `module` (or `class`) that defines a `load` and `dump` method. These serializers are included in the gem for your convenience: * [PaperTrail::Serializers::YAML][24] - Default * [PaperTrail::Serializers::JSON][25] #### PostgreSQL JSON column type support If you use PostgreSQL, and would like to store your `object` (and/or `object_changes`) data in a column of [type `json` or type `jsonb`][26], specify `json` instead of `text` for these columns in your migration: ```ruby create_table :versions do |t| # ... t.json :object # Full object changes t.json :object_changes # Optional column-level changes # ... end ``` If you use the PostgreSQL `json` or `jsonb` column type, you do not need to specify a `PaperTrail.serializer`. ##### Convert existing YAML data to JSON If you've been using PaperTrail for a while with the default YAML serializer and you want to switch to JSON or JSONB, you're in a bit of a bind because there's no automatic way to migrate your data. The first (slow) option is to loop over every record and parse it in Ruby, then write to a temporary column: ```ruby add_column :versions, :new_object, :jsonb # or :json # add_column :versions, :new_object_changes, :jsonb # or :json # PaperTrail::Version.reset_column_information # needed for rails < 6 PaperTrail::Version.where.not(object: nil).find_each do |version| version.update_column(:new_object, YAML.load(version.object)) # if version.object_changes # version.update_column( # :new_object_changes, # YAML.load(version.object_changes) # ) # end end remove_column :versions, :object # remove_column :versions, :object_changes rename_column :versions, :new_object, :object # rename_column :versions, :new_object_changes, :object_changes ``` This technique can be very slow if you have a lot of data. Though slow, it is safe in databases where transactions are protected against DDL, such as Postgres. In databases without such protection, such as MySQL, a table lock may be necessary. If the above technique is too slow for your needs, and you're okay doing without PaperTrail data temporarily, you can create the new column without converting the data. ```ruby rename_column :versions, :object, :old_object add_column :versions, :object, :jsonb # or :json ``` After that migration, your historical data still exists as YAML, and new data will be stored as JSON. Next, convert records from YAML to JSON using a background script. ```ruby PaperTrail::Version.where.not(old_object: nil).find_each do |version| version.update_columns old_object: nil, object: YAML.load(version.old_object) end ``` Finally, in another migration, remove the old column. ```ruby remove_column :versions, :old_object ``` If you use the optional `object_changes` column, don't forget to convert it also, using the same technique. ##### Convert a Column from Text to JSON If your `object` column already contains JSON data, and you want to change its data type to `json` or `jsonb`, you can use the following [DDL][36]. Of course, if your `object` column contains YAML, you must first convert the data to JSON (see above) before you can change the column type. Using SQL: ```sql alter table versions alter column object type jsonb using object::jsonb; ``` Using ActiveRecord: ```ruby class ConvertVersionsObjectToJson < ActiveRecord::Migration def up change_column :versions, :object, 'jsonb USING object::jsonb' end def down change_column :versions, :object, 'text USING object::text' end end ``` ### 6.c. Custom Object Changes To fully control the contents of their `object_changes` column, expert users can write an adapter. ```ruby PaperTrail.config.object_changes_adapter = MyObjectChangesAdapter.new class MyObjectChangesAdapter # @param changes Hash # @return Hash def diff(changes) # ... end end ``` You should only use this feature if you are comfortable reading PT's source to see exactly how the adapter is used. For example, see how `diff` is used by reading `::PaperTrail::Events::Base#recordable_object_changes`. An adapter can implement any or all of the following methods: 1. diff: Returns the changeset in the desired format given the changeset in the original format 2. load_changeset: Returns the changeset for a given version object 3. where_object_changes: Returns the records resulting from the given hash of attributes. 4. where_object_changes_from: Returns the records resulting from the given hash of attributes where the attributes changed *from* the provided value(s). Depending on what your adapter does, you may have to implement all three. For an example of a complete and useful adapter, see [paper_trail-hashdiff](https://github.com/hashwin/paper_trail-hashdiff) ### 6.d. Excluding the Object Column The `object` column ends up storing a lot of duplicate data if you have models that have many columns, and that are updated many times. You can save ~50% of storage space by removing the column from the versions table. It's important to note that this will disable `reify` and `where_object`. ## 7. Testing You may want to turn PaperTrail off to speed up your tests. See [Turning PaperTrail Off](#2d-turning-papertrail-off) above. ### 7.a. Minitest First, disable PT for the entire `ruby` process. ```ruby # in config/environments/test.rb config.after_initialize do PaperTrail.enabled = false end ``` Then, to enable PT for specific tests, you can add a `with_versioning` test helper method. ```ruby # in test/test_helper.rb def with_versioning was_enabled = PaperTrail.enabled? was_enabled_for_request = PaperTrail.request.enabled? PaperTrail.enabled = true PaperTrail.request.enabled = true begin yield ensure PaperTrail.enabled = was_enabled PaperTrail.request.enabled = was_enabled_for_request end end ``` Then, use the helper in your tests. ```ruby test 'something that needs versioning' do with_versioning do # your test end end ``` ### 7.b. RSpec PaperTrail provides a helper, `paper_trail/frameworks/rspec.rb`, that works with [RSpec][27] to make it easier to control when `PaperTrail` is enabled during testing. ```ruby # spec/rails_helper.rb ENV["RAILS_ENV"] ||= 'test' require 'spec_helper' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' # ... require 'paper_trail/frameworks/rspec' ``` With the helper loaded, PaperTrail will be turned off for all tests by default. To enable PaperTrail for a test you can either wrap the test in a `with_versioning` block, or pass in `versioning: true` option to a spec block. ```ruby describe 'RSpec test group' do it 'by default, PaperTrail will be turned off' do expect(PaperTrail).to_not be_enabled end with_versioning do it 'within a `with_versioning` block it will be turned on' do expect(PaperTrail).to be_enabled end end it 'can be turned on at the `it` or `describe` level', versioning: true do expect(PaperTrail).to be_enabled end end ``` The helper will also reset `whodunnit` to `nil` before each test to help prevent data spillover between tests. If you are using PaperTrail with Rails, the helper will automatically set the `PaperTrail.request.controller_info` value to `{}` as well, again, to help prevent data spillover between tests. There is also a `be_versioned` matcher provided by PaperTrail's RSpec helper which can be leveraged like so: ```ruby class Widget < ActiveRecord::Base end describe Widget do it 'is not versioned by default' do is_expected.to_not be_versioned end describe 'add versioning to the `Widget` class' do before(:all) do class Widget < ActiveRecord::Base has_paper_trail end end it 'enables paper trail' do is_expected.to be_versioned end end end ``` #### Matchers The `have_a_version_with` matcher makes assertions about versions using `where_object`, based on the `object` column. ```ruby describe '`have_a_version_with` matcher' do it 'is possible to do assertions on version attributes' do widget.update!(name: 'Leonard', an_integer: 1) widget.update!(name: 'Tom') widget.update!(name: 'Bob') expect(widget).to have_a_version_with name: 'Leonard', an_integer: 1 expect(widget).to have_a_version_with an_integer: 1 expect(widget).to have_a_version_with name: 'Tom' end end ``` The `have_a_version_with_changes` matcher makes assertions about versions using `where_object_changes`, based on the optional [`object_changes` column](#3c-diffing-versions). ```ruby describe '`have_a_version_with_changes` matcher' do it 'is possible to do assertions on version changes' do widget.update!(name: 'Leonard', an_integer: 1) widget.update!(name: 'Tom') widget.update!(name: 'Bob') expect(widget).to have_a_version_with_changes name: 'Leonard', an_integer: 2 expect(widget).to have_a_version_with_changes an_integer: 2 expect(widget).to have_a_version_with_changes name: 'Bob' end end ``` For more examples of the RSpec matchers, see the [Widget spec](https://github.com/paper-trail-gem/paper_trail/blob/master/spec/models/widget_spec.rb) ### 7.c. Cucumber PaperTrail provides a helper for [Cucumber][28] that works similar to the RSpec helper. If you want to use the helper, you will need to require in your cucumber helper like so: ```ruby # features/support/env.rb ENV["RAILS_ENV"] ||= 'cucumber' require File.expand_path(File.dirname(__FILE__) + '/../../config/environment') # ... require 'paper_trail/frameworks/cucumber' ``` When the helper is loaded, PaperTrail will be turned off for all scenarios by a `before` hook added by the helper by default. When you want to enable PaperTrail for a scenario, you can wrap code in a `with_versioning` block in a step, like so: ```ruby Given /I want versioning on my model/ do with_versioning do # PaperTrail will be turned on for all code inside of this block end end ``` The helper will also reset the `whodunnit` value to `nil` before each test to help prevent data spillover between tests. If you are using PaperTrail with Rails, the helper will automatically set the `PaperTrail.request.controller_info` value to `{}` as well, again, to help prevent data spillover between tests. ### 7.d. Spork If you want to use the `RSpec` or `Cucumber` helpers with [Spork][29], you will need to manually require the helper(s) in your `prefork` block on your test helper, like so: ```ruby # spec/rails_helper.rb require 'spork' Spork.prefork do # This file is copied to spec/ when you run 'rails generate rspec:install' ENV["RAILS_ENV"] ||= 'test' require 'spec_helper' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'paper_trail/frameworks/rspec' require 'paper_trail/frameworks/cucumber' # ... end ``` ### 7.e. Zeus or Spring If you want to use the `RSpec` or `Cucumber` helpers with [Zeus][30] or [Spring][31], you will need to manually require the helper(s) in your test helper, like so: ```ruby # spec/rails_helper.rb ENV["RAILS_ENV"] ||= 'test' require 'spec_helper' require File.expand_path("../../config/environment", __FILE__) require 'rspec/rails' require 'paper_trail/frameworks/rspec' ``` ## 8. PaperTrail Plugins - [paper_trail-association_tracking][6] - track and reify associations - [paper_trail-globalid][49] - enhances whodunnit by adding an `actor` ## 9. Integration with Other Libraries - [ActiveAdmin][42] - [paper_trail_manager][46] - Browse, subscribe, view and revert changes to records with rails and paper_trail - [rails_admin_history_rollback][51] - History rollback for rails_admin with PT - Sinatra - [paper_trail-sinatra][41] - [globalize][45] - [globalize-versioning][44] - [solidus_papertrail][47] - PT integration for Solidus method to instances of PaperTrail::Version that returns the ActiveRecord object who was responsible for change ## 10. Related Libraries and Ports - [izelnakri/paper_trail][50] - An Ecto library, inspired by PT. - [sequelize-paper-trail][48] - A JS library, inspired by PT. A sequelize plugin for tracking revision history of model instances. ## Articles * [PaperTrail Gem Tutorial](https://stevepolito.design/blog/paper-trail-gem-tutorial/), 20th April 2020. * [Jutsu #8 - Version your RoR models with PaperTrail](http://samurails.com/gems/papertrail/), [Thibault](http://samurails.com/about-me/), 29th September 2014 * [Versioning with PaperTrail](http://www.sitepoint.com/versioning-papertrail), [Ilya Bodrov](http://www.sitepoint.com/author/ibodrov), 10th April 2014 * [Using PaperTrail to track stack traces](http://web.archive.org/web/20141120233916/http://rubyrailsexpert.com/?p=36), T James Corcoran's blog, 1st October 2013. * [RailsCast #255 - Undo with PaperTrail](http://railscasts.com/episodes/255-undo-with-paper-trail), 28th February 2011. * [Keep a Paper Trail with PaperTrail](http://www.linux-mag.com/id/7528), Linux Magazine, 16th September 2009. ## Problems Please use GitHub's [issue tracker](https://github.com/paper-trail-gem/paper_trail/issues). ## Contributors Created by Andy Stewart in 2010, maintained since 2012 by Ben Atkins, since 2015 by Jared Beck, with contributions by over 150 people. https://github.com/paper-trail-gem/paper_trail/graphs/contributors ## Contributing See our [contribution guidelines][43] ## Inspirations * [Simply Versioned](https://github.com/jerome/simply_versioned) * [Acts As Audited](https://github.com/collectiveidea/audited) ## Intellectual Property Copyright (c) 2011 Andy Stewart (boss@airbladesoftware.com). Released under the MIT licence. [1]: http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html [2]: https://github.com/paper-trail-gem/paper_trail/issues/163 [3]: http://railscasts.com/episodes/255-undo-with-paper-trail [4]: https://api.travis-ci.org/paper-trail-gem/paper_trail.svg?branch=master [5]: https://travis-ci.org/paper-trail-gem/paper_trail [6]: https://github.com/westonganger/paper_trail-association_tracking [9]: https://github.com/paper-trail-gem/paper_trail/tree/3.0-stable [10]: https://github.com/paper-trail-gem/paper_trail/tree/2.7-stable [11]: https://github.com/paper-trail-gem/paper_trail/tree/rails2 [14]: https://raw.github.com/paper-trail-gem/paper_trail/master/lib/generators/paper_trail/templates/create_versions.rb [16]: https://github.com/paper-trail-gem/paper_trail/issues/113 [17]: https://github.com/rails/protected_attributes [18]: https://github.com/rails/strong_parameters [19]: http://github.com/myobie/htmldiff [20]: http://github.com/pvande/differ [21]: https://github.com/halostatue/diff-lcs [22]: http://github.com/jeremyw/paper_trail/blob/master/lib/paper_trail/has_paper_trail.rb#L151-156 [23]: http://github.com/tim/activerecord-diff [24]: https://github.com/paper-trail-gem/paper_trail/blob/master/lib/paper_trail/serializers/yaml.rb [25]: https://github.com/paper-trail-gem/paper_trail/blob/master/lib/paper_trail/serializers/json.rb [26]: http://www.postgresql.org/docs/9.4/static/datatype-json.html [27]: https://github.com/rspec/rspec [28]: http://cukes.info [29]: https://github.com/sporkrb/spork [30]: https://github.com/burke/zeus [31]: https://github.com/rails/spring [32]: http://api.rubyonrails.org/classes/ActiveRecord/AutosaveAssociation.html#method-i-mark_for_destruction [33]: https://github.com/paper-trail-gem/paper_trail/wiki/Setting-whodunnit-in-the-rails-console [34]: https://github.com/rails/rails/blob/591a0bb87fff7583e01156696fbbf929d48d3e54/activerecord/lib/active_record/fixtures.rb#L142 [35]: https://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html [36]: http://www.postgresql.org/docs/9.4/interactive/ddl.html [37]: https://github.com/ankit1910/paper_trail-globalid [38]: https://github.com/sferik/rails_admin [39]: http://api.rubyonrails.org/classes/ActiveRecord/Base.html#class-ActiveRecord::Base-label-Single+table+inheritance [40]: http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#module-ActiveRecord::Associations::ClassMethods-label-Polymorphic+Associations [41]: https://github.com/jaredbeck/paper_trail-sinatra [42]: https://github.com/activeadmin/activeadmin/wiki/Auditing-via-paper_trail-%28change-history%29 [43]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/CONTRIBUTING.md [44]: https://github.com/globalize/globalize-versioning [45]: https://github.com/globalize/globalize [46]: https://github.com/fusion94/paper_trail_manager [47]: https://github.com/solidusio-contrib/solidus_papertrail [48]: https://github.com/nielsgl/sequelize-paper-trail [49]: https://github.com/ankit1910/paper_trail-globalid [50]: https://github.com/izelnakri/paper_trail [51]: https://github.com/rikkipitt/rails_admin_history_rollback [52]: http://guides.rubyonrails.org/active_record_callbacks.html [53]: https://badge.fury.io/rb/paper_trail.svg [54]: https://rubygems.org/gems/paper_trail [55]: https://api.dependabot.com/badges/compatibility_score?dependency-name=paper_trail&package-manager=bundler&version-scheme=semver [56]: https://dependabot.com/compatibility-score.html?dependency-name=paper_trail&package-manager=bundler&version-scheme=semver paper_trail-12.0.0/Rakefile000066400000000000000000000042651403037104100155410ustar00rootroot00000000000000# frozen_string_literal: true ENV["DB"] ||= "sqlite" require "fileutils" require "bundler" Bundler::GemHelper.install_tasks desc "Copy the database.DB.yml per ENV['DB']" task :install_database_yml do puts format("installing database.yml for %s", ENV["DB"]) # It's tempting to use `git clean` here, but this rake task will be run by # people working on changes that haven't been committed yet, so we have to # be more selective with what we delete. ::FileUtils.rm("spec/dummy_app/db/database.yml", force: true) FileUtils.cp( "spec/dummy_app/config/database.#{ENV['DB']}.yml", "spec/dummy_app/config/database.yml" ) end desc "Delete generated files and databases" task :clean do puts format("dropping %s database", ENV["DB"]) case ENV["DB"] when "mysql" # TODO: only works locally. doesn't respect database.yml system "mysqladmin drop -f paper_trail_test > /dev/null 2>&1" when "postgres" # TODO: only works locally. doesn't respect database.yml system "dropdb --if-exists paper_trail_test > /dev/null 2>&1" when nil, "sqlite" ::FileUtils.rm(::Dir.glob("spec/dummy_app/db/*.sqlite3")) else raise "Don't know how to clean specified RDBMS: #{ENV['DB']}" end end desc <<~EOS Write a database.yml for the specified RDBMS, and create database. Does not migrate. Migration happens later in spec_helper. EOS task prepare: %i[clean install_database_yml] do puts format("creating %s database", ENV["DB"]) case ENV["DB"] when "mysql" # TODO: only works locally. doesn't respect database.yml system "mysqladmin create paper_trail_test" when "postgres" # TODO: only works locally. doesn't respect database.yml system "createdb paper_trail_test" when nil, "sqlite" # noop. test.sqlite3 will be created when migration happens nil else raise "Don't know how to create specified DB: #{ENV['DB']}" end end require "rspec/core/rake_task" desc "Run tests on PaperTrail with RSpec" task(:spec).clear RSpec::Core::RakeTask.new(:spec) do |t| t.verbose = false # hide list of specs bit.ly/1nVq3Jn end require "rubocop/rake_task" RuboCop::RakeTask.new desc "Default: run all available test suites" task default: %i[rubocop prepare spec] paper_trail-12.0.0/doc/000077500000000000000000000000001403037104100146325ustar00rootroot00000000000000paper_trail-12.0.0/doc/bug_report_template.rb000066400000000000000000000001561403037104100212240ustar00rootroot00000000000000# frozen_string_literal: true # Moved to .github/ISSUE_TEMPLATE/bug_report.md # Please update your bookmarks paper_trail-12.0.0/doc/triage.md000066400000000000000000000027771403037104100164440ustar00rootroot00000000000000# Triage ## Response to Usage Question ``` Per our [contributing guide][1], please ask usage questions on [StackOverflow][2]. Due to limited volunteer time, we can only accept bug reports and feature requests here on GitHub. Once you create a question on StackOverflow, please feel free to comment here with the URL. For instructions on how to file a bug report, please see our [issue template][3]. [1]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/CONTRIBUTING.md [2]: https://stackoverflow.com/tags/paper-trail-gem [3]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/ISSUE_TEMPLATE/bug_report.md ``` ## Responses to Common Problems ``` Hi ___. All issues are required to use our [issue template][2]. See also our [contributing guide][1]. Please review the issue template, open a new issue, and I'll do what I can to help! [1]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/CONTRIBUTING.md [2]: https://github.com/paper-trail-gem/paper_trail/blob/master/.github/ISSUE_TEMPLATE/bug_report.md ``` ## Usage question masquerading as a feature proposal ``` > Is there a way to X? If not, I want that feature. Hi _____, Due to limited volunteer time, we can't answer questions like "Is there a way ...". We ask you to do that research yourself, or use [StackOverflow][1]. We can only accept feature proposals that have already done this research. Please do come back with a more concrete proposal. Thanks! [1]: https://stackoverflow.com/tags/paper-trail-gem ``` paper_trail-12.0.0/doc/warning_about_not_setting_whodunnit.md000066400000000000000000000020161403037104100245260ustar00rootroot00000000000000# The warning about not setting whodunnit After upgrading to PaperTrail 5, you see this warning: > user_for_paper_trail is present, but whodunnit has not been set. PaperTrail no > longer adds the set_paper_trail_whodunnit before_action for you. Please add this > before_action to your ApplicationController to continue recording whodunnit. ## You want to track whodunnit Add `before_action :set_paper_trail_whodunnit` to your ApplicationController. See the PaperTrail readme for an example (https://git.io/vrsbt). ## You don't want to track whodunnit If you no longer want to track whodunnit, you may disable this warning by overriding user_for_paper_trail to return nil. ```ruby # in application_controller.rb def user_for_paper_trail nil # disable whodunnit tracking end ``` ## You just want the warning to go away Upgrade to PT 6. ## Why does PT no longer add this callback for me? So that you can control the order of callbacks. Maybe you have another callback that must happen first, before `set_paper_trail_whodunnit`. paper_trail-12.0.0/gemfiles/000077500000000000000000000000001403037104100156605ustar00rootroot00000000000000paper_trail-12.0.0/gemfiles/rails_5.2.gemfile000066400000000000000000000002371403037104100207120ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 5.2.4" gem "rails-controller-testing", "~> 1.0.2" gemspec path: "../" paper_trail-12.0.0/gemfiles/rails_6.0.gemfile000066400000000000000000000002371403037104100207110ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 6.0.3" gem "rails-controller-testing", "~> 1.0.3" gemspec path: "../" paper_trail-12.0.0/gemfiles/rails_6.1.gemfile000066400000000000000000000002371403037104100207120ustar00rootroot00000000000000# This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 6.1.0" gem "rails-controller-testing", "~> 1.0.5" gemspec path: "../" paper_trail-12.0.0/lib/000077500000000000000000000000001403037104100146335ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/000077500000000000000000000000001403037104100170045ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/000077500000000000000000000000001403037104100213065ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/install/000077500000000000000000000000001403037104100227545ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/install/USAGE000066400000000000000000000003211403037104100235370ustar00rootroot00000000000000Description: Generates (but does not run) a migration to add a versions table. Also generates an initializer file for configuring PaperTrail. See section 5.c. Generators in README.md for more information. paper_trail-12.0.0/lib/generators/paper_trail/install/install_generator.rb000066400000000000000000000047431403037104100270250ustar00rootroot00000000000000# frozen_string_literal: true require_relative "../migration_generator" module PaperTrail # Installs PaperTrail in a rails app. class InstallGenerator < MigrationGenerator # Class names of MySQL adapters. # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`. # - `Mysql2Adapter` - Used by `mysql2` gem. MYSQL_ADAPTERS = [ "ActiveRecord::ConnectionAdapters::MysqlAdapter", "ActiveRecord::ConnectionAdapters::Mysql2Adapter" ].freeze source_root File.expand_path("templates", __dir__) class_option( :with_changes, type: :boolean, default: false, desc: "Store changeset (diff) with each version" ) desc "Generates (but does not run) a migration to add a versions table." \ " See section 5.c. Generators in README.md for more information." def create_migration_file add_paper_trail_migration( "create_versions", item_type_options: item_type_options, versions_table_options: versions_table_options ) if options.with_changes? add_paper_trail_migration("add_object_changes_to_versions") end end private # MySQL 5.6 utf8mb4 limit is 191 chars for keys used in indexes. # See https://github.com/paper-trail-gem/paper_trail/issues/651 def item_type_options if mysql? ", { null: false, limit: 191 }" else ", { null: false }" end end def mysql? MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name) end # Even modern versions of MySQL still use `latin1` as the default character # encoding. Many users are not aware of this, and run into trouble when they # try to use PaperTrail in apps that otherwise tend to use UTF-8. Postgres, by # comparison, uses UTF-8 except in the unusual case where the OS is configured # with a custom locale. # # - https://dev.mysql.com/doc/refman/5.7/en/charset-applications.html # - http://www.postgresql.org/docs/9.4/static/multibyte.html # # Furthermore, MySQL's original implementation of UTF-8 was flawed, and had # to be fixed later by introducing a new charset, `utf8mb4`. # # - https://mathiasbynens.be/notes/mysql-utf8mb4 # - https://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html # def versions_table_options if mysql? ', options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"' else "" end end end end paper_trail-12.0.0/lib/generators/paper_trail/install/templates/000077500000000000000000000000001403037104100247525ustar00rootroot00000000000000add_object_changes_to_versions.rb.erb000066400000000000000000000007311403037104100341700ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/install/templates# This migration adds the optional `object_changes` column, in which PaperTrail # will store the `changes` diff for each update event. See the readme for # details. class AddObjectChangesToVersions < ActiveRecord::Migration<%= migration_version %> # The largest text column available in all supported RDBMS. # See `create_versions.rb` for details. TEXT_BYTES = 1_073_741_823 def change add_column :versions, :object_changes, :text, limit: TEXT_BYTES end end paper_trail-12.0.0/lib/generators/paper_trail/install/templates/create_versions.rb.erb000066400000000000000000000030331403037104100312400ustar00rootroot00000000000000# This migration creates the `versions` table, the only schema PT requires. # All other migrations PT provides are optional. class CreateVersions < ActiveRecord::Migration<%= migration_version %> # The largest text column available in all supported RDBMS is # 1024^3 - 1 bytes, roughly one gibibyte. We specify a size # so that MySQL will use `longtext` instead of `text`. Otherwise, # when serializing very large objects, `text` might not be big enough. TEXT_BYTES = 1_073_741_823 def change create_table :versions<%= versions_table_options %> do |t| t.string :item_type<%= item_type_options %> t.bigint :item_id, null: false t.string :event, null: false t.string :whodunnit t.text :object, limit: TEXT_BYTES # Known issue in MySQL: fractional second precision # ------------------------------------------------- # # MySQL timestamp columns do not support fractional seconds unless # defined with "fractional seconds precision". MySQL users should manually # add fractional seconds precision to this migration, specifically, to # the `created_at` column. # (https://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html) # # MySQL users should also upgrade to at least rails 4.2, which is the first # version of ActiveRecord with support for fractional seconds in MySQL. # (https://github.com/rails/rails/pull/14359) # t.datetime :created_at end add_index :versions, %i(item_type item_id) end end paper_trail-12.0.0/lib/generators/paper_trail/migration_generator.rb000066400000000000000000000020131403037104100256660ustar00rootroot00000000000000# frozen_string_literal: true require "rails/generators" require "rails/generators/active_record" module PaperTrail # Basic structure to support a generator that builds a migration class MigrationGenerator < ::Rails::Generators::Base include ::Rails::Generators::Migration def self.next_migration_number(dirname) ::ActiveRecord::Generators::Base.next_migration_number(dirname) end protected def add_paper_trail_migration(template, extra_options = {}) migration_dir = File.expand_path("db/migrate") if self.class.migration_exists?(migration_dir, template) ::Kernel.warn "Migration already exists: #{template}" else migration_template( "#{template}.rb.erb", "db/migrate/#{template}.rb", { migration_version: migration_version }.merge(extra_options) ) end end def migration_version format( "[%d.%d]", ActiveRecord::VERSION::MAJOR, ActiveRecord::VERSION::MINOR ) end end end paper_trail-12.0.0/lib/generators/paper_trail/update_item_subtype/000077500000000000000000000000001403037104100253615ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/update_item_subtype/USAGE000066400000000000000000000002761403037104100261550ustar00rootroot00000000000000Description: Generates (but does not run) a migration to update item_type for STI entries in an existing versions table. See section 5.c. Generators in README.md for more information. paper_trail-12.0.0/lib/generators/paper_trail/update_item_subtype/templates/000077500000000000000000000000001403037104100273575ustar00rootroot00000000000000update_versions_for_item_subtype.rb.erb000066400000000000000000000066041403037104100372530ustar00rootroot00000000000000paper_trail-12.0.0/lib/generators/paper_trail/update_item_subtype/templates# This migration updates existing `versions` that have `item_type` that refers to # the base_class, and changes them to refer to the subclass instead. class UpdateVersionsForItemSubtype < ActiveRecord::Migration<%= migration_version %> include ActionView::Helpers::TextHelper def up <%= # Returns class, column, range def self.parse_custom_entry(text) parts = text.split("):") range = parts.last.split("..").map(&:to_i) range = Range.new(range.first, range.last) parts.first.split("(") + [range] end # Running: # rails g paper_trail:update_item_subtype Animal(species):1..4 Plant(genus):42..1337 # results in: # # Versions of item_type "Animal" with IDs between 1 and 4 will be updated based on `species` # # Versions of item_type "Plant" with IDs between 42 and 1337 will be updated based on `genus` # hints = {"Animal"=>{1..4=>"species"}, "Plant"=>{42..1337=>"genus"}} hint_descriptions = "" hints = args.inject(Hash.new{|h, k| h[k] = {}}) do |s, v| klass, column, range = parse_custom_entry(v) hint_descriptions << " # Versions of item_type \"#{klass}\" with IDs between #{ range.first} and #{range.last} will be updated based on \`#{column}\`\n" s[klass][range] = column s end unless hints.empty? "#{hint_descriptions} hints = #{hints.inspect}\n" end %> # Find all ActiveRecord models mentioned in existing versions changes = Hash.new { |h, k| h[k] = [] } model_names = PaperTrail::Version.select(:item_type).distinct model_names.map(&:item_type).each do |model_name| hint = hints[model_name] if defined?(hints) begin klass = model_name.constantize # Actually implements an inheritance_column? (Usually "type") has_inheritance_column = klass.columns.map(&:name).include?(klass.inheritance_column) # Find domain of types stored in PaperTrail versions PaperTrail::Version.where(item_type: model_name, item_subtype: nil).select(:id, :object, :object_changes).each do |obj| if (object_detail = PaperTrail.serializer.load(obj.object || obj.object_changes)) is_found = false subtype_name = nil hint&.each do |k, v| if k === obj.id && (subtype_name = object_detail[v]) break end end if subtype_name.nil? && has_inheritance_column subtype_name = object_detail[klass.inheritance_column] end if subtype_name subtype_name = subtype_name.last if subtype_name.is_a?(Array) if subtype_name != model_name changes[subtype_name] << obj.id end end end end rescue NameError => ex say "Skipping reference to #{model_name}", subitem: true end end changes.each do |k, v| # Update in blocks of up to 100 at a time block_of_ids = [] id_count = 0 num_updated = 0 v.sort.each do |id| block_of_ids << id if (id_count += 1) % 100 == 0 num_updated += PaperTrail::Version.where(id: block_of_ids).update_all(item_subtype: k) block_of_ids = [] end end num_updated += PaperTrail::Version.where(id: block_of_ids).update_all(item_subtype: k) if num_updated > 0 say "Associated #{pluralize(num_updated, 'record')} to #{k}", subitem: true end end end end paper_trail-12.0.0/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb000066400000000000000000000010071403037104100340250ustar00rootroot00000000000000# frozen_string_literal: true require_relative "../migration_generator" module PaperTrail # Updates STI entries for PaperTrail class UpdateItemSubtypeGenerator < MigrationGenerator source_root File.expand_path("templates", __dir__) desc "Generates (but does not run) a migration to update item_subtype for STI entries in an "\ "existing versions table." def create_migration_file add_paper_trail_migration("update_versions_for_item_subtype", sti_type_options: options) end end end paper_trail-12.0.0/lib/paper_trail.rb000066400000000000000000000077171403037104100174760ustar00rootroot00000000000000# frozen_string_literal: true # AR does not require all of AS, but PT does. PT uses core_ext like # `String#squish`, so we require `active_support/all`. Instead of eagerly # loading all of AS here, we could put specific `require`s in only the various # PT files that need them, but this seems easier to troubleshoot, though it may # add a few milliseconds to rails boot time. If that becomes a pain point, we # can revisit this decision. require "active_support/all" require "paper_trail/cleaner" require "paper_trail/compatibility" require "paper_trail/config" require "paper_trail/record_history" require "paper_trail/request" require "paper_trail/version_number" require "paper_trail/serializers/json" # An ActiveRecord extension that tracks changes to your models, for auditing or # versioning. module PaperTrail E_TIMESTAMP_FIELD_CONFIG = <<-EOS.squish.freeze PaperTrail.timestamp_field= has been removed, without replacement. It is no longer configurable. The timestamp column in the versions table must now be named created_at. EOS extend PaperTrail::Cleaner class << self # Switches PaperTrail on or off, for all threads. # @api public def enabled=(value) PaperTrail.config.enabled = value end # Returns `true` if PaperTrail is on, `false` otherwise. This is the # on/off switch that affects all threads. Enabled by default. # @api public def enabled? !!PaperTrail.config.enabled end # Returns PaperTrail's `::Gem::Version`, convenient for comparisons. This is # recommended over `::PaperTrail::VERSION::STRING`. # # Added in 7.0.0 # # @api public def gem_version ::Gem::Version.new(VERSION::STRING) end # Set variables for the current request, eg. whodunnit. # # All request-level variables are now managed here, as of PT 9. Having the # word "request" right there in your application code will remind you that # these variables only affect the current request, not all threads. # # Given a block, temporarily sets the given `options`, executes the block, # and returns the value of the block. # # Without a block, this currently just returns `PaperTrail::Request`. # However, please do not use `PaperTrail::Request` directly. Currently, # `Request` is a `Module`, but in the future it is quite possible we may # make it a `Class`. If we make such a choice, we will not provide any # warning and will not treat it as a breaking change. You've been warned :) # # @api public def request(options = nil, &block) if options.nil? && !block_given? Request else Request.with(options, &block) end end # Set the field which records when a version was created. # @api public def timestamp_field=(_field_name) raise(E_TIMESTAMP_FIELD_CONFIG) end # Set the PaperTrail serializer. This setting affects all threads. # @api public def serializer=(value) PaperTrail.config.serializer = value end # Get the PaperTrail serializer used by all threads. # @api public def serializer PaperTrail.config.serializer end # Returns PaperTrail's global configuration object, a singleton. These # settings affect all threads. # @api private def config @config ||= PaperTrail::Config.instance yield @config if block_given? @config end alias configure config def version VERSION::STRING end end end # PT is built on ActiveRecord, but does not require Rails. If Rails is defined, # our Railtie makes sure not to load the AR-dependent parts of PT until AR is # ready. A typical Rails `application.rb` has: # # ``` # require 'rails/all' # Defines `Rails` # Bundler.require(*Rails.groups) # require 'paper_trail' (this file) # ``` # # Non-rails applications should take similar care to load AR before PT. if defined?(Rails) require "paper_trail/frameworks/rails" else require "paper_trail/frameworks/active_record" end paper_trail-12.0.0/lib/paper_trail/000077500000000000000000000000001403037104100171355ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/attribute_serializers/000077500000000000000000000000001403037104100235545ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/attribute_serializers/README.md000066400000000000000000000005761403037104100250430ustar00rootroot00000000000000Attribute Serializers ===================== "Serialization" here refers to the preparation of data for insertion into a database, particularly the `object` and `object_changes` columns in the `versions` table. Likewise, "deserialization" refers to any processing of data after they have been read from the database, for example preparing the result of `VersionConcern#changeset`. paper_trail-12.0.0/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb000066400000000000000000000022511403037104100320640ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/type_serializers/postgres_array_serializer" module PaperTrail module AttributeSerializers # Values returned by some Active Record serializers are # not suited for writing JSON to a text column. This factory # replaces certain default Active Record serializers # with custom PaperTrail ones. # # @api private module AttributeSerializerFactory class << self # @api private def for(klass, attr) active_record_serializer = klass.type_for_attribute(attr) if ar_pg_array?(active_record_serializer) TypeSerializers::PostgresArraySerializer.new( active_record_serializer.subtype, active_record_serializer.delimiter ) else active_record_serializer end end private # @api private def ar_pg_array?(obj) if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) obj.instance_of?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array) else false end end end end end end paper_trail-12.0.0/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb000066400000000000000000000025511403037104100313520ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/attribute_serializer_factory" module PaperTrail # :nodoc: module AttributeSerializers # The `CastAttributeSerializer` (de)serializes model attribute values. For # example, the string "1.99" serializes into the integer `1` when assigned # to an attribute of type `ActiveRecord::Type::Integer`. class CastAttributeSerializer def initialize(klass) @klass = klass end private # Returns a hash mapping attributes to hashes that map strings to # integers. Example: # # ``` # { "status" => { "draft"=>0, "published"=>1, "archived"=>2 } } # ``` # # ActiveRecord::Enum was added in AR 4.1 # http://edgeguides.rubyonrails.org/4_1_release_notes.html#active-record-enums def defined_enums @defined_enums ||= (@klass.respond_to?(:defined_enums) ? @klass.defined_enums : {}) end def deserialize(attr, val) if defined_enums[attr] && val.is_a?(::String) # Because PT 4 used to save the string version of enums to `object_changes` val else AttributeSerializerFactory.for(@klass, attr).deserialize(val) end end def serialize(attr, val) AttributeSerializerFactory.for(@klass, attr).serialize(val) end end end end paper_trail-12.0.0/lib/paper_trail/attribute_serializers/object_attribute.rb000066400000000000000000000021511403037104100274310ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/cast_attribute_serializer" module PaperTrail module AttributeSerializers # Serialize or deserialize the `version.object` column. class ObjectAttribute def initialize(model_class) @model_class = model_class end def serialize(attributes) alter(attributes, :serialize) end def deserialize(attributes) alter(attributes, :deserialize) end private # Modifies `attributes` in place. # TODO: Return a new hash instead. def alter(attributes, serialization_method) # Don't serialize before values before inserting into columns of type # `JSON` on `PostgreSQL` databases. return attributes if object_col_is_json? serializer = CastAttributeSerializer.new(@model_class) attributes.each do |key, value| attributes[key] = serializer.send(serialization_method, key, value) end end def object_col_is_json? @model_class.paper_trail.version_class.object_col_is_json? end end end end paper_trail-12.0.0/lib/paper_trail/attribute_serializers/object_changes_attribute.rb000066400000000000000000000024011403037104100311170ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/cast_attribute_serializer" module PaperTrail module AttributeSerializers # Serialize or deserialize the `version.object_changes` column. class ObjectChangesAttribute def initialize(item_class) @item_class = item_class end def serialize(changes) alter(changes, :serialize) end def deserialize(changes) alter(changes, :deserialize) end private # Modifies `changes` in place. # TODO: Return a new hash instead. def alter(changes, serialization_method) # Don't serialize before values before inserting into columns of type # `JSON` on `PostgreSQL` databases. return changes if object_changes_col_is_json? serializer = CastAttributeSerializer.new(@item_class) changes.clone.each do |key, change| # `change` is an Array with two elements, representing before and after. changes[key] = Array(change).map do |value| serializer.send(serialization_method, key, value) end end end def object_changes_col_is_json? @item_class.paper_trail.version_class.object_changes_col_is_json? end end end end paper_trail-12.0.0/lib/paper_trail/cleaner.rb000066400000000000000000000047231403037104100211010ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # Utilities for deleting version records. module Cleaner # Destroys all but the most recent version(s) for items on a given date # (or on all dates). Useful for deleting drafts. # # Options: # # - :keeping - An `integer` indicating the number of versions to be kept for # each item per date. Defaults to `1`. The most recent matching versions # are kept. # - :date - Should either be a `Date` object specifying which date to # destroy versions for or `:all`, which will specify that all dates # should be cleaned. Defaults to `:all`. # - :item_id - The `id` for the item to be cleaned on, or `nil`, which # causes all items to be cleaned. Defaults to `nil`. # def clean_versions!(options = {}) options = { keeping: 1, date: :all }.merge(options) gather_versions(options[:item_id], options[:date]).each do |_item_id, item_versions| group_versions_by_date(item_versions).each do |_date, date_versions| # Remove the number of versions we wish to keep from the collection # of versions prior to destruction. date_versions.pop(options[:keeping]) date_versions.map(&:destroy) end end end private # Returns a hash of versions grouped by the `item_id` attribute formatted # like this: {:item_id => PaperTrail::Version}. If `item_id` or `date` is # set, versions will be narrowed to those pointing at items with those ids # that were created on specified date. Versions are returned in # chronological order. def gather_versions(item_id = nil, date = :all) unless date == :all || date.respond_to?(:to_date) raise ArgumentError, "Expected date to be a Timestamp or :all" end versions = item_id ? PaperTrail::Version.where(item_id: item_id) : PaperTrail::Version versions = versions.order(PaperTrail::Version.timestamp_sort_order) versions = versions.between(date.to_date, date.to_date + 1.day) unless date == :all # If `versions` has not been converted to an ActiveRecord::Relation yet, # do so now. versions = PaperTrail::Version.all if versions == PaperTrail::Version versions.group_by(&:item_id) end # Given an array of versions, returns a hash mapping dates to arrays of # versions. # @api private def group_versions_by_date(versions) versions.group_by { |v| v.created_at.to_date } end end end paper_trail-12.0.0/lib/paper_trail/compatibility.rb000066400000000000000000000041511403037104100223340ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # Rails does not follow SemVer, makes breaking changes in minor versions. # Breaking changes are expected, and are generally good for the rails # ecosystem. However, they often require dozens of hours to fix, even with the # [help of experts](https://github.com/paper-trail-gem/paper_trail/pull/899). # # It is not safe to assume that a new version of rails will be compatible with # PaperTrail. PT is only compatible with the versions of rails that it is # tested against. See `.travis.yml`. # # However, as of # [#1213](https://github.com/paper-trail-gem/paper_trail/pull/1213) our # gemspec allows installation with newer, incompatible rails versions. We hope # this will make it easier for contributors to work on compatibility with # newer rails versions. Most PT users should avoid incompatible rails # versions. module Compatibility ACTIVERECORD_GTE = ">= 5.2" # enforced in gemspec ACTIVERECORD_LT = "< 6.2" # not enforced in gemspec E_INCOMPATIBLE_AR = <<-EOS PaperTrail %s is not compatible with ActiveRecord %s. We allow PT contributors to install incompatible versions of ActiveRecord, and this warning can be silenced with an environment variable, but this is a bad idea for normal use. Please install a compatible version of ActiveRecord instead (%s). Please see the discussion in paper_trail/compatibility.rb for details. EOS # Normal users need a warning if they accidentally install an incompatible # version of ActiveRecord. Contributors can silence this warning with an # environment variable. def self.check_activerecord(ar_version) raise ::TypeError unless ar_version.instance_of?(::Gem::Version) return if ::ENV["PT_SILENCE_AR_COMPAT_WARNING"].present? req = ::Gem::Requirement.new([ACTIVERECORD_GTE, ACTIVERECORD_LT]) unless req.satisfied_by?(ar_version) ::Kernel.warn( format( E_INCOMPATIBLE_AR, ::PaperTrail.gem_version, ar_version, req ) ) end end end end paper_trail-12.0.0/lib/paper_trail/config.rb000066400000000000000000000017531403037104100207350ustar00rootroot00000000000000# frozen_string_literal: true require "singleton" require "paper_trail/serializers/yaml" module PaperTrail # Global configuration affecting all threads. Some thread-specific # configuration can be found in `paper_trail.rb`, others in `controller.rb`. class Config include Singleton attr_accessor( :association_reify_error_behaviour, :object_changes_adapter, :serializer, :version_limit, :has_paper_trail_defaults ) def initialize # Variables which affect all threads, whose access is synchronized. @mutex = Mutex.new @enabled = true # Variables which affect all threads, whose access is *not* synchronized. @serializer = PaperTrail::Serializers::YAML @has_paper_trail_defaults = {} end # Indicates whether PaperTrail is on or off. Default: true. def enabled @mutex.synchronize { !!@enabled } end def enabled=(enable) @mutex.synchronize { @enabled = enable } end end end paper_trail-12.0.0/lib/paper_trail/events/000077500000000000000000000000001403037104100204415ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/events/base.rb000066400000000000000000000247311403037104100217070ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Events # We refer to times in the lifecycle of a record as "events". There are # three events: # # - create # - `after_create` we call `RecordTrail#record_create` # - update # - `after_update` we call `RecordTrail#record_update` # - `after_touch` we call `RecordTrail#record_update` # - `RecordTrail#save_with_version` calls `RecordTrail#record_update` # - `RecordTrail#update_columns` is also referred to as an update, though # it uses `RecordTrail#record_update_columns` rather than # `RecordTrail#record_update` # - destroy # - `before_destroy` or `after_destroy` we call `RecordTrail#record_destroy` # # The value inserted into the `event` column of the versions table can also # be overridden by the user, with `paper_trail_event`. # # @api private class Base # @api private def initialize(record, in_after_callback) @record = record @in_after_callback = in_after_callback end # Determines whether it is appropriate to generate a new version # instance. A timestamp-only update (e.g. only `updated_at` changed) is # considered notable unless an ignored attribute was also changed. # # @api private def changed_notably? if ignored_attr_has_changed? timestamps = @record.send(:timestamp_attributes_for_update_in_model).map(&:to_s) (notably_changed - timestamps).any? else notably_changed.any? end end private # Rails 5.1 changed the API of `ActiveRecord::Dirty`. See # https://github.com/paper-trail-gem/paper_trail/pull/899 # # @api private def attribute_changed_in_latest_version?(attr_name) if @in_after_callback @record.saved_change_to_attribute?(attr_name.to_s) else @record.attribute_changed?(attr_name.to_s) end end # @api private def nonskipped_attributes_before_change(is_touch) record_attributes = @record.attributes.except(*@record.paper_trail_options[:skip]) record_attributes.each_key do |k| if @record.class.column_names.include?(k) record_attributes[k] = attribute_in_previous_version(k, is_touch) end end end # Rails 5.1 changed the API of `ActiveRecord::Dirty`. See # https://github.com/paper-trail-gem/paper_trail/pull/899 # # Event can be any of the three (create, update, destroy). # # @api private def attribute_in_previous_version(attr_name, is_touch) if @in_after_callback && !is_touch # For most events, we want the original value of the attribute, before # the last save. @record.attribute_before_last_save(attr_name.to_s) else # We are either performing a `record_destroy` or a # `record_update(is_touch: true)`. @record.attribute_in_database(attr_name.to_s) end end # @api private def calculated_ignored_array ignore = @record.paper_trail_options[:ignore].dup # Remove Hash arguments and then evaluate whether the attributes (the # keys of the hash) should also get pushed into the collection. ignore.delete_if do |obj| obj.is_a?(Hash) && obj.each { |attr, condition| ignore << attr if condition.respond_to?(:call) && condition.call(@record) } end end # @api private def changed_and_not_ignored skip = @record.paper_trail_options[:skip] (changed_in_latest_version - calculated_ignored_array) - skip end # @api private def changed_in_latest_version # Memoized to reduce memory usage @changed_in_latest_version ||= changes_in_latest_version.keys end # Rails 5.1 changed the API of `ActiveRecord::Dirty`. See # https://github.com/paper-trail-gem/paper_trail/pull/899 # # @api private def changes_in_latest_version # Memoized to reduce memory usage @changes_in_latest_version ||= begin if @in_after_callback @record.saved_changes else @record.changes end end end # An attributed is "ignored" if it is listed in the `:ignore` option # and/or the `:skip` option. Returns true if an ignored attribute has # changed. # # @api private def ignored_attr_has_changed? ignored = calculated_ignored_array + @record.paper_trail_options[:skip] ignored.any? && (changed_in_latest_version & ignored).any? end # PT 10 has a new optional column, `item_subtype` # # @api private def merge_item_subtype_into(data) if @record.class.paper_trail.version_class.columns_hash.key?("item_subtype") data.merge!(item_subtype: @record.class.name) end end # Updates `data` from the model's `meta` option and from `controller_info`. # Metadata is always recorded; that means all three events (create, update, # destroy) and `update_columns`. # # @api private def merge_metadata_into(data) merge_metadata_from_model_into(data) merge_metadata_from_controller_into(data) end # Updates `data` from `controller_info`. # # @api private def merge_metadata_from_controller_into(data) data.merge(PaperTrail.request.controller_info || {}) end # Updates `data` from the model's `meta` option. # # @api private def merge_metadata_from_model_into(data) @record.paper_trail_options[:meta].each do |k, v| data[k] = model_metadatum(v, data[:event]) end end # Given a `value` from the model's `meta` option, returns an object to be # persisted. The `value` can be a simple scalar value, but it can also # be a symbol that names a model method, or even a Proc. # # @api private def model_metadatum(value, event) if value.respond_to?(:call) value.call(@record) elsif value.is_a?(Symbol) && @record.respond_to?(value, true) # If it is an attribute that is changing in an existing object, # be sure to grab the current version. if event != "create" && @record.has_attribute?(value) && attribute_changed_in_latest_version?(value) attribute_in_previous_version(value, false) else @record.send(value) end else value end end # @api private def notable_changes changes_in_latest_version.delete_if { |k, _v| !notably_changed.include?(k) } end # @api private def notably_changed # Memoized to reduce memory usage @notably_changed ||= begin only = @record.paper_trail_options[:only].dup # Remove Hash arguments and then evaluate whether the attributes (the # keys of the hash) should also get pushed into the collection. only.delete_if do |obj| obj.is_a?(Hash) && obj.each { |attr, condition| only << attr if condition.respond_to?(:call) && condition.call(@record) } end only.empty? ? changed_and_not_ignored : (changed_and_not_ignored & only) end end # Returns hash of attributes (with appropriate attributes serialized), # omitting attributes to be skipped. # # @api private def object_attrs_for_paper_trail(is_touch) attrs = nonskipped_attributes_before_change(is_touch) AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs) attrs end # @api private def prepare_object_changes(changes) changes = serialize_object_changes(changes) recordable_object_changes(changes) end # Returns an object which can be assigned to the `object_changes` # attribute of a nascent version record. If the `object_changes` column is # a postgres `json` column, then a hash can be used in the assignment, # otherwise the column is a `text` column, and we must perform the # serialization here, using `PaperTrail.serializer`. # # @api private # @param changes HashWithIndifferentAccess def recordable_object_changes(changes) if PaperTrail.config.object_changes_adapter.respond_to?(:diff) # We'd like to avoid the `to_hash` here, because it increases memory # usage, but that would be a breaking change because # `object_changes_adapter` expects a plain `Hash`, not a # `HashWithIndifferentAccess`. changes = PaperTrail.config.object_changes_adapter.diff(changes.to_hash) end if @record.class.paper_trail.version_class.object_changes_col_is_json? changes else PaperTrail.serializer.dump(changes) end end # Returns a boolean indicating whether to store serialized version diffs # in the `object_changes` column of the version record. # # @api private def record_object_changes? @record.class.paper_trail.version_class.column_names.include?("object_changes") end # Returns a boolean indicating whether to store the original object during save. # # @api private def record_object? @record.class.paper_trail.version_class.column_names.include?("object") end # Returns an object which can be assigned to the `object` attribute of a # nascent version record. If the `object` column is a postgres `json` # column, then a hash can be used in the assignment, otherwise the column # is a `text` column, and we must perform the serialization here, using # `PaperTrail.serializer`. # # @api private def recordable_object(is_touch) if @record.class.paper_trail.version_class.object_col_is_json? object_attrs_for_paper_trail(is_touch) else PaperTrail.serializer.dump(object_attrs_for_paper_trail(is_touch)) end end # @api private def serialize_object_changes(changes) AttributeSerializers::ObjectChangesAttribute. new(@record.class). serialize(changes) # We'd like to convert this `HashWithIndifferentAccess` to a plain # `Hash`, but we don't, to save memory. changes end end end end paper_trail-12.0.0/lib/paper_trail/events/create.rb000066400000000000000000000014451403037104100222350ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/events/base" module PaperTrail module Events # See docs in `Base`. # # @api private class Create < Base # Return attributes of nascent `Version` record. # # @api private def data data = { item: @record, event: @record.paper_trail_event || "create", whodunnit: PaperTrail.request.whodunnit } if @record.respond_to?(:updated_at) data[:created_at] = @record.updated_at end if record_object_changes? && changed_notably? changes = notable_changes data[:object_changes] = prepare_object_changes(changes) end merge_item_subtype_into(data) merge_metadata_into(data) end end end end paper_trail-12.0.0/lib/paper_trail/events/destroy.rb000066400000000000000000000021041403037104100224540ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/events/base" module PaperTrail module Events # See docs in `Base`. # # @api private class Destroy < Base # Return attributes of nascent `Version` record. # # @api private def data data = { item_id: @record.id, item_type: @record.class.base_class.name, event: @record.paper_trail_event || "destroy", whodunnit: PaperTrail.request.whodunnit } if record_object? data[:object] = recordable_object(false) end if record_object_changes? data[:object_changes] = prepare_object_changes(notable_changes) end merge_item_subtype_into(data) merge_metadata_into(data) end private # Rails' implementation (eg. `@record.saved_changes`) returns nothing on # destroy, so we have to build the hash we want. # # @override def changes_in_latest_version @record.attributes.transform_values { |value| [value, nil] } end end end end paper_trail-12.0.0/lib/paper_trail/events/update.rb000066400000000000000000000034711403037104100222550ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/events/base" module PaperTrail module Events # See docs in `Base`. # # @api private class Update < Base # - is_touch - [boolean] - Used in the two situations that are touch-like: # - `after_touch` we call `RecordTrail#record_update` # - force_changes - [Hash] - Only used by `RecordTrail#update_columns`, # because there dirty-tracking is off, so it has to track its own changes. # # @api private def initialize(record, in_after_callback, is_touch, force_changes) super(record, in_after_callback) @is_touch = is_touch @force_changes = force_changes end # Return attributes of nascent `Version` record. # # @api private def data data = { item: @record, event: @record.paper_trail_event || "update", whodunnit: PaperTrail.request.whodunnit } if @record.respond_to?(:updated_at) data[:created_at] = @record.updated_at end if record_object? data[:object] = recordable_object(@is_touch) end if record_object_changes? changes = @force_changes.nil? ? notable_changes : @force_changes data[:object_changes] = prepare_object_changes(changes) end merge_item_subtype_into(data) merge_metadata_into(data) end private # `touch` cannot record `object_changes` because rails' `touch` does not # perform dirty-tracking. Specifically, methods from `Dirty`, like # `saved_changes`, return the same values before and after `touch`. # # See https://github.com/rails/rails/issues/33429 # # @api private def record_object_changes? !@is_touch && super end end end end paper_trail-12.0.0/lib/paper_trail/frameworks/000077500000000000000000000000001403037104100213155ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/active_record.rb000066400000000000000000000007411403037104100244550ustar00rootroot00000000000000# frozen_string_literal: true # Either ActiveRecord has already been loaded by the Lazy Load Hook in our # Railtie, or else we load it now. require "active_record" ::PaperTrail::Compatibility.check_activerecord(::ActiveRecord.gem_version) # Now we can load the parts of PT that depend on AR. require "paper_trail/has_paper_trail" require "paper_trail/reifier" require "paper_trail/frameworks/active_record/models/paper_trail/version" ActiveRecord::Base.include PaperTrail::Model paper_trail-12.0.0/lib/paper_trail/frameworks/active_record/000077500000000000000000000000001403037104100241265ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/active_record/models/000077500000000000000000000000001403037104100254115ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/active_record/models/paper_trail/000077500000000000000000000000001403037104100277135ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/active_record/models/paper_trail/version.rb000066400000000000000000000010401403037104100317200ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/version_concern" module PaperTrail # This is the default ActiveRecord model provided by PaperTrail. Most simple # applications will use this model as-is, but it is possible to sub-class, # extend, or even do without this model entirely. See documentation section # 6.a. Custom Version Classes. # # The paper_trail-association_tracking gem provides a related model, # `VersionAssociation`. class Version < ::ActiveRecord::Base include PaperTrail::VersionConcern end end paper_trail-12.0.0/lib/paper_trail/frameworks/cucumber.rb000066400000000000000000000013301403037104100234440ustar00rootroot00000000000000# frozen_string_literal: true # before hook for Cucumber Before do PaperTrail.enabled = false PaperTrail.request.enabled = true PaperTrail.request.whodunnit = nil PaperTrail.request.controller_info = {} if defined?(::Rails) end module PaperTrail module Cucumber # Helper method for enabling PT in Cucumber features. module Extensions # :call-seq: # with_versioning # # enable versioning for specific blocks def with_versioning was_enabled = ::PaperTrail.enabled? ::PaperTrail.enabled = true begin yield ensure ::PaperTrail.enabled = was_enabled end end end end end World PaperTrail::Cucumber::Extensions paper_trail-12.0.0/lib/paper_trail/frameworks/rails.rb000066400000000000000000000001161403037104100227520ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/frameworks/rails/railtie" paper_trail-12.0.0/lib/paper_trail/frameworks/rails/000077500000000000000000000000001403037104100224275ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/rails/controller.rb000066400000000000000000000063071403037104100251450ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Rails # Extensions to rails controllers. Provides convenient ways to pass certain # information to the model layer, with `controller_info` and `whodunnit`. # Also includes a convenient on/off switch, # `paper_trail_enabled_for_controller`. module Controller def self.included(controller) controller.before_action( :set_paper_trail_enabled_for_controller, :set_paper_trail_controller_info ) end protected # Returns the user who is responsible for any changes that occur. # By default this calls `current_user` and returns the result. # # Override this method in your controller to call a different # method, e.g. `current_person`, or anything you like. # # @api public def user_for_paper_trail return unless defined?(current_user) current_user.try(:id) || current_user end # Returns any information about the controller or request that you # want PaperTrail to store alongside any changes that occur. By # default this returns an empty hash. # # Override this method in your controller to return a hash of any # information you need. The hash's keys must correspond to columns # in your `versions` table, so don't forget to add any new columns # you need. # # For example: # # {:ip => request.remote_ip, :user_agent => request.user_agent} # # The columns `ip` and `user_agent` must exist in your `versions` # table. # # Use the `:meta` option to # `PaperTrail::Model::ClassMethods.has_paper_trail` to store any extra # model-level data you need. # # @api public def info_for_paper_trail {} end # Returns `true` (default) or `false` depending on whether PaperTrail # should be active for the current request. # # Override this method in your controller to specify when PaperTrail # should be off. # # ``` # def paper_trail_enabled_for_controller # # Don't omit `super` without a good reason. # super && request.user_agent != 'Disable User-Agent' # end # ``` # # @api public def paper_trail_enabled_for_controller ::PaperTrail.enabled? end private # Tells PaperTrail whether versions should be saved in the current # request. # # @api public def set_paper_trail_enabled_for_controller ::PaperTrail.request.enabled = paper_trail_enabled_for_controller end # Tells PaperTrail who is responsible for any changes that occur. # # @api public def set_paper_trail_whodunnit if ::PaperTrail.request.enabled? ::PaperTrail.request.whodunnit = user_for_paper_trail end end # Tells PaperTrail any information from the controller you want to store # alongside any changes that occur. # # @api public def set_paper_trail_controller_info if ::PaperTrail.request.enabled? ::PaperTrail.request.controller_info = info_for_paper_trail end end end end end paper_trail-12.0.0/lib/paper_trail/frameworks/rails/railtie.rb000066400000000000000000000021551403037104100244100ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # Represents code to load within Rails framework. See documentation in # `railties/lib/rails/railtie.rb`. # @api private class Railtie < ::Rails::Railtie # PaperTrail only has one initializer. # # We specify `before: "load_config_initializers"` to ensure that the PT # initializer happens before "app initializers" (those defined in # the app's `config/initalizers`). initializer "paper_trail", before: "load_config_initializers" do # `on_load` is a "lazy load hook". It "declares a block that will be # executed when a Rails component is fully loaded". (See # `active_support/lazy_load_hooks.rb`) ActiveSupport.on_load(:action_controller) do require "paper_trail/frameworks/rails/controller" # Mix our extensions into `ActionController::Base`, which is `self` # because of the `class_eval` in `lazy_load_hooks.rb`. include PaperTrail::Rails::Controller end ActiveSupport.on_load(:active_record) do require "paper_trail/frameworks/active_record" end end end end paper_trail-12.0.0/lib/paper_trail/frameworks/rspec.rb000066400000000000000000000025641403037104100227650ustar00rootroot00000000000000# frozen_string_literal: true require "rspec/core" require "rspec/matchers" require "paper_trail/frameworks/rspec/helpers" RSpec.configure do |config| config.include ::PaperTrail::RSpec::Helpers::InstanceMethods config.extend ::PaperTrail::RSpec::Helpers::ClassMethods config.before(:each) do ::PaperTrail.enabled = false ::PaperTrail.request.enabled = true ::PaperTrail.request.whodunnit = nil ::PaperTrail.request.controller_info = {} if defined?(::Rails) && defined?(::RSpec::Rails) end config.before(:each, versioning: true) do ::PaperTrail.enabled = true end end RSpec::Matchers.define :be_versioned do # check to see if the model has `has_paper_trail` declared on it match { |actual| actual.is_a?(::PaperTrail::Model::InstanceMethods) } end RSpec::Matchers.define :have_a_version_with do |attributes| # check if the model has a version with the specified attributes match do |actual| versions_association = actual.class.versions_association_name actual.send(versions_association).where_object(attributes).any? end end RSpec::Matchers.define :have_a_version_with_changes do |attributes| # check if the model has a version changes with the specified attributes match do |actual| versions_association = actual.class.versions_association_name actual.send(versions_association).where_object_changes(attributes).any? end end paper_trail-12.0.0/lib/paper_trail/frameworks/rspec/000077500000000000000000000000001403037104100224315ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/frameworks/rspec/helpers.rb000066400000000000000000000014361403037104100244240ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module RSpec module Helpers # Included in the RSpec configuration in `frameworks/rspec.rb` module InstanceMethods # enable versioning for specific blocks (at instance-level) def with_versioning was_enabled = ::PaperTrail.enabled? ::PaperTrail.enabled = true yield ensure ::PaperTrail.enabled = was_enabled end end # Extended by the RSpec configuration in `frameworks/rspec.rb` module ClassMethods # enable versioning for specific blocks (at class-level) def with_versioning(&block) context "with versioning", versioning: true do class_exec(&block) end end end end end end paper_trail-12.0.0/lib/paper_trail/has_paper_trail.rb000066400000000000000000000074371403037104100226320ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/object_attribute" require "paper_trail/attribute_serializers/object_changes_attribute" require "paper_trail/model_config" require "paper_trail/record_trail" module PaperTrail # Extensions to `ActiveRecord::Base`. See `frameworks/active_record.rb`. # It is our goal to have the smallest possible footprint here, because # `ActiveRecord::Base` is a very crowded namespace! That is why we introduced # `.paper_trail` and `#paper_trail`. module Model def self.included(base) base.extend ClassMethods end # :nodoc: module ClassMethods # Declare this in your model to track every create, update, and destroy. # Each version of the model is available in the `versions` association. # # Options: # # - :on - The events to track (optional; defaults to all of them). Set # to an array of `:create`, `:update`, `:destroy` and `:touch` as desired. # - :class_name (deprecated) - The name of a custom Version class that # includes `PaperTrail::VersionConcern`. # - :ignore - An array of attributes for which a new `Version` will not be # created if only they change. It can also accept a Hash as an # argument where the key is the attribute to ignore (a `String` or # `Symbol`), which will only be ignored if the value is a `Proc` which # returns truthily. # - :if, :unless - Procs that allow to specify conditions when to save # versions for an object. # - :only - Inverse of `ignore`. A new `Version` will be created only # for these attributes if supplied it can also accept a Hash as an # argument where the key is the attribute to track (a `String` or # `Symbol`), which will only be counted if the value is a `Proc` which # returns truthily. # - :skip - Fields to ignore completely. As with `ignore`, updates to # these fields will not create a new `Version`. In addition, these # fields will not be included in the serialized versions of the object # whenever a new `Version` is created. # - :meta - A hash of extra data to store. You must add a column to the # `versions` table for each key. Values are objects or procs (which # are called with `self`, i.e. the model with the paper trail). See # `PaperTrail::Controller.info_for_paper_trail` for how to store data # from the controller. # - :versions - Either, # - A String (deprecated) - The name to use for the versions # association. Default is `:versions`. # - A Hash - options passed to `has_many`, plus `name:` and `scope:`. # - :version - The name to use for the method which returns the version # the instance was reified from. Default is `:version`. # # Plugins like the experimental `paper_trail-association_tracking` gem # may accept additional options. # # You can define a default set of options via the configurable # `PaperTrail.config.has_paper_trail_defaults` hash in your applications # initializer. The hash can contain any of the following options and will # provide an overridable default for all models. # # @api public def has_paper_trail(options = {}) defaults = PaperTrail.config.has_paper_trail_defaults paper_trail.setup(defaults.merge(options)) end # @api public def paper_trail ::PaperTrail::ModelConfig.new(self) end end # Wrap the following methods in a module so we can include them only in the # ActiveRecord models that declare `has_paper_trail`. module InstanceMethods # @api public def paper_trail ::PaperTrail::RecordTrail.new(self) end end end end paper_trail-12.0.0/lib/paper_trail/model_config.rb000066400000000000000000000214441403037104100221140ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # Configures an ActiveRecord model, mostly at application boot time, but also # sometimes mid-request, with methods like enable/disable. class ModelConfig E_CANNOT_RECORD_AFTER_DESTROY = <<-STR.strip_heredoc.freeze paper_trail.on_destroy(:after) is incompatible with ActiveRecord's belongs_to_required_by_default. Use on_destroy(:before) or disable belongs_to_required_by_default. STR E_HPT_ABSTRACT_CLASS = <<~STR.squish.freeze An application model (%s) has been configured to use PaperTrail (via `has_paper_trail`), but the version model it has been told to use (%s) is an `abstract_class`. This could happen when an advanced feature called Custom Version Classes (http://bit.ly/2G4ch0G) is misconfigured. When all version classes are custom, PaperTrail::Version is configured to be an `abstract_class`. This is fine, but all application models must be configured to use concrete (not abstract) version models. STR E_MODEL_LIMIT_REQUIRES_ITEM_SUBTYPE = <<~STR.squish.freeze To use PaperTrail's per-model limit in your %s model, you must have an item_subtype column in your versions table. See documentation sections 2.e.1 Per-model limit, and 4.b.1 The optional item_subtype column. STR DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION = <<~STR.squish Passing versions association name as `has_paper_trail versions: %{versions_name}` is deprecated. Use `has_paper_trail versions: {name: %{versions_name}}` instead. The hash you pass to `versions:` is now passed directly to `has_many`. STR DPR_CLASS_NAME_OPTION = <<~STR.squish Passing Version class name as `has_paper_trail class_name: %{class_name}` is deprecated. Use `has_paper_trail versions: {class_name: %{class_name}}` instead. The hash you pass to `versions:` is now passed directly to `has_many`. STR def initialize(model_class) @model_class = model_class end # Adds a callback that records a version after a "create" event. # # @api public def on_create @model_class.after_create { |r| r.paper_trail.record_create if r.paper_trail.save_version? } return if @model_class.paper_trail_options[:on].include?(:create) @model_class.paper_trail_options[:on] << :create end # Adds a callback that records a version before or after a "destroy" event. # # @api public def on_destroy(recording_order = "before") unless %w[after before].include?(recording_order.to_s) raise ArgumentError, 'recording order can only be "after" or "before"' end if recording_order.to_s == "after" && cannot_record_after_destroy? raise E_CANNOT_RECORD_AFTER_DESTROY end @model_class.send( "#{recording_order}_destroy", lambda do |r| return unless r.paper_trail.save_version? r.paper_trail.record_destroy(recording_order) end ) return if @model_class.paper_trail_options[:on].include?(:destroy) @model_class.paper_trail_options[:on] << :destroy end # Adds a callback that records a version after an "update" event. # # @api public def on_update @model_class.before_save { |r| r.paper_trail.reset_timestamp_attrs_for_update_if_needed } @model_class.after_update { |r| if r.paper_trail.save_version? r.paper_trail.record_update( force: false, in_after_callback: true, is_touch: false ) end } @model_class.after_update { |r| r.paper_trail.clear_version_instance } return if @model_class.paper_trail_options[:on].include?(:update) @model_class.paper_trail_options[:on] << :update end # Adds a callback that records a version after a "touch" event. # @api public def on_touch @model_class.after_touch { |r| r.paper_trail.record_update( force: true, in_after_callback: true, is_touch: true ) } end # Set up `@model_class` for PaperTrail. Installs callbacks, associations, # "class attributes", instance methods, and more. # @api private def setup(options = {}) options[:on] ||= %i[create update destroy touch] options[:on] = Array(options[:on]) # Support single symbol @model_class.send :include, ::PaperTrail::Model::InstanceMethods setup_options(options) setup_associations(options) check_presence_of_item_subtype_column(options) @model_class.after_rollback { paper_trail.clear_rolled_back_versions } setup_callbacks_from_options options[:on] end # @api private def version_class @version_class ||= @model_class.version_class_name.constantize end private # Raises an error if the provided class is an `abstract_class`. # @api private def assert_concrete_activerecord_class(class_name) if class_name.constantize.abstract_class? raise format(E_HPT_ABSTRACT_CLASS, @model_class, class_name) end end def cannot_record_after_destroy? ::ActiveRecord::Base.belongs_to_required_by_default end # Some options require the presence of the `item_subtype` column. Currently # only `limit`, but in the future there may be others. # # @api private def check_presence_of_item_subtype_column(options) return unless options.key?(:limit) return if version_class.item_subtype_column_present? raise format(E_MODEL_LIMIT_REQUIRES_ITEM_SUBTYPE, @model_class.name) end def check_version_class_name(options) # @api private - `version_class_name` @model_class.class_attribute :version_class_name if options[:class_name] ::ActiveSupport::Deprecation.warn( format( DPR_CLASS_NAME_OPTION, class_name: options[:class_name].inspect ), caller(1) ) options[:versions][:class_name] = options[:class_name] end @model_class.version_class_name = options[:versions][:class_name] || "PaperTrail::Version" assert_concrete_activerecord_class(@model_class.version_class_name) end def check_versions_association_name(options) # @api private - versions_association_name @model_class.class_attribute :versions_association_name @model_class.versions_association_name = options[:versions][:name] || :versions end def define_has_many_versions(options) options = ensure_versions_option_is_hash(options) check_version_class_name(options) check_versions_association_name(options) scope = get_versions_scope(options) @model_class.has_many( @model_class.versions_association_name, scope, class_name: @model_class.version_class_name, as: :item, **options[:versions].except(:name, :scope) ) end def ensure_versions_option_is_hash(options) unless options[:versions].is_a?(Hash) if options[:versions] ::ActiveSupport::Deprecation.warn( format( DPR_PASSING_ASSOC_NAME_DIRECTLY_TO_VERSIONS_OPTION, versions_name: options[:versions].inspect ), caller(1) ) end options[:versions] = { name: options[:versions] } end options end def get_versions_scope(options) options[:versions][:scope] || -> { order(model.timestamp_sort_order) } end def setup_associations(options) # @api private - version_association_name @model_class.class_attribute :version_association_name @model_class.version_association_name = options[:version] || :version # The version this instance was reified from. # @api public @model_class.send :attr_accessor, @model_class.version_association_name # @api public - paper_trail_event @model_class.send :attr_accessor, :paper_trail_event define_has_many_versions(options) end def setup_callbacks_from_options(options_on = []) options_on.each do |event| public_send("on_#{event}") end end def setup_options(options) # @api public - paper_trail_options - Let's encourage plugins to use eg. # `paper_trail_options[:versions][:class_name]` rather than # `version_class_name` because the former is documented and the latter is # not. @model_class.class_attribute :paper_trail_options @model_class.paper_trail_options = options.dup %i[ignore skip only].each do |k| @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]]. flatten. compact. map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s } end @model_class.paper_trail_options[:meta] ||= {} end end end paper_trail-12.0.0/lib/paper_trail/queries/000077500000000000000000000000001403037104100206125ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/queries/versions/000077500000000000000000000000001403037104100224625ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/queries/versions/where_object.rb000066400000000000000000000035371403037104100254570ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Queries module Versions # For public API documentation, see `where_object` in # `paper_trail/version_concern.rb`. # @api private class WhereObject # - version_model_class - The class that VersionConcern was mixed into. # - attributes - A `Hash` of attributes and values. See the public API # documentation for details. # @api private def initialize(version_model_class, attributes) @version_model_class = version_model_class @attributes = attributes end # @api private def execute column = @version_model_class.columns_hash["object"] raise "where_object can't be called without an object column" unless column case column.type when :jsonb jsonb when :json json else text end end private # @api private def json predicates = [] values = [] @attributes.each do |field, value| predicates.push "object->>? = ?" values.concat([field, value.to_s]) end sql = predicates.join(" and ") @version_model_class.where(sql, *values) end # @api private def jsonb @version_model_class.where("object @> ?", @attributes.to_json) end # @api private def text arel_field = @version_model_class.arel_table[:object] where_conditions = @attributes.map { |field, value| ::PaperTrail.serializer.where_object_condition(arel_field, field, value) } where_conditions = where_conditions.reduce { |a, e| a.and(e) } @version_model_class.where(where_conditions) end end end end end paper_trail-12.0.0/lib/paper_trail/queries/versions/where_object_changes.rb000066400000000000000000000047471403037104100271530ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Queries module Versions # For public API documentation, see `where_object_changes` in # `paper_trail/version_concern.rb`. # @api private class WhereObjectChanges # - version_model_class - The class that VersionConcern was mixed into. # - attributes - A `Hash` of attributes and values. See the public API # documentation for details. # @api private def initialize(version_model_class, attributes) @version_model_class = version_model_class # Currently, this `deep_dup` is necessary because the `jsonb` branch # modifies `@attributes`, and that would be a nasty suprise for # consumers of this class. # TODO: Stop modifying `@attributes`, then remove `deep_dup`. @attributes = attributes.deep_dup end # @api private def execute if PaperTrail.config.object_changes_adapter.respond_to?(:where_object_changes) return PaperTrail.config.object_changes_adapter.where_object_changes( @version_model_class, @attributes ) end case @version_model_class.columns_hash["object_changes"].type when :jsonb jsonb when :json json else text end end private # @api private def json predicates = [] values = [] @attributes.each do |field, value| predicates.push( "((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))" ) values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"]) end sql = predicates.join(" and ") @version_model_class.where(sql, *values) end # @api private def jsonb @attributes.each { |field, value| @attributes[field] = [value] } @version_model_class.where("object_changes @> ?", @attributes.to_json) end # @api private def text arel_field = @version_model_class.arel_table[:object_changes] where_conditions = @attributes.map { |field, value| ::PaperTrail.serializer.where_object_changes_condition(arel_field, field, value) } where_conditions = where_conditions.reduce { |a, e| a.and(e) } @version_model_class.where(where_conditions) end end end end end paper_trail-12.0.0/lib/paper_trail/queries/versions/where_object_changes_from.rb000066400000000000000000000037011403037104100301630ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Queries module Versions # For public API documentation, see `where_object_changes_from` in # `paper_trail/version_concern.rb`. # @api private class WhereObjectChangesFrom # - version_model_class - The class that VersionConcern was mixed into. # - attributes - A `Hash` of attributes and values. See the public API # documentation for details. # @api private def initialize(version_model_class, attributes) @version_model_class = version_model_class @attributes = attributes end # @api private def execute if PaperTrail.config.object_changes_adapter.respond_to?(:where_object_changes_from) return PaperTrail.config.object_changes_adapter.where_object_changes_from( @version_model_class, @attributes ) end case @version_model_class.columns_hash["object_changes"].type when :jsonb, :json json else text end end private # @api private def json predicates = [] values = [] @attributes.each do |field, value| predicates.push( "(object_changes->>? ILIKE ?)" ) values.concat([field, "[#{value.to_json},%"]) end sql = predicates.join(" and ") @version_model_class.where(sql, *values) end # @api private def text arel_field = @version_model_class.arel_table[:object_changes] where_conditions = @attributes.map do |field, value| ::PaperTrail.serializer.where_object_changes_from_condition(arel_field, field, value) end where_conditions = where_conditions.reduce { |a, e| a.and(e) } @version_model_class.where(where_conditions) end end end end end paper_trail-12.0.0/lib/paper_trail/record_history.rb000066400000000000000000000026421403037104100225250ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # Represents the history of a single record. # @api private class RecordHistory # @param versions - ActiveRecord::Relation - All versions of the record. # @param version_class - Class - Usually PaperTrail::Version, # but it could also be a custom version class. # @api private def initialize(versions, version_class) @versions = versions @version_class = version_class end # Returns ordinal position of `version` in `sequence`. # @api private def index(version) sequence.to_a.index(version) end private # Returns `@versions` in chronological order. # @api private def sequence if @version_class.primary_key_is_int? @versions.select(primary_key).order(primary_key.asc) else @versions. select([table[:created_at], primary_key]). order(@version_class.timestamp_sort_order) end end # @return - Arel::Attribute - Attribute representing the primary key # of the version table. The column's data type is usually a serial # integer (the rails convention) but not always. # @api private def primary_key table[@version_class.primary_key] end # @return - Arel::Table - The version table, usually named `versions`, but # not always. # @api private def table @version_class.arel_table end end end paper_trail-12.0.0/lib/paper_trail/record_trail.rb000066400000000000000000000231641403037104100221410ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/events/create" require "paper_trail/events/destroy" require "paper_trail/events/update" module PaperTrail # Represents the "paper trail" for a single record. class RecordTrail def initialize(record) @record = record end # Invoked after rollbacks to ensure versions records are not created for # changes that never actually took place. Optimization: Use lazy `reset` # instead of eager `reload` because, in many use cases, the association will # not be used. def clear_rolled_back_versions versions.reset end # Invoked via`after_update` callback for when a previous version is # reified and then saved. def clear_version_instance @record.send("#{@record.class.version_association_name}=", nil) end # Is PT enabled for this particular record? # @api private def enabled? PaperTrail.enabled? && PaperTrail.request.enabled? && PaperTrail.request.enabled_for_model?(@record.class) end # Returns true if this instance is the current, live one; # returns false if this instance came from a previous version. def live? source_version.nil? end # Returns the object (not a Version) as it became next. # NOTE: if self (the item) was not reified from a version, i.e. it is the # "live" item, we return nil. Perhaps we should return self instead? def next_version subsequent_version = source_version.next subsequent_version ? subsequent_version.reify : @record.class.find(@record.id) rescue StandardError # TODO: Rescue something more specific nil end # Returns who put `@record` into its current state. # # @api public def originator (source_version || versions.last).try(:whodunnit) end # Returns the object (not a Version) as it was most recently. # # @api public def previous_version (source_version ? source_version.previous : versions.last).try(:reify) end def record_create return unless enabled? build_version_on_create(in_after_callback: true).tap do |version| version.save! # Because the version object was created using version_class.new instead # of versions_assoc.build?, the association cache is unaware. So, we # invalidate the `versions` association cache with `reset`. versions.reset end end # PT-AT extends this method to add its transaction id. # # @api private def data_for_create {} end # `recording_order` is "after" or "before". See ModelConfig#on_destroy. # # @api private # @return - The created version object, so that plugins can use it, e.g. # paper_trail-association_tracking def record_destroy(recording_order) return unless enabled? && !@record.new_record? in_after_callback = recording_order == "after" event = Events::Destroy.new(@record, in_after_callback) # Merge data from `Event` with data from PT-AT. We no longer use # `data_for_destroy` but PT-AT still does. data = event.data.merge(data_for_destroy) version = @record.class.paper_trail.version_class.create(data) if version.errors.any? log_version_errors(version, :destroy) else assign_and_reset_version_association(version) version end end # PT-AT extends this method to add its transaction id. # # @api private def data_for_destroy {} end # @api private # @return - The created version object, so that plugins can use it, e.g. # paper_trail-association_tracking def record_update(force:, in_after_callback:, is_touch:) return unless enabled? version = build_version_on_update( force: force, in_after_callback: in_after_callback, is_touch: is_touch ) return unless version if version.save # Because the version object was created using version_class.new instead # of versions_assoc.build?, the association cache is unaware. So, we # invalidate the `versions` association cache with `reset`. versions.reset version else log_version_errors(version, :update) end end # PT-AT extends this method to add its transaction id. # # @api private def data_for_update {} end # @api private # @return - The created version object, so that plugins can use it, e.g. # paper_trail-association_tracking def record_update_columns(changes) return unless enabled? event = Events::Update.new(@record, false, false, changes) # Merge data from `Event` with data from PT-AT. We no longer use # `data_for_update_columns` but PT-AT still does. data = event.data.merge(data_for_update_columns) versions_assoc = @record.send(@record.class.versions_association_name) version = versions_assoc.create(data) if version.errors.any? log_version_errors(version, :update) else version end end # PT-AT extends this method to add its transaction id. # # @api private def data_for_update_columns {} end # Invoked via callback when a user attempts to persist a reified # `Version`. def reset_timestamp_attrs_for_update_if_needed return if live? @record.send(:timestamp_attributes_for_update_in_model).each do |column| @record.send("restore_#{column}!") end end # AR callback. # @api private def save_version? if_condition = @record.paper_trail_options[:if] unless_condition = @record.paper_trail_options[:unless] (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record) end def source_version version end # Save, and create a version record regardless of options such as `:on`, # `:if`, or `:unless`. # # Arguments are passed to `save`. # # This is an "update" event. That is, we record the same data we would in # the case of a normal AR `update`. def save_with_version(**options) ::PaperTrail.request(enabled: false) do @record.save(**options) end record_update(force: true, in_after_callback: false, is_touch: false) end # Like the `update_column` method from `ActiveRecord::Persistence`, but also # creates a version to record those changes. # @api public def update_column(name, value) update_columns(name => value) end # Like the `update_columns` method from `ActiveRecord::Persistence`, but also # creates a version to record those changes. # @api public def update_columns(attributes) # `@record.update_columns` skips dirty-tracking, so we can't just use # `@record.changes` or @record.saved_changes` from `ActiveModel::Dirty`. # We need to build our own hash with the changes that will be made # directly to the database. changes = {} attributes.each do |k, v| changes[k] = [@record[k], v] end @record.update_columns(attributes) record_update_columns(changes) end # Returns the object (not a Version) as it was at the given timestamp. def version_at(timestamp, reify_options = {}) # Because a version stores how its object looked *before* the change, # we need to look for the first version created *after* the timestamp. v = versions.subsequent(timestamp, true).first return v.reify(reify_options) if v @record unless @record.destroyed? end # Returns the objects (not Versions) as they were between the given times. def versions_between(start_time, end_time) versions = send(@record.class.versions_association_name).between(start_time, end_time) versions.collect { |version| version_at(version.created_at) } end private # @api private def assign_and_reset_version_association(version) @record.send("#{@record.class.version_association_name}=", version) @record.send(@record.class.versions_association_name).reset end # @api private def build_version_on_create(in_after_callback:) event = Events::Create.new(@record, in_after_callback) # Merge data from `Event` with data from PT-AT. We no longer use # `data_for_create` but PT-AT still does. data = event.data.merge!(data_for_create) # Pure `version_class.new` reduces memory usage compared to `versions_assoc.build` @record.class.paper_trail.version_class.new(data) end # @api private def build_version_on_update(force:, in_after_callback:, is_touch:) event = Events::Update.new(@record, in_after_callback, is_touch, nil) return unless force || event.changed_notably? # Merge data from `Event` with data from PT-AT. We no longer use # `data_for_update` but PT-AT still does. To save memory, we use `merge!` # instead of `merge`. data = event.data.merge!(data_for_update) # Using `version_class.new` reduces memory usage compared to # `versions_assoc.build`. It's a trade-off though. We have to clear # the association cache (see `versions.reset`) and that could cause an # additional query in certain applications. @record.class.paper_trail.version_class.new(data) end def log_version_errors(version, action) version.logger&.warn( "Unable to create version for #{action} of #{@record.class.name}" \ "##{@record.id}: " + version.errors.full_messages.join(", ") ) end def version @record.public_send(@record.class.version_association_name) end def versions @record.public_send(@record.class.versions_association_name) end end end paper_trail-12.0.0/lib/paper_trail/reifier.rb000066400000000000000000000121341403037104100211100ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/object_attribute" module PaperTrail # Given a version record and some options, builds a new model object. # @api private module Reifier class << self # See `VersionConcern#reify` for documentation. # @api private def reify(version, options) options = apply_defaults_to(options, version) attrs = version.object_deserialized model = init_model(attrs, options, version) reify_attributes(model, version, attrs) model.send "#{model.class.version_association_name}=", version model end private # Given a hash of `options` for `.reify`, return a new hash with default # values applied. # @api private def apply_defaults_to(options, version) { version_at: version.created_at, mark_for_destruction: false, has_one: false, has_many: false, belongs_to: false, has_and_belongs_to_many: false, unversioned_attributes: :nil }.merge(options) end # Initialize a model object suitable for reifying `version` into. Does # not perform reification, merely instantiates the appropriate model # class and, if specified by `options[:unversioned_attributes]`, sets # unversioned attributes to `nil`. # # Normally a polymorphic belongs_to relationship allows us to get the # object we belong to by calling, in this case, `item`. However this # returns nil if `item` has been destroyed, and we need to be able to # retrieve destroyed objects. # # In this situation we constantize the `item_type` to get hold of the # class...except when the stored object's attributes include a `type` # key. If this is the case, the object we belong to is using single # table inheritance (STI) and the `item_type` will be the base class, # not the actual subclass. If `type` is present but empty, the class is # the base class. def init_model(attrs, options, version) klass = version_reification_class(version, attrs) # The `dup` option and destroyed version always returns a new object, # otherwise we should attempt to load item or to look for the item # outside of default scope(s). model = if options[:dup] == true || version.event == "destroy" klass.new else find_cond = { klass.primary_key => version.item_id } version.item || klass.unscoped.where(find_cond).first || klass.new end if options[:unversioned_attributes] == :nil && !model.new_record? init_unversioned_attrs(attrs, model) end model end # Look for attributes that exist in `model` and not in this version. # These attributes should be set to nil. Modifies `attrs`. # @api private def init_unversioned_attrs(attrs, model) (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil } end # Reify onto `model` an attribute named `k` with value `v` from `version`. # # `ObjectAttribute#deserialize` will return the mapped enum value and in # Rails < 5, the []= uses the integer type caster from the column # definition (in general) and thus will turn a (usually) string to 0 # instead of the correct value. # # @api private def reify_attribute(k, v, model, version) if model.has_attribute?(k) model[k.to_sym] = v elsif model.respond_to?("#{k}=") model.send("#{k}=", v) elsif version.logger version.logger.warn( "Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})." ) end end # Reify onto `model` all the attributes of `version`. # @api private def reify_attributes(model, version, attrs) AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs) attrs.each do |k, v| reify_attribute(k, v, model, version) end end # Given a `version`, return the class to reify. This method supports # Single Table Inheritance (STI) with custom inheritance columns. # # For example, imagine a `version` whose `item_type` is "Animal". The # `animals` table is an STI table (it has cats and dogs) and it has a # custom inheritance column, `species`. If `attrs["species"]` is "Dog", # this method returns the constant `Dog`. If `attrs["species"]` is blank, # this method returns the constant `Animal`. You can see this particular # example in action in `spec/models/animal_spec.rb`. # # TODO: Duplication: similar `constantize` in VersionConcern#version_limit def version_reification_class(version, attrs) inheritance_column_name = version.item_type.constantize.inheritance_column inher_col_value = attrs[inheritance_column_name] class_name = inher_col_value.blank? ? version.item_type : inher_col_value class_name.constantize end end end end paper_trail-12.0.0/lib/paper_trail/request.rb000066400000000000000000000120261403037104100211530ustar00rootroot00000000000000# frozen_string_literal: true require "request_store" module PaperTrail # Manages variables that affect the current HTTP request, such as `whodunnit`. # # Please do not use `PaperTrail::Request` directly, use `PaperTrail.request`. # Currently, `Request` is a `Module`, but in the future it is quite possible # we may make it a `Class`. If we make such a choice, we will not provide any # warning and will not treat it as a breaking change. You've been warned :) # # @api private module Request class InvalidOption < RuntimeError end class << self # Sets any data from the controller that you want PaperTrail to store. # See also `PaperTrail::Rails::Controller#info_for_paper_trail`. # # PaperTrail.request.controller_info = { ip: request_user_ip } # PaperTrail.request.controller_info # => { ip: '127.0.0.1' } # # @api public def controller_info=(value) store[:controller_info] = value end # Returns the data from the controller that you want PaperTrail to store. # See also `PaperTrail::Rails::Controller#info_for_paper_trail`. # # PaperTrail.request.controller_info = { ip: request_user_ip } # PaperTrail.request.controller_info # => { ip: '127.0.0.1' } # # @api public def controller_info store[:controller_info] end # Switches PaperTrail off for the given model. # @api public def disable_model(model_class) enabled_for_model(model_class, false) end # Switches PaperTrail on for the given model. # @api public def enable_model(model_class) enabled_for_model(model_class, true) end # Sets whether PaperTrail is enabled or disabled for the current request. # @api public def enabled=(value) store[:enabled] = value end # Returns `true` if PaperTrail is enabled for the request, `false` otherwise. # See `PaperTrail::Rails::Controller#paper_trail_enabled_for_controller`. # @api public def enabled? !!store[:enabled] end # Sets whether PaperTrail is enabled or disabled for this model in the # current request. # @api public def enabled_for_model(model, value) store[:"enabled_for_#{model}"] = value end # Returns `true` if PaperTrail is enabled for this model in the current # request, `false` otherwise. # @api public def enabled_for_model?(model) model.include?(::PaperTrail::Model::InstanceMethods) && !!store.fetch(:"enabled_for_#{model}", true) end # @api private def merge(options) options.to_h.each do |k, v| store[k] = v end end # @api private def set(options) store.clear merge(options) end # Returns a deep copy of the internal hash from our RequestStore. Keys are # all symbols. Values are mostly primitives, but whodunnit can be a Proc. # We cannot use Marshal.dump here because it doesn't support Proc. It is # unclear exactly how `deep_dup` handles a Proc, but it doesn't complain. # @api private def to_h store.deep_dup end # Temporarily set `options` and execute a block. # @api private def with(options) return unless block_given? validate_public_options(options) before = to_h merge(options) yield ensure set(before) end # Sets who is responsible for any changes that occur during request. You # would normally use this in a migration or on the console, when working # with models directly. # # `value` is usually a string, the name of a person, but you can set # anything that responds to `to_s`. You can also set a Proc, which will # not be evaluated until `whodunnit` is called later, usually right before # inserting a `Version` record. # # @api public def whodunnit=(value) store[:whodunnit] = value end # Returns who is reponsible for any changes that occur during request. # # @api public def whodunnit who = store[:whodunnit] who.respond_to?(:call) ? who.call : who end private # Returns a Hash, initializing with default values if necessary. # @api private def store RequestStore.store[:paper_trail] ||= { enabled: true } end # Provide a helpful error message if someone has a typo in one of their # option keys. We don't validate option values here. That's traditionally # been handled with casting (`to_s`, `!!`) in the accessor method. # @api private def validate_public_options(options) options.each do |k, _v| case k when :controller_info, /enabled_for_/, :enabled, :whodunnit next else raise InvalidOption, "Invalid option: #{k}" end end end end end end paper_trail-12.0.0/lib/paper_trail/serializers/000077500000000000000000000000001403037104100214715ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/serializers/json.rb000066400000000000000000000037101403037104100227700ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module Serializers # An alternate serializer for, e.g. `versions.object`. module JSON extend self # makes all instance methods become module methods as well def load(string) ActiveSupport::JSON.decode string end def dump(object) ActiveSupport::JSON.encode object end # Returns a SQL LIKE condition to be used to match the given field and # value in the serialized object. def where_object_condition(arel_field, field, value) # Convert to JSON to handle strings and nulls correctly. json_value = value.to_json # If the value is a number, we need to ensure that we find the next # character too, which is either `,` or `}`, to ensure that searching # for the value 12 doesn't yield false positives when the value is # 123. if value.is_a? Numeric arel_field.matches("%\"#{field}\":#{json_value},%"). or(arel_field.matches("%\"#{field}\":#{json_value}}%")) else arel_field.matches("%\"#{field}\":#{json_value}%") end end def where_object_changes_condition(*) raise <<-STR.squish.freeze where_object_changes no longer supports reading JSON from a text column. The old implementation was inaccurate, returning more records than you wanted. This feature was deprecated in 7.1.0 and removed in 8.0.0. The json and jsonb datatypes are still supported. See the discussion at https://github.com/paper-trail-gem/paper_trail/issues/803 STR end # Raises an exception as this operation is not allowed from text columns. def where_object_changes_from_condition(*) raise <<-STR.squish.freeze where_object_changes_from does not support reading JSON from a text column. The json and jsonb datatypes are supported. STR end end end end paper_trail-12.0.0/lib/paper_trail/serializers/yaml.rb000066400000000000000000000036141403037104100227640ustar00rootroot00000000000000# frozen_string_literal: true require "yaml" module PaperTrail module Serializers # The default serializer for, e.g. `versions.object`. module YAML extend self # makes all instance methods become module methods as well def load(string) ::YAML.load string end # @param object (Hash | HashWithIndifferentAccess) - Coming from # `recordable_object` `object` will be a plain `Hash`. However, due to # recent [memory optimizations](https://git.io/fjeYv), when coming from # `recordable_object_changes`, it will be a `HashWithIndifferentAccess`. def dump(object) object = object.to_hash if object.is_a?(HashWithIndifferentAccess) ::YAML.dump object end # Returns a SQL LIKE condition to be used to match the given field and # value in the serialized object. def where_object_condition(arel_field, field, value) arel_field.matches("%\n#{field}: #{value}\n%") end # Returns a SQL LIKE condition to be used to match the given field and # value in the serialized `object_changes`. def where_object_changes_condition(*) raise <<-STR.squish.freeze where_object_changes no longer supports reading YAML from a text column. The old implementation was inaccurate, returning more records than you wanted. This feature was deprecated in 8.1.0 and removed in 9.0.0. The json and jsonb datatypes are still supported. See discussion at https://github.com/paper-trail-gem/paper_trail/pull/997 STR end # Raises an exception as this operation is not allowed with YAML. def where_object_changes_from_condition(*) raise <<-STR.squish.freeze where_object_changes_from does not support reading YAML from a text column. The json and jsonb datatypes are supported. STR end end end end paper_trail-12.0.0/lib/paper_trail/type_serializers/000077500000000000000000000000001403037104100225325ustar00rootroot00000000000000paper_trail-12.0.0/lib/paper_trail/type_serializers/postgres_array_serializer.rb000066400000000000000000000015261403037104100303600ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail module TypeSerializers # Provides an alternative method of serialization # and deserialization of PostgreSQL array columns. class PostgresArraySerializer def initialize(subtype, delimiter) @subtype = subtype @delimiter = delimiter end def serialize(array) array end def deserialize(array) case array # Needed for legacy data. If serialized array is a string # then it was serialized with Rails < 5.0.2. when ::String then deserialize_with_ar(array) else array end end private def deserialize_with_ar(array) ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array. new(@subtype, @delimiter). deserialize(array) end end end end paper_trail-12.0.0/lib/paper_trail/version_concern.rb000066400000000000000000000277621403037104100226740ustar00rootroot00000000000000# frozen_string_literal: true require "paper_trail/attribute_serializers/object_changes_attribute" require "paper_trail/queries/versions/where_object" require "paper_trail/queries/versions/where_object_changes" require "paper_trail/queries/versions/where_object_changes_from" module PaperTrail # Originally, PaperTrail did not provide this module, and all of this # functionality was in `PaperTrail::Version`. That model still exists (and is # used by most apps) but by moving the functionality to this module, people # can include this concern instead of sub-classing the `Version` model. module VersionConcern extend ::ActiveSupport::Concern included do belongs_to :item, polymorphic: true, optional: true validates_presence_of :event after_create :enforce_version_limit! end # :nodoc: module ClassMethods def item_subtype_column_present? column_names.include?("item_subtype") end def with_item_keys(item_type, item_id) where item_type: item_type, item_id: item_id end def creates where event: "create" end def updates where event: "update" end def destroys where event: "destroy" end def not_creates where "event <> ?", "create" end def between(start_time, end_time) where( arel_table[:created_at].gt(start_time). and(arel_table[:created_at].lt(end_time)) ).order(timestamp_sort_order) end # Defaults to using the primary key as the secondary sort order if # possible. def timestamp_sort_order(direction = "asc") [arel_table[:created_at].send(direction.downcase)].tap do |array| array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int? end end # Given a hash of attributes like `name: 'Joan'`, query the # `versions.objects` column. # # ``` # SELECT "versions".* # FROM "versions" # WHERE ("versions"."object" LIKE '% # name: Joan # %') # ``` # # This is useful for finding versions where a given attribute had a given # value. Imagine, in the example above, that Joan had changed her name # and we wanted to find the versions before that change. # # Based on the data type of the `object` column, the appropriate SQL # operator is used. For example, a text column will use `like`, and a # jsonb column will use `@>`. # # @api public def where_object(args = {}) raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash) Queries::Versions::WhereObject.new(self, args).execute end # Given a hash of attributes like `name: 'Joan'`, query the # `versions.objects_changes` column. # # ``` # SELECT "versions".* # FROM "versions" # WHERE .. ("versions"."object_changes" LIKE '% # name: # - Joan # %' OR "versions"."object_changes" LIKE '% # name: # -% # - Joan # %') # ``` # # This is useful for finding versions immediately before and after a given # attribute had a given value. Imagine, in the example above, that someone # changed their name to Joan and we wanted to find the versions # immediately before and after that change. # # Based on the data type of the `object` column, the appropriate SQL # operator is used. For example, a text column will use `like`, and a # jsonb column will use `@>`. # # @api public def where_object_changes(args = {}) raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash) Queries::Versions::WhereObjectChanges.new(self, args).execute end # Given a hash of attributes like `name: 'Joan'`, query the # `versions.objects_changes` column for changes where the version changed # from the hash of attributes to other values. # # This is useful for finding versions where the attribute started with a # known value and changed to something else. This is in comparison to # `where_object_changes` which will find both the changes before and # after. # # @api public def where_object_changes_from(args = {}) raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash) Queries::Versions::WhereObjectChangesFrom.new(self, args).execute end def primary_key_is_int? @primary_key_is_int ||= columns_hash[primary_key].type == :integer rescue StandardError # TODO: Rescue something more specific true end # Returns whether the `object` column is using the `json` type supported # by PostgreSQL. def object_col_is_json? %i[json jsonb].include?(columns_hash["object"].type) end # Returns whether the `object_changes` column is using the `json` type # supported by PostgreSQL. def object_changes_col_is_json? %i[json jsonb].include?(columns_hash["object_changes"].try(:type)) end # Returns versions before `obj`. # # @param obj - a `Version` or a timestamp # @param timestamp_arg - boolean - When true, `obj` is a timestamp. # Default: false. # @return `ActiveRecord::Relation` # @api public # rubocop:disable Style/OptionalBooleanParameter def preceding(obj, timestamp_arg = false) if timestamp_arg != true && primary_key_is_int? preceding_by_id(obj) else preceding_by_timestamp(obj) end end # rubocop:enable Style/OptionalBooleanParameter # Returns versions after `obj`. # # @param obj - a `Version` or a timestamp # @param timestamp_arg - boolean - When true, `obj` is a timestamp. # Default: false. # @return `ActiveRecord::Relation` # @api public # rubocop:disable Style/OptionalBooleanParameter def subsequent(obj, timestamp_arg = false) if timestamp_arg != true && primary_key_is_int? subsequent_by_id(obj) else subsequent_by_timestamp(obj) end end # rubocop:enable Style/OptionalBooleanParameter private # @api private def preceding_by_id(obj) where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc) end # @api private def preceding_by_timestamp(obj) obj = obj.send(:created_at) if obj.is_a?(self) where(arel_table[:created_at].lt(obj)). order(timestamp_sort_order("desc")) end # @api private def subsequent_by_id(version) where(arel_table[primary_key].gt(version.id)).order(arel_table[primary_key].asc) end # @api private def subsequent_by_timestamp(obj) obj = obj.send(:created_at) if obj.is_a?(self) where(arel_table[:created_at].gt(obj)).order(timestamp_sort_order) end end # @api private def object_deserialized if self.class.object_col_is_json? object else PaperTrail.serializer.load(object) end end # Restore the item from this version. # # Options: # # - :mark_for_destruction # - `true` - Mark the has_one/has_many associations that did not exist in # the reified version for destruction, instead of removing them. # - `false` - Default. Useful for persisting the reified version. # - :dup # - `false` - Default. # - `true` - Always create a new object instance. Useful for # comparing two versions of the same object. # - :unversioned_attributes # - `:nil` - Default. Attributes undefined in version record are set to # nil in reified record. # - `:preserve` - Attributes undefined in version record are not modified. # def reify(options = {}) unless self.class.column_names.include? "object" raise "reify can't be called without an object column" end return nil if object.nil? ::PaperTrail::Reifier.reify(self, options) end # Returns what changed in this version of the item. # `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does # not have an `object_changes` text column. def changeset return nil unless self.class.column_names.include? "object_changes" @changeset ||= load_changeset end # Returns who put the item into the state stored in this version. def paper_trail_originator @paper_trail_originator ||= previous.try(:whodunnit) end # Returns who changed the item from the state it had in this version. This # is an alias for `whodunnit`. def terminator @terminator ||= whodunnit end alias version_author terminator def next @next ||= sibling_versions.subsequent(self).first end def previous @previous ||= sibling_versions.preceding(self).first end # Returns an integer representing the chronological position of the # version among its siblings. The "create" event, for example, has an index # of 0. # # @api public def index @index ||= RecordHistory.new(sibling_versions, self.class).index(self) end private # @api private def load_changeset if PaperTrail.config.object_changes_adapter.respond_to?(:load_changeset) return PaperTrail.config.object_changes_adapter.load_changeset(self) end # First, deserialize the `object_changes` column. changes = HashWithIndifferentAccess.new(object_changes_deserialized) # The next step is, perhaps unfortunately, called "de-serialization", # and appears to be responsible for custom attribute serializers. For an # example of a custom attribute serializer, see # `Person::TimeZoneSerializer` in the test suite. # # Is `item.class` good enough? Does it handle `inheritance_column` # as well as `Reifier#version_reification_class`? We were using # `item_type.constantize`, but that is problematic when the STI parent # is not versioned. (See `Vehicle` and `Car` in the test suite). # # Note: `item` returns nil if `event` is "destroy". unless item.nil? AttributeSerializers::ObjectChangesAttribute. new(item.class). deserialize(changes) end # Finally, return a Hash mapping each attribute name to # a two-element array representing before and after. changes end # If the `object_changes` column is a Postgres JSON column, then # ActiveRecord will deserialize it for us. Otherwise, it's a string column # and we must deserialize it ourselves. # @api private def object_changes_deserialized if self.class.object_changes_col_is_json? object_changes else begin PaperTrail.serializer.load(object_changes) rescue StandardError # TODO: Rescue something more specific {} end end end # Enforces the `version_limit`, if set. Default: no limit. # @api private def enforce_version_limit! limit = version_limit return unless limit.is_a? Numeric previous_versions = sibling_versions.not_creates. order(self.class.timestamp_sort_order("asc")) return unless previous_versions.size > limit excess_versions = previous_versions - previous_versions.last(limit) excess_versions.map(&:destroy) end # @api private def sibling_versions @sibling_versions ||= self.class.with_item_keys(item_type, item_id) end # See docs section 2.e. Limiting the Number of Versions Created. # The version limit can be global or per-model. # # @api private # # TODO: Duplication: similar `constantize` in Reifier#version_reification_class def version_limit if self.class.item_subtype_column_present? klass = (item_subtype || item_type).constantize if klass&.paper_trail_options&.key?(:limit) return klass.paper_trail_options[:limit] end end PaperTrail.config.version_limit end end end paper_trail-12.0.0/lib/paper_trail/version_number.rb000066400000000000000000000012271403037104100225210ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail # The version number of the paper_trail gem. Not to be confused with # `PaperTrail::Version`. Ruby constants are case-sensitive, apparently, # and they are two different modules! It would be nice to remove `VERSION`, # because of this confusion, but it's not worth the breaking change. # People are encouraged to use `PaperTrail.gem_version` instead. module VERSION MAJOR = 12 MINOR = 0 TINY = 0 # Set PRE to nil unless it's a pre-release (beta, rc, etc.) PRE = nil STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".").freeze def self.to_s STRING end end end paper_trail-12.0.0/paper_trail.gemspec000066400000000000000000000072111403037104100177350ustar00rootroot00000000000000# frozen_string_literal: true $LOAD_PATH.unshift File.expand_path("lib", __dir__) require "paper_trail/compatibility" require "paper_trail/version_number" Gem::Specification.new do |s| s.name = "paper_trail" s.version = PaperTrail::VERSION::STRING s.platform = Gem::Platform::RUBY s.summary = "Track changes to your models." s.description = <<-EOS Track changes to your models, for auditing or versioning. See how a model looked at any stage in its lifecycle, revert it to any version, or restore it after it has been destroyed. EOS s.homepage = "https://github.com/paper-trail-gem/paper_trail" s.authors = ["Andy Stewart", "Ben Atkins", "Jared Beck"] s.email = "jared@jaredbeck.com" s.license = "MIT" # > Files included in this gem. .. Only add files you can require to this # > list, not directories, etc. # > https://guides.rubygems.org/specification-reference/#files # # > Avoid using `git ls-files` to produce lists of files. Downstreams (OS # > packagers) often need to build your package in an environment that does # > not have git (on purpose). # > https://packaging.rubystyle.guide/#using-git-in-gemspec # # By convention, the `.gemspec` is omitted. Tests and related files (like # `Gemfile`) are omitted. Documentation is omitted because it would double # gem size. See discussion: # https://github.com/paper-trail-gem/paper_trail/pull/1279#pullrequestreview-558840513 s.files = Dir["lib/**/*", "LICENSE"].reject { |f| File.directory?(f) } s.executables = [] s.require_paths = ["lib"] s.required_rubygems_version = ">= 1.3.6" # PT supports ruby versions until they reach End-of-Life, historically # about 3 years, per https://www.ruby-lang.org/en/downloads/branches/ # # See "Lowest supported ruby version" in CONTRIBUTING.md # # Ruby 2.5 reaches EoL on 2021-03-31. s.required_ruby_version = ">= 2.5.0" # We no longer specify a maximum activerecord version. # See discussion in paper_trail/compatibility.rb s.add_dependency "activerecord", ::PaperTrail::Compatibility::ACTIVERECORD_GTE s.add_dependency "request_store", "~> 1.1" s.add_development_dependency "appraisal", "~> 2.2" s.add_development_dependency "byebug", "~> 11.0" s.add_development_dependency "ffaker", "~> 2.11" s.add_development_dependency "generator_spec", "~> 0.9.4" s.add_development_dependency "memory_profiler", "~> 0.9.14" # For `spec/dummy_app`. Technically, we only need `actionpack` (as of 2020). # However, that might change in the future, and the advantages of specifying a # subset (e.g. actionpack only) are unclear. s.add_development_dependency "rails", ::PaperTrail::Compatibility::ACTIVERECORD_GTE s.add_development_dependency "rake", "~> 13.0" s.add_development_dependency "rspec-rails", "~> 4.0" s.add_development_dependency "rubocop", "~> 1.11.0" s.add_development_dependency "rubocop-rails", "~> 2.9.1" s.add_development_dependency "rubocop-packaging", "~> 0.5.1" s.add_development_dependency "rubocop-performance", "~> 1.10.1" s.add_development_dependency "rubocop-rspec", "~> 2.2.0" # ## Database Adapters # # The dependencies here must match the `gem` call at the top of their # adapters, eg. `active_record/connection_adapters/mysql2_adapter.rb`, # assuming said call is consistent for all versions of rails we test against # (see `Appraisals`). # # Currently, all versions of rails we test against are consistent. In the past, # when we tested against rails 4.2, we had to specify database adapters in # `Appraisals`. s.add_development_dependency "mysql2", "~> 0.5" s.add_development_dependency "pg", ">= 0.18", "< 2.0" s.add_development_dependency "sqlite3", "~> 1.4" end paper_trail-12.0.0/spec/000077500000000000000000000000001403037104100150175ustar00rootroot00000000000000paper_trail-12.0.0/spec/controllers/000077500000000000000000000000001403037104100173655ustar00rootroot00000000000000paper_trail-12.0.0/spec/controllers/articles_controller_spec.rb000066400000000000000000000017451403037104100250040ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe ArticlesController, type: :controller do describe "PaperTrail.request.enabled?" do context "PaperTrail.enabled? == true" do before { PaperTrail.enabled = true } after { PaperTrail.enabled = false } it "returns true" do expect(PaperTrail.enabled?).to eq(true) post :create, params: { article: { title: "Doh", content: FFaker::Lorem.sentence } } expect(assigns(:article)).not_to be_nil expect(PaperTrail.request.enabled?).to eq(true) expect(assigns(:article).versions.length).to eq(1) end end context "PaperTrail.enabled? == false" do it "returns false" do expect(PaperTrail.enabled?).to eq(false) post :create, params: { article: { title: "Doh", content: FFaker::Lorem.sentence } } expect(PaperTrail.request.enabled?).to eq(false) expect(assigns(:article).versions.length).to eq(0) end end end end paper_trail-12.0.0/spec/controllers/widgets_controller_spec.rb000066400000000000000000000065661403037104100246520ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe WidgetsController, type: :controller, versioning: true do before { request.env["REMOTE_ADDR"] = "127.0.0.1" } after { RequestStore.store[:paper_trail] = nil } describe "#create" do context "PT enabled" do it "stores information like IP address in version" do post(:create, params: { widget: { name: "Flugel" } }) widget = assigns(:widget) expect(widget.versions.length).to(eq(1)) expect(widget.versions.last.whodunnit.to_i).to(eq(153)) expect(widget.versions.last.ip).to(eq("127.0.0.1")) expect(widget.versions.last.user_agent).to(eq("Rails Testing")) end it "controller metadata methods should get evaluated" do request.env["HTTP_USER_AGENT"] = "User-Agent" post :create, params: { widget: { name: "Flugel" } } expect(PaperTrail.request.enabled?).to eq(true) expect(PaperTrail.request.whodunnit).to(eq(153)) expect(PaperTrail.request.controller_info.present?).to(eq(true)) expect(PaperTrail.request.controller_info.key?(:ip)).to(eq(true)) expect(PaperTrail.request.controller_info.key?(:user_agent)).to(eq(true)) end end context "PT disabled" do it "does not save a version, and metadata is not set" do request.env["HTTP_USER_AGENT"] = "Disable User-Agent" post :create, params: { widget: { name: "Flugel" } } expect(assigns(:widget).versions.length).to(eq(0)) expect(PaperTrail.request.enabled?).to eq(false) expect(PaperTrail.request.whodunnit).to be_nil expect(PaperTrail.request.controller_info).to eq({}) end end end describe "#destroy" do it "can be disabled" do request.env["HTTP_USER_AGENT"] = "Disable User-Agent" post(:create, params: { widget: { name: "Flugel" } }) w = assigns(:widget) expect(w.versions.length).to(eq(0)) delete(:destroy, params: { id: w.id }) expect(PaperTrail::Version.with_item_keys("Widget", w.id).size).to(eq(0)) end it "stores information like IP address in version" do w = Widget.create(name: "Roundel") expect(w.versions.length).to(eq(1)) delete(:destroy, params: { id: w.id }) widget = assigns(:widget) expect(widget.versions.length).to(eq(2)) expect(widget.versions.last.ip).to(eq("127.0.0.1")) expect(widget.versions.last.user_agent).to(eq("Rails Testing")) expect(widget.versions.last.whodunnit.to_i).to(eq(153)) end end describe "#update" do it "stores information like IP address in version" do w = Widget.create(name: "Duvel") expect(w.versions.length).to(eq(1)) put(:update, params: { id: w.id, widget: { name: "Bugle" } }) widget = assigns(:widget) expect(widget.versions.length).to(eq(2)) expect(widget.versions.last.whodunnit.to_i).to(eq(153)) expect(widget.versions.last.ip).to(eq("127.0.0.1")) expect(widget.versions.last.user_agent).to(eq("Rails Testing")) end it "can be disabled" do request.env["HTTP_USER_AGENT"] = "Disable User-Agent" post(:create, params: { widget: { name: "Flugel" } }) w = assigns(:widget) expect(w.versions.length).to(eq(0)) put(:update, params: { id: w.id, widget: { name: "Bugle" } }) widget = assigns(:widget) expect(widget.versions.length).to(eq(0)) end end end paper_trail-12.0.0/spec/dummy_app/000077500000000000000000000000001403037104100170125ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/Rakefile000066400000000000000000000004441403037104100204610ustar00rootroot00000000000000# frozen_string_literal: true # Add your own tasks in files placed in lib/tasks ending in .rake, # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. require File.expand_path("config/application", __dir__) require "rake" Dummy::Application.load_tasks paper_trail-12.0.0/spec/dummy_app/app/000077500000000000000000000000001403037104100175725ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/controllers/000077500000000000000000000000001403037104100221405ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/controllers/application_controller.rb000066400000000000000000000016201403037104100272320ustar00rootroot00000000000000# frozen_string_literal: true class ApplicationController < ActionController::Base protect_from_forgery # Some applications and libraries modify `current_user`. Their changes need # to be reflected in `whodunnit`, so the `set_paper_trail_whodunnit` below # must happen after this. before_action :modify_current_user # PT used to add this callback automatically. Now people are required to add # it themsevles, like this, allowing them to control the order of callbacks. # The `modify_current_user` callback above shows why this control is useful. before_action :set_paper_trail_whodunnit def rescue_action(e) raise e end # Returns id of hypothetical current user attr_reader :current_user def info_for_paper_trail { ip: request.remote_ip, user_agent: request.user_agent } end private def modify_current_user @current_user = OpenStruct.new(id: 153) end end paper_trail-12.0.0/spec/dummy_app/app/controllers/articles_controller.rb000066400000000000000000000004241403037104100265360ustar00rootroot00000000000000# frozen_string_literal: true class ArticlesController < ApplicationController def create @article = Article.create article_params head :ok end def current_user "foobar" end private def article_params params.require(:article).permit! end end paper_trail-12.0.0/spec/dummy_app/app/controllers/test_controller.rb000066400000000000000000000002171403037104100257070ustar00rootroot00000000000000# frozen_string_literal: true class TestController < ActionController::Base def user_for_paper_trail Thread.current.object_id end end paper_trail-12.0.0/spec/dummy_app/app/controllers/widgets_controller.rb000066400000000000000000000010021403037104100263670ustar00rootroot00000000000000# frozen_string_literal: true class WidgetsController < ApplicationController def paper_trail_enabled_for_controller request.user_agent != "Disable User-Agent" end def create @widget = Widget.create widget_params head :ok end def update @widget = Widget.find params[:id] @widget.update widget_params head :ok end def destroy @widget = Widget.find params[:id] @widget.destroy head :ok end private def widget_params params[:widget].permit! end end paper_trail-12.0.0/spec/dummy_app/app/models/000077500000000000000000000000001403037104100210555ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/models/animal.rb000066400000000000000000000001751403037104100226460ustar00rootroot00000000000000# frozen_string_literal: true class Animal < ActiveRecord::Base has_paper_trail self.inheritance_column = "species" end paper_trail-12.0.0/spec/dummy_app/app/models/article.rb000066400000000000000000000012461403037104100230300ustar00rootroot00000000000000# frozen_string_literal: true # Demonstrates the `only` and `ignore` attributes, among other things. class Article < ActiveRecord::Base has_paper_trail( ignore: [ :title, { abstract: proc { |obj| ["ignore abstract", "Other abstract"].include? obj.abstract } } ], only: [:content, { abstract: proc { |obj| obj.abstract.present? } }], skip: [:file_upload], meta: { answer: 42, action: :action_data_provider_method, question: proc { "31 + 11 = #{31 + 11}" }, article_id: proc { |article| article.id }, title: :title } ) def action_data_provider_method object_id.to_s end end paper_trail-12.0.0/spec/dummy_app/app/models/authorship.rb000066400000000000000000000002311403037104100235640ustar00rootroot00000000000000# frozen_string_literal: true class Authorship < ActiveRecord::Base belongs_to :book belongs_to :author, class_name: "Person" has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/bar_habtm.rb000066400000000000000000000001771403037104100233260ustar00rootroot00000000000000# frozen_string_literal: true class BarHabtm < ActiveRecord::Base has_and_belongs_to_many :foo_habtms has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/bicycle.rb000066400000000000000000000001151403037104100230110ustar00rootroot00000000000000# frozen_string_literal: true class Bicycle < Vehicle has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/book.rb000066400000000000000000000004071403037104100223350ustar00rootroot00000000000000# frozen_string_literal: true class Book < ActiveRecord::Base has_many :authorships, dependent: :destroy has_many :authors, through: :authorships has_many :editorships, dependent: :destroy has_many :editors, through: :editorships has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/boolit.rb000066400000000000000000000001771403037104100226770ustar00rootroot00000000000000# frozen_string_literal: true class Boolit < ActiveRecord::Base default_scope { where(scoped: true) } has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/callback_modifier.rb000066400000000000000000000016351403037104100250210ustar00rootroot00000000000000# frozen_string_literal: true class CallbackModifier < ActiveRecord::Base has_paper_trail on: [] def test_destroy transaction do run_callbacks(:destroy) do self.deleted = true save! end end end def flagged_deleted? deleted? end end class BeforeDestroyModifier < CallbackModifier has_paper_trail on: [] paper_trail.on_destroy :before end unless ActiveRecord::Base.belongs_to_required_by_default class AfterDestroyModifier < CallbackModifier has_paper_trail on: [] paper_trail.on_destroy :after end end class NoArgDestroyModifier < CallbackModifier has_paper_trail on: [] paper_trail.on_destroy end class UpdateModifier < CallbackModifier has_paper_trail on: [] paper_trail.on_update end class CreateModifier < CallbackModifier has_paper_trail on: [] paper_trail.on_create end class DefaultModifier < CallbackModifier has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/car.rb000066400000000000000000000001111403037104100221400ustar00rootroot00000000000000# frozen_string_literal: true class Car < Vehicle has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/cat.rb000066400000000000000000000000661403037104100221530ustar00rootroot00000000000000# frozen_string_literal: true class Cat < Animal end paper_trail-12.0.0/spec/dummy_app/app/models/chapter.rb000066400000000000000000000004071403037104100230310ustar00rootroot00000000000000# frozen_string_literal: true class Chapter < ActiveRecord::Base has_many :sections, dependent: :destroy has_many :paragraphs, through: :sections has_many :quotations, dependent: :destroy has_many :citations, through: :quotations has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/citation.rb000066400000000000000000000001621403037104100232130ustar00rootroot00000000000000# frozen_string_literal: true class Citation < ActiveRecord::Base belongs_to :quotation has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/concerns/000077500000000000000000000000001403037104100226675ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/models/concerns/prefix_versions_inspect_with_count.rb000066400000000000000000000002071403037104100324300ustar00rootroot00000000000000# frozen_string_literal: true module PrefixVersionsInspectWithCount def inspect "#{length} versions:\n" + super end end paper_trail-12.0.0/spec/dummy_app/app/models/custom_primary_key_record.rb000066400000000000000000000007441403037104100266720ustar00rootroot00000000000000# frozen_string_literal: true require "securerandom" class CustomPrimaryKeyRecord < ActiveRecord::Base self.primary_key = :uuid has_paper_trail versions: { class_name: "CustomPrimaryKeyRecordVersion" } # This default_scope is to test the case of the Version#item association # not returning the item due to unmatched default_scope on the model. default_scope { where(name: "custom_primary_key_record") } before_create do self.uuid ||= SecureRandom.uuid end end paper_trail-12.0.0/spec/dummy_app/app/models/customer.rb000066400000000000000000000002011403037104100232340ustar00rootroot00000000000000# frozen_string_literal: true class Customer < ActiveRecord::Base has_many :orders, dependent: :destroy has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/document.rb000066400000000000000000000004721403037104100232230ustar00rootroot00000000000000# frozen_string_literal: true # Demonstrates a "custom versions association name". Instead of the association # being named `versions`, it will be named `paper_trail_versions`. class Document < ActiveRecord::Base has_paper_trail( versions: { name: :paper_trail_versions }, on: %i[create update] ) end paper_trail-12.0.0/spec/dummy_app/app/models/dog.rb000066400000000000000000000000661403037104100221550ustar00rootroot00000000000000# frozen_string_literal: true class Dog < Animal end paper_trail-12.0.0/spec/dummy_app/app/models/editor.rb000066400000000000000000000003041403037104100226650ustar00rootroot00000000000000# frozen_string_literal: true # to demonstrate a has_through association that does not have paper_trail enabled class Editor < ActiveRecord::Base has_many :editorships, dependent: :destroy end paper_trail-12.0.0/spec/dummy_app/app/models/editorship.rb000066400000000000000000000002031403037104100235470ustar00rootroot00000000000000# frozen_string_literal: true class Editorship < ActiveRecord::Base belongs_to :book belongs_to :editor has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/elephant.rb000066400000000000000000000014241403037104100232030ustar00rootroot00000000000000# frozen_string_literal: true class Elephant < Animal end # Nice! We used to have `paper_trail.disable` inside the class, which was really # misleading because it looked like a permanent, global setting. It's so much # more obvious now that we are disabling the model for this request only. Of # course, we run the PT unit tests in a single thread, and I think this setting # will affect multiple unit tests, but in a normal application, this new API is # a huge improvement. # # TODO: If this call to `disable_model` were moved to the unit tests, this file # would be more like normal application code. It'd be pretty strange for someone # to do this in app code, especially now that it is obvious that it only affects # the current request. PaperTrail.request.disable_model(Elephant) paper_trail-12.0.0/spec/dummy_app/app/models/family/000077500000000000000000000000001403037104100223365ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/models/family/celebrity_family.rb000066400000000000000000000001421403037104100262030ustar00rootroot00000000000000# frozen_string_literal: true module Family class CelebrityFamily < ::Family::Family end end paper_trail-12.0.0/spec/dummy_app/app/models/family/family.rb000066400000000000000000000012601403037104100241430ustar00rootroot00000000000000# frozen_string_literal: true module Family class Family < ActiveRecord::Base has_paper_trail has_many :familie_lines, class_name: "::Family::FamilyLine", foreign_key: :parent_id has_many :children, class_name: "::Family::Family", foreign_key: :parent_id has_many :grandsons, through: :familie_lines has_one :mentee, class_name: "::Family::Family", foreign_key: :partner_id belongs_to :parent, class_name: "::Family::Family", foreign_key: :parent_id, optional: true belongs_to :mentor, class_name: "::Family::Family", foreign_key: :partner_id, optional: true accepts_nested_attributes_for :mentee accepts_nested_attributes_for :children end end paper_trail-12.0.0/spec/dummy_app/app/models/family/family_line.rb000066400000000000000000000005331403037104100251540ustar00rootroot00000000000000# frozen_string_literal: true module Family class FamilyLine < ActiveRecord::Base has_paper_trail belongs_to :parent, class_name: "::Family::Family", foreign_key: :parent_id, optional: true belongs_to :grandson, class_name: "::Family::Family", foreign_key: :grandson_id, optional: true end end paper_trail-12.0.0/spec/dummy_app/app/models/fluxor.rb000066400000000000000000000001521403037104100227170ustar00rootroot00000000000000# frozen_string_literal: true class Fluxor < ActiveRecord::Base belongs_to :widget, optional: true end paper_trail-12.0.0/spec/dummy_app/app/models/foo_habtm.rb000066400000000000000000000002531403037104100233400ustar00rootroot00000000000000# frozen_string_literal: true class FooHabtm < ActiveRecord::Base has_and_belongs_to_many :bar_habtms accepts_nested_attributes_for :bar_habtms has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/foo_widget.rb000066400000000000000000000000741403037104100235310ustar00rootroot00000000000000# frozen_string_literal: true class FooWidget < Widget end paper_trail-12.0.0/spec/dummy_app/app/models/fruit.rb000066400000000000000000000003001403037104100225240ustar00rootroot00000000000000# frozen_string_literal: true class Fruit < ActiveRecord::Base if ENV["DB"] == "postgres" || JsonVersion.table_exists? has_paper_trail versions: { class_name: "JsonVersion" } end end paper_trail-12.0.0/spec/dummy_app/app/models/gadget.rb000066400000000000000000000002311403037104100226310ustar00rootroot00000000000000# frozen_string_literal: true class Gadget < ActiveRecord::Base has_paper_trail ignore: [:brand, { color: proc { |obj| obj.color == "Yellow" } }] end paper_trail-12.0.0/spec/dummy_app/app/models/kitchen/000077500000000000000000000000001403037104100225025ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/models/kitchen/banana.rb000066400000000000000000000002431403037104100242460ustar00rootroot00000000000000# frozen_string_literal: true module Kitchen class Banana < ActiveRecord::Base has_paper_trail versions: { class_name: "Kitchen::BananaVersion" } end end paper_trail-12.0.0/spec/dummy_app/app/models/legacy_widget.rb000066400000000000000000000004641403037104100242150ustar00rootroot00000000000000# frozen_string_literal: true # The `legacy_widgets` table has a `version` column that would conflict with our # `version` method. It is configured to define a method named `custom_version` # instead. class LegacyWidget < ActiveRecord::Base has_paper_trail ignore: :version, version: "custom_version" end paper_trail-12.0.0/spec/dummy_app/app/models/limited_bicycle.rb000066400000000000000000000001351403037104100245220ustar00rootroot00000000000000# frozen_string_literal: true class LimitedBicycle < Vehicle has_paper_trail limit: 3 end paper_trail-12.0.0/spec/dummy_app/app/models/line_item.rb000066400000000000000000000002021403037104100233410ustar00rootroot00000000000000# frozen_string_literal: true class LineItem < ActiveRecord::Base belongs_to :order, dependent: :destroy has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/management.rb000066400000000000000000000003441403037104100235170ustar00rootroot00000000000000# frozen_string_literal: true # Note that there is no `type` column for this subclassed model, so changes to # Management objects should result in Versions which have an item_type of # Customer. class Management < Customer end paper_trail-12.0.0/spec/dummy_app/app/models/no_object.rb000066400000000000000000000004311403037104100233420ustar00rootroot00000000000000# frozen_string_literal: true # Demonstrates a table that omits the `object` column. class NoObject < ActiveRecord::Base has_paper_trail( versions: { class_name: "NoObjectVersion" }, meta: { metadatum: 42 } ) validates :letter, length: { is: 1 }, presence: true end paper_trail-12.0.0/spec/dummy_app/app/models/not_on_update.rb000066400000000000000000000002471403037104100242430ustar00rootroot00000000000000# frozen_string_literal: true # This model does not record versions when updated. class NotOnUpdate < ActiveRecord::Base has_paper_trail on: %i[create destroy] end paper_trail-12.0.0/spec/dummy_app/app/models/on/000077500000000000000000000000001403037104100214715ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/models/on/create.rb000066400000000000000000000002321403037104100232560ustar00rootroot00000000000000# frozen_string_literal: true module On class Create < ActiveRecord::Base self.table_name = :on_create has_paper_trail on: [:create] end end paper_trail-12.0.0/spec/dummy_app/app/models/on/destroy.rb000066400000000000000000000002351403037104100235070ustar00rootroot00000000000000# frozen_string_literal: true module On class Destroy < ActiveRecord::Base self.table_name = :on_destroy has_paper_trail on: [:destroy] end end paper_trail-12.0.0/spec/dummy_app/app/models/on/empty_array.rb000066400000000000000000000002341403037104100243510ustar00rootroot00000000000000# frozen_string_literal: true module On class EmptyArray < ActiveRecord::Base self.table_name = :on_empty_array has_paper_trail on: [] end end paper_trail-12.0.0/spec/dummy_app/app/models/on/touch.rb000066400000000000000000000002271403037104100231410ustar00rootroot00000000000000# frozen_string_literal: true module On class Touch < ActiveRecord::Base self.table_name = :on_touch has_paper_trail on: [:touch] end end paper_trail-12.0.0/spec/dummy_app/app/models/on/update.rb000066400000000000000000000002321403037104100232750ustar00rootroot00000000000000# frozen_string_literal: true module On class Update < ActiveRecord::Base self.table_name = :on_update has_paper_trail on: [:update] end end paper_trail-12.0.0/spec/dummy_app/app/models/order.rb000066400000000000000000000002041403037104100225110ustar00rootroot00000000000000# frozen_string_literal: true class Order < ActiveRecord::Base belongs_to :customer has_many :line_items has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/paragraph.rb000066400000000000000000000001611403037104100233450ustar00rootroot00000000000000# frozen_string_literal: true class Paragraph < ActiveRecord::Base belongs_to :section has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/person.rb000066400000000000000000000023671403037104100227200ustar00rootroot00000000000000# frozen_string_literal: true class Person < ActiveRecord::Base has_many :authorships, foreign_key: :author_id, dependent: :destroy has_many :books, through: :authorships has_many :pets, foreign_key: :owner_id, dependent: :destroy has_many :animals, through: :pets has_many :dogs, class_name: "Dog", through: :pets, source: :animal has_many :cats, class_name: "Cat", through: :pets, source: :animal has_one :car, foreign_key: :owner_id has_one :bicycle, foreign_key: :owner_id has_one :thing belongs_to :mentor, class_name: "Person", foreign_key: :mentor_id, optional: true has_paper_trail # Convert strings to TimeZone objects when assigned def time_zone=(value) if value.is_a? ActiveSupport::TimeZone super else zone = ::Time.find_zone(value) # nil if can't find time zone super zone end end # Store TimeZone objects as strings when serialized to database class TimeZoneSerializer class << self def dump(zone) zone.try(:name) end def load(value) ::Time.find_zone(value) end end def dump(zone) self.class.dump(zone) end def load(value) self.class.load(value) end end serialize :time_zone, TimeZoneSerializer.new end paper_trail-12.0.0/spec/dummy_app/app/models/pet.rb000066400000000000000000000002631403037104100221730ustar00rootroot00000000000000# frozen_string_literal: true class Pet < ActiveRecord::Base belongs_to :owner, class_name: "Person", optional: true belongs_to :animal, optional: true has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/post.rb000066400000000000000000000001751403037104100223720ustar00rootroot00000000000000# frozen_string_literal: true class Post < ActiveRecord::Base has_paper_trail versions: { class_name: "PostVersion" } end paper_trail-12.0.0/spec/dummy_app/app/models/post_with_status.rb000066400000000000000000000004471403037104100250320ustar00rootroot00000000000000# frozen_string_literal: true # This model tests ActiveRecord::Enum, which was added in AR 4.1 # http://edgeguides.rubyonrails.org/4_1_release_notes.html#active-record-enums class PostWithStatus < ActiveRecord::Base has_paper_trail enum status: { draft: 0, published: 1, archived: 2 } end paper_trail-12.0.0/spec/dummy_app/app/models/postgres_user.rb000066400000000000000000000001351403037104100243050ustar00rootroot00000000000000# frozen_string_literal: true class PostgresUser < ActiveRecord::Base has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/quotation.rb000066400000000000000000000002331403037104100234230ustar00rootroot00000000000000# frozen_string_literal: true class Quotation < ActiveRecord::Base belongs_to :chapter has_many :citations, dependent: :destroy has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/section.rb000066400000000000000000000002331403037104100230440ustar00rootroot00000000000000# frozen_string_literal: true class Section < ActiveRecord::Base belongs_to :chapter has_many :paragraphs, dependent: :destroy has_paper_trail end paper_trail-12.0.0/spec/dummy_app/app/models/skipper.rb000066400000000000000000000002121403037104100230520ustar00rootroot00000000000000# frozen_string_literal: true class Skipper < ActiveRecord::Base has_paper_trail ignore: [:created_at], skip: [:another_timestamp] end paper_trail-12.0.0/spec/dummy_app/app/models/song.rb000066400000000000000000000004611403037104100223510ustar00rootroot00000000000000# frozen_string_literal: true class Song < ActiveRecord::Base has_paper_trail attribute :name, :string # Uses an integer of seconds to hold the length of the song def length=(minutes) write_attribute(:length, minutes.to_i * 60) end def length read_attribute(:length) / 60 end end paper_trail-12.0.0/spec/dummy_app/app/models/thing.rb000066400000000000000000000003321403037104100225110ustar00rootroot00000000000000# frozen_string_literal: true class Thing < ActiveRecord::Base has_paper_trail versions: { scope: -> { order("id desc") }, extend: PrefixVersionsInspectWithCount } belongs_to :person, optional: true end paper_trail-12.0.0/spec/dummy_app/app/models/translation.rb000066400000000000000000000005431403037104100237420ustar00rootroot00000000000000# frozen_string_literal: true # Demonstrates the `if` and `unless` configuration options. class Translation < ActiveRecord::Base # Has a `type` column, but it's not used for STI. # TODO: rename column self.inheritance_column = nil has_paper_trail( if: proc { |t| t.language_code == "US" }, unless: proc { |t| t.type == "DRAFT" } ) end paper_trail-12.0.0/spec/dummy_app/app/models/truck.rb000066400000000000000000000003151403037104100225310ustar00rootroot00000000000000# frozen_string_literal: true class Truck < Vehicle # This STI child class specifically does not call `has_paper_trail`. # Of the sub-classes of `Vehicle`, only `Car` and `Bicycle` are versioned. end paper_trail-12.0.0/spec/dummy_app/app/models/unlimited_bicycle.rb000066400000000000000000000001411403037104100250620ustar00rootroot00000000000000# frozen_string_literal: true class UnlimitedBicycle < Vehicle has_paper_trail limit: nil end paper_trail-12.0.0/spec/dummy_app/app/models/vehicle.rb000066400000000000000000000004101403037104100230140ustar00rootroot00000000000000# frozen_string_literal: true class Vehicle < ActiveRecord::Base # This STI parent class specifically does not call `has_paper_trail`. # Of its sub-classes, only `Car` and `Bicycle` are versioned. belongs_to :owner, class_name: "Person", optional: true end paper_trail-12.0.0/spec/dummy_app/app/models/whatchamajigger.rb000066400000000000000000000002271403037104100245300ustar00rootroot00000000000000# frozen_string_literal: true class Whatchamajigger < ActiveRecord::Base has_paper_trail belongs_to :owner, polymorphic: true, optional: true end paper_trail-12.0.0/spec/dummy_app/app/models/widget.rb000066400000000000000000000004151403037104100226650ustar00rootroot00000000000000# frozen_string_literal: true class Widget < ActiveRecord::Base EXCLUDED_NAME = "Biglet" has_paper_trail has_one :wotsit has_many(:fluxors, -> { order(:name) }) has_many :whatchamajiggers, as: :owner validates :name, exclusion: { in: [EXCLUDED_NAME] } end paper_trail-12.0.0/spec/dummy_app/app/models/wotsit.rb000066400000000000000000000002541403037104100227340ustar00rootroot00000000000000# frozen_string_literal: true class Wotsit < ActiveRecord::Base has_paper_trail belongs_to :widget, optional: true def created_on created_at.to_date end end paper_trail-12.0.0/spec/dummy_app/app/versions/000077500000000000000000000000001403037104100214425ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/versions/abstract_version.rb000066400000000000000000000002201403037104100253310ustar00rootroot00000000000000# frozen_string_literal: true class AbstractVersion < ActiveRecord::Base include PaperTrail::VersionConcern self.abstract_class = true end paper_trail-12.0.0/spec/dummy_app/app/versions/custom_primary_key_record_version.rb000066400000000000000000000002261403037104100310170ustar00rootroot00000000000000# frozen_string_literal: true class CustomPrimaryKeyRecordVersion < PaperTrail::Version self.table_name = "custom_primary_key_record_versions" end paper_trail-12.0.0/spec/dummy_app/app/versions/joined_version.rb000066400000000000000000000005461403037104100250110ustar00rootroot00000000000000# frozen_string_literal: true # The purpose of this custom version class is to test the scope methods on the # VersionConcern::ClassMethods module. See # https://github.com/paper-trail-gem/paper_trail/issues/295 for more details. class JoinedVersion < PaperTrail::Version default_scope { joins("INNER JOIN widgets ON widgets.id = versions.item_id") } end paper_trail-12.0.0/spec/dummy_app/app/versions/json_version.rb000066400000000000000000000001571403037104100245100ustar00rootroot00000000000000# frozen_string_literal: true class JsonVersion < PaperTrail::Version self.table_name = "json_versions" end paper_trail-12.0.0/spec/dummy_app/app/versions/kitchen/000077500000000000000000000000001403037104100230675ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/app/versions/kitchen/banana_version.rb000066400000000000000000000002141403037104100263760ustar00rootroot00000000000000# frozen_string_literal: true module Kitchen class BananaVersion < PaperTrail::Version self.table_name = "banana_versions" end end paper_trail-12.0.0/spec/dummy_app/app/versions/no_object_version.rb000066400000000000000000000002611403037104100254750ustar00rootroot00000000000000# frozen_string_literal: true # Demonstrates a table that omits the `object` column. class NoObjectVersion < ::PaperTrail::Version self.table_name = "no_object_versions" end paper_trail-12.0.0/spec/dummy_app/app/versions/post_version.rb000066400000000000000000000001571403037104100245240ustar00rootroot00000000000000# frozen_string_literal: true class PostVersion < PaperTrail::Version self.table_name = "post_versions" end paper_trail-12.0.0/spec/dummy_app/bin/000077500000000000000000000000001403037104100175625ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/bin/rails000077500000000000000000000002541403037104100206230ustar00rootroot00000000000000#!/usr/bin/env ruby # frozen_string_literal: true APP_PATH = File.expand_path("../config/application", __dir__) require_relative "../config/boot" require "rails/commands" paper_trail-12.0.0/spec/dummy_app/config.ru000066400000000000000000000002651403037104100206320ustar00rootroot00000000000000# frozen_string_literal: true # This file is used by Rack-based servers to start the application. require ::File.expand_path("config/environment", __dir__) run Dummy::Application paper_trail-12.0.0/spec/dummy_app/config/000077500000000000000000000000001403037104100202575ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/config/application.rb000066400000000000000000000022131403037104100231050ustar00rootroot00000000000000# frozen_string_literal: true require File.expand_path("boot", __dir__) # Here a conventional app would load the Rails components it needs, but we have # already loaded these in our spec_helper. # require "active_record/railtie" # require "action_controller/railtie" # Here a conventional app would require gems, but again, we have already loaded # these in our spec_helper. # Bundler.require(:default, Rails.env) module Dummy class Application < Rails::Application config.load_defaults(::Rails.gem_version.segments.take(2).join(".")) config.encoding = "utf-8" config.filter_parameters += [:password] config.active_support.escape_html_entities_in_json = true config.active_support.test_order = :sorted config.secret_key_base = "A fox regularly kicked the screaming pile of biscuits." # In rails >= 6.0, "`.represent_boolean_as_integer=` is now always true, # so setting this is deprecated and will be removed in Rails 6.1." if ::ENV["DB"] == "sqlite" && ::Gem::Requirement.new("~> 5.2").satisfied_by?(::Rails.gem_version) config.active_record.sqlite3.represent_boolean_as_integer = true end end end paper_trail-12.0.0/spec/dummy_app/config/boot.rb000066400000000000000000000001601403037104100215440ustar00rootroot00000000000000# frozen_string_literal: true # Unlike a conventional Rails app, our "dummy" app is booted by our spec_helper. paper_trail-12.0.0/spec/dummy_app/config/database.mysql.yml000066400000000000000000000005261403037104100237150ustar00rootroot00000000000000test: &test adapter: mysql2 encoding: utf8 database: <%= ENV.fetch('PT_CI_DATABASE', 'paper_trail') %>_test pool: 5 username: <%= ENV.fetch('PT_CI_DB_USER', 'root') %> host: <%= ENV.fetch('PT_CI_DB_HOST', 'localhost') %> port: <%= ENV.fetch('PT_CI_DB_PORT', 3306) %> protocol: TCP # password deliberately blank password: paper_trail-12.0.0/spec/dummy_app/config/database.postgres.yml000066400000000000000000000005141403037104100244130ustar00rootroot00000000000000test: &test adapter: postgresql database: <%= ENV.fetch('PT_CI_DATABASE', 'paper_trail') %>_test username: <%= ENV.fetch('PT_CI_DB_USER', 'postgres') %> password: <%= ENV.fetch('PT_CI_DB_PASSWORD', '') %> host: <%= ENV.fetch('PT_CI_DB_HOST', 'localhost') %> port: <%= ENV.fetch('PT_CI_DB_PORT', 5432) %> protocol: TCP paper_trail-12.0.0/spec/dummy_app/config/database.sqlite.yml000066400000000000000000000002471403037104100240510ustar00rootroot00000000000000# SQLite version 3.x # gem install sqlite3-ruby (not necessary on OS X Leopard) test: &test adapter: sqlite3 pool: 5 timeout: 5000 database: db/test.sqlite3 paper_trail-12.0.0/spec/dummy_app/config/environment.rb000066400000000000000000000002601403037104100231460ustar00rootroot00000000000000# frozen_string_literal: true # Load the rails application require File.expand_path("application", __dir__) # Initialize the rails application Dummy::Application.initialize! paper_trail-12.0.0/spec/dummy_app/config/environments/000077500000000000000000000000001403037104100230065ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/config/environments/development.rb000066400000000000000000000025261403037104100256620ustar00rootroot00000000000000# frozen_string_literal: true Dummy::Application.configure do # Settings specified here will take precedence over those in config/application.rb # In the development environment your application's code is reloaded on # every request. This slows down response time but is perfect for development # since you don't have to restart the web server when you make code changes. config.cache_classes = false # Do not eager load code on boot. config.eager_load = false # Show full error reports and disable caching config.consider_all_requests_local = true config.action_controller.perform_caching = false # Don't care if the mailer can't send # config.action_mailer.raise_delivery_errors = false # Print deprecation notices to the Rails logger config.active_support.deprecation = :log # Only use best-standards-support built into browsers config.action_dispatch.best_standards_support = :builtin # Log the query plan for queries taking more than this (works # with SQLite, MySQL, and PostgreSQL) # config.active_record.auto_explain_threshold_in_seconds = 0.5 # Do not compress assets config.assets.compress = false # Debug mode disables concatenation and preprocessing of assets. # This option may cause significant delays in view rendering with a large # number of complex assets. config.assets.debug = true end paper_trail-12.0.0/spec/dummy_app/config/environments/production.rb000066400000000000000000000053441403037104100255270ustar00rootroot00000000000000# frozen_string_literal: true Dummy::Application.configure do # Settings specified here will take precedence over those in config/application.rb # Code is not reloaded between requests config.cache_classes = true # Eager load code on boot. This eager loads most of Rails and # your application in memory, allowing both thread web servers # and those relying on copy on write to perform better. # Rake tasks automatically ignore this option for performance. config.eager_load = true # Full error reports are disabled and caching is turned on config.consider_all_requests_local = false config.action_controller.perform_caching = true # Disable Rails's static asset server (Apache or nginx will already do this) config.serve_static_assets = false # Compress JavaScripts and CSS config.assets.compress = true # Don't fallback to assets pipeline if a precompiled asset is missed config.assets.compile = false # Generate digests for assets URLs config.assets.digest = true # Defaults to nil and saved in location specified by config.assets.prefix # config.assets.manifest = YOUR_PATH # Specifies the header that your server uses for sending files # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. # config.force_ssl = true # See everything in the log (default is :info) # config.log_level = :debug # Prepend all log lines with the following tags # config.log_tags = [ :subdomain, :uuid ] # Use a different logger for distributed setups # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) # Use a different cache store in production # config.cache_store = :mem_cache_store # Enable serving of images, stylesheets, and JavaScripts from an asset server # config.action_controller.asset_host = "http://assets.example.com" # Precompile additional assets (application.js, application.css, and all # non-JS/CSS are already added) # config.assets.precompile += %w( search.js ) # Disable delivery errors, bad email addresses will be ignored # config.action_mailer.raise_delivery_errors = false # Enable threaded mode # config.threadsafe! # Enable locale fallbacks for I18n (makes lookups for any locale fall back to # the I18n.default_locale when a translation can not be found) config.i18n.fallbacks = true # Send deprecation notices to registered listeners config.active_support.deprecation = :notify # Log the query plan for queries taking more than this (works # with SQLite, MySQL, and PostgreSQL) # config.active_record.auto_explain_threshold_in_seconds = 0.5 end paper_trail-12.0.0/spec/dummy_app/config/environments/test.rb000066400000000000000000000036011403037104100243120ustar00rootroot00000000000000# frozen_string_literal: true Dummy::Application.configure do # Settings specified here will take precedence over those in config/application.rb # The test environment is used exclusively to run your application's # test suite. You never need to work with it otherwise. Remember that # your test database is "scratch space" for the test suite and is wiped # and recreated between test runs. Don't rely on the data there! config.cache_classes = true # In Rails 6.0, rails/rails@3b95478 made a change to eagerly define attribute # methods of a Model when `eager_load` is enabled. If we used `eager_load`, # this would break our test suite because of the way we run migration. For # example, `People.attribute_names` would return an empty array. config.eager_load = false if config.respond_to?(:public_file_server) config.public_file_server.enabled = true elsif config.respond_to?(:serve_static_files=) config.serve_static_files = true else config.serve_static_assets = true end if config.respond_to?(:public_file_server) config.public_file_server.headers = { "Cache-Control" => "public, max-age=3600" } else config.static_cache_control = "public, max-age=3600" end # Show full error reports and disable caching config.consider_all_requests_local = true config.action_controller.perform_caching = false # Raise exceptions instead of rendering exception templates config.action_dispatch.show_exceptions = false # Disable request forgery protection in test environment config.action_controller.allow_forgery_protection = false # Tell Action Mailer not to deliver emails to the real world. # The :test delivery method accumulates sent emails in the # ActionMailer::Base.deliveries array. # config.action_mailer.delivery_method = :test # Print deprecation notices to the stderr config.active_support.deprecation = :stderr end paper_trail-12.0.0/spec/dummy_app/config/initializers/000077500000000000000000000000001403037104100227655ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/config/initializers/backtrace_silencers.rb000066400000000000000000000006661403037104100273100ustar00rootroot00000000000000# frozen_string_literal: true # Be sure to restart your server when you modify this file. # You can add backtrace silencers for libraries that you're using but don't wish # to see in your backtraces. # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } # You can also remove all the silencers if you're trying to debug a problem that # might stem from framework code. # Rails.backtrace_cleaner.remove_silencers! paper_trail-12.0.0/spec/dummy_app/config/initializers/inflections.rb000066400000000000000000000006261403037104100256330ustar00rootroot00000000000000# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Add new inflection rules using the following format # (all these examples are active by default): # ActiveSupport::Inflector.inflections do |inflect| # inflect.plural /^(ox)$/i, '\1en' # inflect.singular /^(ox)en/i, '\1' # inflect.irregular 'person', 'people' # inflect.uncountable %w( fish sheep ) # end paper_trail-12.0.0/spec/dummy_app/config/initializers/mime_types.rb000066400000000000000000000003531403037104100254660ustar00rootroot00000000000000# frozen_string_literal: true # Be sure to restart your server when you modify this file. # Add new mime types for use in respond_to blocks: # Mime::Type.register "text/richtext", :rtf # Mime::Type.register_alias "text/html", :iphone paper_trail-12.0.0/spec/dummy_app/config/initializers/session_store.rb000066400000000000000000000006661403037104100262210ustar00rootroot00000000000000# frozen_string_literal: true # Be sure to restart your server when you modify this file. Dummy::Application.config.session_store :cookie_store, key: "_dummy_session" # Use the database for sessions instead of the cookie-based default, # which shouldn't be used to store highly confidential information # (create the session table with "rails generate session_migration") # Dummy::Application.config.session_store :active_record_store paper_trail-12.0.0/spec/dummy_app/config/locales/000077500000000000000000000000001403037104100217015ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/config/locales/en.yml000066400000000000000000000003251403037104100230260ustar00rootroot00000000000000# Sample localization file for English. Add more files in this directory for other locales. # See http://github.com/svenfuchs/rails-i18n/tree/master/rails%2Flocale for starting points. en: hello: "Hello world" paper_trail-12.0.0/spec/dummy_app/config/routes.rb000066400000000000000000000002421403037104100221230ustar00rootroot00000000000000# frozen_string_literal: true Dummy::Application.routes.draw do resources :articles, only: [:create] resources :widgets, only: %i[create update destroy] end paper_trail-12.0.0/spec/dummy_app/db/000077500000000000000000000000001403037104100173775ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/db/migrate/000077500000000000000000000000001403037104100210275ustar00rootroot00000000000000paper_trail-12.0.0/spec/dummy_app/db/migrate/20110208155312_set_up_test_tables.rb000066400000000000000000000236531403037104100267130ustar00rootroot00000000000000# frozen_string_literal: true # Parts of this migration must be kept in sync with # `lib/generators/paper_trail/templates/create_versions.rb` # # Starting with AR 5.1, we must specify which version of AR we are using. # I tried using `const_get` but I got a `NameError`, then I learned about # `::ActiveRecord::Migration::Current`. class SetUpTestTables < ::ActiveRecord::Migration::Current MYSQL_ADAPTERS = [ "ActiveRecord::ConnectionAdapters::MysqlAdapter", "ActiveRecord::ConnectionAdapters::Mysql2Adapter" ].freeze TEXT_BYTES = 1_073_741_823 def up create_table :on_create, force: true do |t| t.string :name, null: false end create_table :on_destroy, force: true do |t| t.string :name, null: false end create_table :on_empty_array, force: true do |t| t.string :name, null: false end create_table :on_touch, force: true do |t| t.string :name, null: false end create_table :on_update, force: true do |t| t.string :name, null: false end # Classes: Vehicle, Car, Truck create_table :vehicles, force: true do |t| t.string :name, null: false t.string :type, null: false t.integer :owner_id t.timestamps null: false, limit: 6 end create_table :skippers, force: true do |t| t.string :name t.datetime :another_timestamp, limit: 6 t.timestamps null: true, limit: 6 end create_table :widgets, force: true do |t| t.string :name t.text :a_text t.integer :an_integer t.float :a_float t.decimal :a_decimal, precision: 6, scale: 4 t.datetime :a_datetime, limit: 6 t.time :a_time t.date :a_date t.boolean :a_boolean t.string :type t.timestamps null: true, limit: 6 end if ENV["DB"] == "postgres" create_table :postgres_users, force: true do |t| t.string :name t.integer :post_ids, array: true t.datetime :login_times, array: true, limit: 6 t.timestamps null: true, limit: 6 end end create_table :versions, **versions_table_options do |t| t.string :item_type, **item_type_options(null: false) t.bigint :item_id, null: false t.string :item_subtype, **item_type_options(null: true) t.string :event, null: false t.string :whodunnit t.text :object, limit: TEXT_BYTES t.text :object_changes, limit: TEXT_BYTES t.integer :transaction_id t.datetime :created_at, limit: 6 # Metadata columns. t.integer :answer t.string :action t.string :question t.integer :article_id t.string :title # Controller info columns. t.string :ip t.string :user_agent end add_index :versions, %i[item_type item_id] create_table :post_versions, force: true do |t| t.string :item_type, null: false t.integer :item_id, null: false t.string :event, null: false t.string :whodunnit t.text :object t.datetime :created_at, limit: 6 # Controller info columns. t.string :ip t.string :user_agent end add_index :post_versions, %i[item_type item_id] # Uses custom versions table `no_object_versions`. create_table :no_objects, force: true do |t| t.string :letter, null: false, limit: 1 t.timestamps null: false, limit: 6 end # This table omits the `object` column. create_table :no_object_versions, force: true do |t| t.string :item_type, null: false t.integer :item_id, null: false t.string :event, null: false t.string :whodunnit t.datetime :created_at, limit: 6 t.text :object_changes, limit: TEXT_BYTES t.integer :metadatum end add_index :no_object_versions, %i[item_type item_id] if ENV["DB"] == "postgres" create_table :json_versions, force: true do |t| t.string :item_type, null: false t.integer :item_id, null: false t.string :event, null: false t.string :whodunnit t.json :object t.json :object_changes t.datetime :created_at, limit: 6 end add_index :json_versions, %i[item_type item_id] end create_table :not_on_updates, force: true do |t| t.timestamps null: true, limit: 6 end create_table :bananas, force: true do |t| t.timestamps null: true, limit: 6 end create_table :banana_versions, force: true do |t| t.string :item_type, null: false t.integer :item_id, null: false t.string :event, null: false t.string :whodunnit t.text :object t.datetime :created_at, limit: 6 end add_index :banana_versions, %i[item_type item_id] create_table :wotsits, force: true do |t| t.integer :widget_id t.string :name t.timestamps null: true, limit: 6 end create_table :fluxors, force: true do |t| t.integer :widget_id t.string :name end create_table :whatchamajiggers, force: true do |t| t.string :owner_type t.integer :owner_id t.string :name end create_table :articles, force: true do |t| t.string :title t.string :content t.string :abstract t.string :file_upload end create_table :books, force: true do |t| t.string :title end create_table :authorships, force: true do |t| t.integer :book_id t.integer :author_id end create_table :people, force: true do |t| t.string :name t.string :time_zone t.integer :mentor_id end create_table :editorships, force: true do |t| t.integer :book_id t.integer :editor_id end create_table :editors, force: true do |t| t.string :name end create_table :songs, force: true do |t| t.integer :length end create_table :posts, force: true do |t| t.string :title t.string :content end create_table :post_with_statuses, force: true do |t| t.integer :status t.timestamps null: false, limit: 6 end create_table :animals, force: true do |t| t.string :name t.string :species # single table inheritance column end create_table :pets, force: true do |t| t.integer :owner_id t.integer :animal_id end create_table :documents, force: true do |t| t.string :name end create_table :legacy_widgets, force: true do |t| t.string :name t.integer :version end create_table :things, force: true do |t| t.string :name t.references :person end create_table :translations, force: true do |t| t.string :headline t.string :content t.string :language_code t.string :type end create_table :gadgets, force: true do |t| t.string :name t.string :brand t.string :color t.timestamps null: true, limit: 6 end create_table :customers, force: true do |t| t.string :name end create_table :orders, force: true do |t| t.integer :customer_id t.string :order_date end create_table :line_items, force: true do |t| t.integer :order_id t.string :product end create_table :fruits, force: true do |t| t.string :name t.string :color end create_table :boolits, force: true do |t| t.string :name t.boolean :scoped, default: true end create_table :callback_modifiers, force: true do |t| t.string :some_content t.boolean :deleted, default: false end create_table :chapters, force: true do |t| t.string :name end create_table :sections, force: true do |t| t.integer :chapter_id t.string :name end create_table :paragraphs, force: true do |t| t.integer :section_id t.string :name end create_table :quotations, force: true do |t| t.integer :chapter_id end create_table :citations, force: true do |t| t.integer :quotation_id end create_table :foo_habtms, force: true do |t| t.string :name end create_table :bar_habtms, force: true do |t| t.string :name end create_table :bar_habtms_foo_habtms, force: true, id: false do |t| t.integer :foo_habtm_id t.integer :bar_habtm_id end add_index :bar_habtms_foo_habtms, [:foo_habtm_id] add_index :bar_habtms_foo_habtms, [:bar_habtm_id] # custom_primary_key_records use a uuid column (string) create_table :custom_primary_key_records, id: false, force: true do |t| t.column :uuid, :string, primary_key: true t.string :name t.timestamps null: true, limit: 6 end # and custom_primary_key_record_versions stores the uuid in item_id, a string create_table :custom_primary_key_record_versions, force: true do |t| t.string :item_type, null: false t.string :item_id, null: false t.string :event, null: false t.string :whodunnit t.text :object t.datetime :created_at, limit: 6 end add_index :custom_primary_key_record_versions, %i[item_type item_id], name: "idx_cust_pk_item" create_table :family_lines do |t| t.integer :parent_id t.integer :grandson_id end create_table :families do |t| t.string :name t.string :type # For STI support t.string :path_to_stardom # Only used for celebrity families t.integer :parent_id t.integer :partner_id end end def down # Not actually irreversible, but there is no need to maintain this method. raise ActiveRecord::IrreversibleMigration end private def item_type_options(null:) opt = { null: null } opt[:limit] = 191 if mysql? opt end def mysql? MYSQL_ADAPTERS.include?(connection.class.name) end def versions_table_options if mysql? { options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" } else {} end end end paper_trail-12.0.0/spec/generators/000077500000000000000000000000001403037104100171705ustar00rootroot00000000000000paper_trail-12.0.0/spec/generators/paper_trail/000077500000000000000000000000001403037104100214725ustar00rootroot00000000000000paper_trail-12.0.0/spec/generators/paper_trail/install_generator_spec.rb000066400000000000000000000061021403037104100265440ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require "generator_spec/test_case" require "generators/paper_trail/install/install_generator" RSpec.describe PaperTrail::InstallGenerator, type: :generator do include GeneratorSpec::TestCase destination File.expand_path("tmp", __dir__) after do prepare_destination # cleanup the tmp directory end describe "no options" do before do prepare_destination run_generator end it "generates a migration for creating the 'versions' table" do expected_parent_class = lambda { old_school = "ActiveRecord::Migration" ar_version = ActiveRecord::VERSION format("%s[%d.%d]", old_school, ar_version::MAJOR, ar_version::MINOR) }.call expected_create_table_options = lambda { if described_class::MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name) ', options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"' else "" end }.call expected_item_type_options = lambda { if described_class::MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name) ", { null: false, limit: 191 }" else ", { null: false }" end }.call expect(destination_root).to( have_structure { directory("db") { directory("migrate") { migration("create_versions") { contains("class CreateVersions < " + expected_parent_class) contains "def change" contains "create_table :versions#{expected_create_table_options}" contains " t.string :item_type#{expected_item_type_options}" } } } } ) expect(destination_root).not_to( have_structure { directory("db") { directory("migrate") { migration("add_object_changes_to_versions") } } } ) end end describe "`--with-changes` option set to `true`" do before do prepare_destination run_generator %w[--with-changes] end it "generates a migration for creating the 'versions' table" do expect(destination_root).to( have_structure { directory("db") { directory("migrate") { migration("create_versions") { contains "class CreateVersions" contains "def change" contains "create_table :versions" } } } } ) end it "generates a migration for adding the 'object_changes' column to the 'versions' table" do expect(destination_root).to( have_structure { directory("db") { directory("migrate") { migration("add_object_changes_to_versions") { contains "class AddObjectChangesToVersions" contains "def change" contains "add_column :versions, :object_changes, :text" } } } } ) end end end paper_trail-12.0.0/spec/models/000077500000000000000000000000001403037104100163025ustar00rootroot00000000000000paper_trail-12.0.0/spec/models/animal_spec.rb000066400000000000000000000045661403037104100211150ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Animal, type: :model, versioning: true do it "baseline test setup" do expect(Animal.new).to be_versioned expect(Animal.inheritance_column).to eq("species") end describe "#descends_from_active_record?" do it "returns true, meaning that Animal is not an STI subclass" do expect(described_class.descends_from_active_record?).to eq(true) end end it "works with custom STI inheritance column" do animal = Animal.create(name: "Animal") animal.update(name: "Animal from the Muppets") animal.update(name: "Animal Muppet") animal.destroy dog = Dog.create(name: "Snoopy") dog.update(name: "Scooby") dog.update(name: "Scooby Doo") dog.destroy cat = Cat.create(name: "Garfield") cat.update(name: "Garfield (I hate Mondays)") cat.update(name: "Garfield The Cat") cat.destroy expect(PaperTrail::Version.count).to(eq(12)) expect(animal.versions.count).to(eq(4)) expect(animal.versions.first.reify).to(be_nil) animal.versions[(1..-1)].each do |v| expect(v.reify.class.name).to(eq("Animal")) end dog_versions = PaperTrail::Version.where(item_id: dog.id).order(:created_at) expect(dog_versions.count).to(eq(4)) expect(dog_versions.first.reify).to(be_nil) expect(dog_versions.map { |v| v.reify.class.name }).to eq(%w[NilClass Dog Dog Dog]) cat_versions = PaperTrail::Version.where(item_id: cat.id).order(:created_at) expect(cat_versions.count).to(eq(4)) expect(cat_versions.first.reify).to(be_nil) expect(cat_versions.map { |v| v.reify.class.name }).to eq(%w[NilClass Cat Cat Cat]) end it "allows the inheritance_column (species) to be updated" do cat = Cat.create!(name: "Leo") cat.update(name: "Spike", species: "Dog") dog = Animal.find(cat.id) expect(dog).to be_instance_of(Dog) end context "with callback-methods" do context "when only has_paper_trail set in super class" do let(:callback_cat) { Cat.create(name: "Markus") } it "trails all events" do callback_cat.update(name: "Billie") callback_cat.destroy expect(callback_cat.versions.collect(&:event)).to eq %w[create update destroy] end it "does not break reify" do callback_cat.destroy expect { callback_cat.versions.last.reify }.not_to raise_error end end end end paper_trail-12.0.0/spec/models/article_spec.rb000066400000000000000000000133151403037104100212670ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Article, type: :model, versioning: true do describe ".create" do it "also creates a version record" do expect { described_class.create }.to( change { PaperTrail::Version.count }.by(+1) ) end end context "which updates an ignored column" do it "not change the number of versions" do article = described_class.create article.update(title: "My first title") expect(PaperTrail::Version.count).to(eq(1)) end end context "which updates an ignored column with truly Proc" do it "not change the number of versions" do article = described_class.create article.update(abstract: "ignore abstract") expect(PaperTrail::Version.count).to(eq(1)) end end context "which updates an ignored column with falsy Proc" do it "change the number of versions" do article = described_class.create article.update(abstract: "do not ignore abstract!") expect(PaperTrail::Version.count).to(eq(2)) end end context "which updates an ignored column, ignored with truly Proc and a selected column" do it "change the number of versions" do article = described_class.create article.update( title: "My first title", content: "Some text here.", abstract: "ignore abstract" ) expect(PaperTrail::Version.count).to(eq(2)) expect(article.versions.size).to(eq(2)) end it "have stored only non-ignored attributes" do article = described_class.create article.update( title: "My first title", content: "Some text here.", abstract: "ignore abstract" ) expected = { "content" => [nil, "Some text here."] } expect(article.versions.last.changeset).to(eq(expected)) end end context "which updates an ignored column, ignored with falsy Proc and a selected column" do it "change the number of versions" do article = described_class.create article.update( title: "My first title", content: "Some text here.", abstract: "do not ignore abstract" ) expect(PaperTrail::Version.count).to(eq(2)) expect(article.versions.size).to(eq(2)) end it "stores only non-ignored attributes" do article = described_class.create article.update( title: "My first title", content: "Some text here.", abstract: "do not ignore abstract" ) expected = { "content" => [nil, "Some text here."], "abstract" => [nil, "do not ignore abstract"] } expect(article.versions.last.changeset).to(eq(expected)) end end context "which updates a selected column" do it "change the number of versions" do article = described_class.create article.update(content: "Some text here.") expect(PaperTrail::Version.count).to(eq(2)) expect(article.versions.size).to(eq(2)) end end context "which updates a non-ignored and non-selected column" do it "not change the number of versions" do article = described_class.create article.update(abstract: "Other abstract") expect(PaperTrail::Version.count).to(eq(1)) end end context "which updates a skipped column" do it "not change the number of versions" do article = described_class.create article.update(file_upload: "Your data goes here") expect(PaperTrail::Version.count).to(eq(1)) end end context "which updates a skipped column and a selected column" do it "change the number of versions" do article = described_class.create article.update( file_upload: "Your data goes here", content: "Some text here." ) expect(PaperTrail::Version.count).to(eq(2)) end it "show the new version in the model's `versions` association" do article = described_class.create article.update( file_upload: "Your data goes here", content: "Some text here." ) expect(article.versions.size).to(eq(2)) end it "have stored only non-skipped attributes" do article = described_class.create article.update( file_upload: "Your data goes here", content: "Some text here." ) expect( article.versions.last.changeset ).to(eq("content" => [nil, "Some text here."])) end context "and when updated again" do it "have removed the skipped attributes when saving the previous version" do article = described_class.create article.update( file_upload: "Your data goes here", content: "Some text here." ) article.update( file_upload: "More data goes here", content: "More text here." ) old_article = article.versions.last expect( PaperTrail.serializer.load(old_article.object)["file_upload"] ).to(be_nil) end it "have kept the non-skipped attributes in the previous version" do article = described_class.create article.update( file_upload: "Your data goes here", content: "Some text here." ) article.update( file_upload: "More data goes here", content: "More text here." ) old_article = article.versions.last expect( PaperTrail.serializer.load(old_article.object)["content"] ).to(eq("Some text here.")) end end end describe "#destroy" do it "creates a version record" do article = described_class.create article.destroy expect(PaperTrail::Version.count).to(eq(2)) expect(article.versions.size).to(eq(2)) expect(article.versions.map(&:event)).to(match_array(%w[create destroy])) end end end paper_trail-12.0.0/spec/models/boolit_spec.rb000066400000000000000000000022601403037104100211310ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require "support/custom_json_serializer" RSpec.describe Boolit, type: :model, versioning: true do let(:boolit) { Boolit.create! } before { boolit.update!(name: FFaker::Name.name) } it "has two versions" do expect(boolit.versions.size).to eq(2) end it "can be reified and persisted" do expect { boolit.versions.last.reify.save! }.not_to raise_error end context "Instance falls out of default scope" do before { boolit.update!(scoped: false) } it "is NOT scoped" do expect(Boolit.first).to be_nil end it "still can be reified and persisted" do expect { boolit.paper_trail.previous_version.save! }.not_to raise_error end context "with `nil` attributes on the live instance" do before do PaperTrail.serializer = CustomJsonSerializer boolit.update!(name: nil) boolit.update!(name: FFaker::Name.name) end after { PaperTrail.serializer = PaperTrail::Serializers::YAML } it "does not overwrite that attribute during the reification process" do expect(boolit.paper_trail.previous_version.name).to be_nil end end end end paper_trail-12.0.0/spec/models/callback_modifier_spec.rb000066400000000000000000000065411403037104100232610ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe CallbackModifier, type: :model, versioning: true do describe "paper_trail_on_destroy" do it "adds :destroy to paper_trail_options[:on]" do modifier = NoArgDestroyModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.paper_trail_options[:on]).to eq([:destroy]) end context "when :before" do it "creates the version before destroy" do modifier = BeforeDestroyModifier.create!(some_content: FFaker::Lorem.sentence) modifier.test_destroy expect(modifier.versions.last.reify).not_to be_flagged_deleted end end unless ActiveRecord::Base.belongs_to_required_by_default context "when :after" do it "creates the version after destroy" do modifier = AfterDestroyModifier.create!(some_content: FFaker::Lorem.sentence) modifier.test_destroy expect(modifier.versions.last.reify).to be_flagged_deleted end end end context "when no argument" do it "defaults to before destroy" do modifier = NoArgDestroyModifier.create!(some_content: FFaker::Lorem.sentence) modifier.test_destroy expect(modifier.versions.last.reify).not_to be_flagged_deleted end end end describe "paper_trail_on_update" do it "adds :update to paper_trail_options[:on]" do modifier = UpdateModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.paper_trail_options[:on]).to eq [:update] end it "creates a version" do modifier = UpdateModifier.create!(some_content: FFaker::Lorem.sentence) modifier.update! some_content: "modified" expect(modifier.versions.last.event).to eq "update" end end describe "paper_trail_on_create" do it "adds :create to paper_trail_options[:on]" do modifier = CreateModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.paper_trail_options[:on]).to eq [:create] end it "creates a version" do modifier = CreateModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.versions.last.event).to eq "create" end end context "when no callback-method used" do it "sets paper_trail_options[:on] to [:create, :update, :destroy]" do modifier = DefaultModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.paper_trail_options[:on]).to eq %i[create update destroy touch] end it "tracks destroy" do modifier = DefaultModifier.create!(some_content: FFaker::Lorem.sentence) modifier.destroy expect(modifier.versions.last.event).to eq "destroy" end it "tracks update" do modifier = DefaultModifier.create!(some_content: FFaker::Lorem.sentence) modifier.update! some_content: "modified" expect(modifier.versions.last.event).to eq "update" end it "tracks create" do modifier = DefaultModifier.create!(some_content: FFaker::Lorem.sentence) expect(modifier.versions.last.event).to eq "create" end end context "when only one callback-method" do it "does only track the corresponding event" do modifier = CreateModifier.create!(some_content: FFaker::Lorem.sentence) modifier.update!(some_content: "modified") modifier.test_destroy expect(modifier.versions.collect(&:event)).to eq ["create"] end end end paper_trail-12.0.0/spec/models/car_spec.rb000066400000000000000000000005641403037104100204130ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Car, type: :model do it { is_expected.to be_versioned } describe "changeset", versioning: true do it "has the expected keys (see issue 738)" do car = Car.create!(name: "Alice") car.update(name: "Bob") assert_includes car.versions.last.changeset.keys, "name" end end end paper_trail-12.0.0/spec/models/cat_spec.rb000066400000000000000000000004631403037104100204130ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Cat, type: :model, versioning: true do describe "#descends_from_active_record?" do it "returns false, meaning that Cat is an STI subclass" do expect(described_class.descends_from_active_record?).to eq(false) end end end paper_trail-12.0.0/spec/models/custom_primary_key_record_spec.rb000066400000000000000000000013621403037104100251260ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe CustomPrimaryKeyRecord, type: :model do it { is_expected.to be_versioned } describe "#versions" do it "returns instances of CustomPrimaryKeyRecordVersion", versioning: true do custom_primary_key_record = described_class.create! custom_primary_key_record.update!(name: "bob") version = custom_primary_key_record.versions.last expect(version).to be_a(CustomPrimaryKeyRecordVersion) version_from_db = CustomPrimaryKeyRecordVersion.last expect(version_from_db.reify).to be_a(CustomPrimaryKeyRecord) custom_primary_key_record.destroy expect(CustomPrimaryKeyRecordVersion.last.reify).to be_a(CustomPrimaryKeyRecord) end end end paper_trail-12.0.0/spec/models/document_spec.rb000066400000000000000000000027141403037104100214630ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Document, type: :model, versioning: true do describe "have_a_version_with matcher" do it "works with custom versions association" do document = Document.create!(name: "Foo") document.update!(name: "Bar") expect(document).to have_a_version_with(name: "Foo") end end describe "#paper_trail.next_version" do it "returns the expected document" do doc = Document.create doc.update(name: "Doc 1") reified = doc.paper_trail_versions.last.reify expect(doc.name).to(eq(reified.paper_trail.next_version.name)) end end describe "#paper_trail.previous_version" do it "returns the expected document" do doc = Document.create doc.update(name: "Doc 1") doc.update(name: "Doc 2") expect(doc.paper_trail_versions.length).to(eq(3)) expect(doc.paper_trail.previous_version.name).to(eq("Doc 1")) end end describe "#paper_trail_versions" do it "returns the expected version records" do doc = Document.create doc.update(name: "Doc 1") expect(doc.paper_trail_versions.length).to(eq(2)) expect(doc.paper_trail_versions.map(&:event)).to( match_array(%w[create update]) ) end end describe "#versions" do it "does not respond to versions method" do doc = Document.create doc.update(name: "Doc 1") expect(doc).not_to respond_to(:versions) end end end paper_trail-12.0.0/spec/models/family/000077500000000000000000000000001403037104100175635ustar00rootroot00000000000000paper_trail-12.0.0/spec/models/family/celebrity_family_spec.rb000066400000000000000000000114611403037104100244500ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module Family RSpec.describe CelebrityFamily, type: :model, versioning: true do describe "#joins" do it "works on an STI model" do described_class.create! result = described_class. joins(:versions). select("families.id, max(versions.event) as event"). group("families.id"). first expect(result.event).to eq("create") end end describe "#create" do it "creates version with item_subtype == class.name, not base_class" do carter = described_class.create( name: "Carter", path_to_stardom: "Mexican radio" ) v = carter.versions.last expect(v[:event]).to eq("create") expect(v[:item_subtype]).to eq("Family::CelebrityFamily") end end describe "#reify" do context "belongs_to" do it "uses the correct item_subtype" do parent = described_class.new(name: "Jermaine Jackson") parent.path_to_stardom = "Emulating Motown greats such as the Temptations and "\ "The Supremes" child1 = parent.children.build(name: "Jaimy Jermaine Jackson") parent.children.build(name: "Autumn Joy Jackson") parent.save! parent.update!( name: "Hazel Gordy", children_attributes: { id: child1.id, name: "Jay Jackson" } ) expect(parent.versions.count).to eq(2) # A create and an update parent.versions.each do |parent_version| expect(parent_version.item_type).to eq("Family::Family") expect(parent_version.item_subtype).to eq("Family::CelebrityFamily") end expect(parent.versions[1].reify).to be_a(::Family::CelebrityFamily) end end context "has_many" do it "uses the correct item_type in queries" do parent = described_class.new(name: "Gomez Addams") parent.path_to_stardom = "Buy a Victorian house next to a sprawling graveyard, "\ "and just become super aloof." parent.children.build(name: "Wednesday") parent.save! parent.name = "Morticia Addams" parent.children.build(name: "Pugsley") parent.save! expect(parent.versions.count).to eq(2) parent.versions.each do |parent_version| expect(parent_version.item_type).to eq("Family::Family") expect(parent_version.item_subtype).to eq("Family::CelebrityFamily") end end end context "has_many through" do it "uses the correct item_type in queries" do parent = described_class.new(name: "Grandad") parent.path_to_stardom = "Took a suitcase and started running a market trading "\ "company out of it, while proclaiming, 'This time next "\ "year, we'll be millionaires!'" parent.grandsons.build(name: "Del Boy") parent.save! parent.name = "Del" parent.grandsons.build(name: "Rodney") parent.save! expect(parent.versions.count).to eq(2) parent.versions.each do |parent_version| expect(parent_version.item_type).to eq("Family::Family") expect(parent_version.item_subtype).to eq("Family::CelebrityFamily") end end end context "has_one" do it "uses the correct item_type in queries" do parent = described_class.new(name: "Minnie Marx") parent.path_to_stardom = "Gain a relentless dedication to the stage by having a "\ "mother who performs as a yodeling harpist, and then "\ "bring up 5 boys who have a true zest for comedy." parent.build_mentee(name: "Abraham Schönberg") parent.save! parent.update( name: "Samuel Marx", mentee_attributes: { id: parent.mentee.id, name: "Al Shean" } ) expect(parent.versions.count).to eq(2) parent.versions.each do |parent_version| expect(parent_version.item_type).to eq("Family::Family") expect(parent_version.item_subtype).to eq("Family::CelebrityFamily") end end end end describe "#update" do it "creates version with item_subtype == class.name, not base_class" do carter = described_class.create( name: "Carter", path_to_stardom: "Mexican radio" ) carter.update(path_to_stardom: "Johnny") v = carter.versions.last expect(v[:event]).to eq("update") expect(v[:item_type]).to eq("Family::Family") expect(v[:item_subtype]).to eq("Family::CelebrityFamily") end end end end paper_trail-12.0.0/spec/models/fruit_spec.rb000066400000000000000000000014611403037104100207740ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" if ENV["DB"] == "postgres" || JsonVersion.table_exists? RSpec.describe Fruit, type: :model, versioning: true do describe "have_a_version_with_changes matcher" do it "works with Fruit because Fruit uses JsonVersion" do # As of PT 9.0.0, with_version_changes only supports json(b) columns, # so that's why were testing the have_a_version_with_changes matcher # here. banana = Fruit.create!(color: "Red", name: "Banana") banana.update!(color: "Yellow") expect(banana).to have_a_version_with_changes(color: "Yellow") expect(banana).not_to have_a_version_with_changes(color: "Pink") expect(banana).not_to have_a_version_with_changes(color: "Yellow", name: "Kiwi") end end end end paper_trail-12.0.0/spec/models/gadget_spec.rb000066400000000000000000000027121403037104100210760ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Gadget, type: :model do let(:gadget) { Gadget.create!(name: "Wrench", brand: "Acme") } it { is_expected.to be_versioned } describe "updates", versioning: true do it "generates a version for updates" do expect { gadget.update_attribute(:name, "Hammer") }.to(change { gadget.versions.size }.by(1)) end context "ignored via symbol" do it "doesn't generate a version" do expect { gadget.update_attribute(:brand, "Picard") }.not_to(change { gadget.versions.size }) end end context "ignored via Hash" do it "generates a version when the ignored attribute isn't true" do expect { gadget.update_attribute(:color, "Blue") }.to(change { gadget.versions.size }.by(1)) expect(gadget.versions.last.changeset.keys).to eq %w[color updated_at] end it "doesn't generate a version when the ignored attribute is true" do expect { gadget.update_attribute(:color, "Yellow") }.not_to(change { gadget.versions.size }) end end it "still generates a version when only the `updated_at` attribute is updated" do # Plus 1 second because MySQL lacks sub-second resolution expect { gadget.update_attribute(:updated_at, Time.current + 1) }.to(change { gadget.versions.size }.by(1)) expect( YAML.load(gadget.versions.last.object_changes).keys ).to eq(["updated_at"]) end end end paper_trail-12.0.0/spec/models/joined_version_spec.rb000066400000000000000000000022101403037104100226510ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe JoinedVersion, type: :model, versioning: true do let(:widget) { Widget.create!(name: FFaker::Name.name) } let(:version) { JoinedVersion.first } describe "default_scope" do it { expect(JoinedVersion.default_scopes).not_to be_empty } end describe "VersionConcern::ClassMethods" do before { widget } # persist a widget describe "#subsequent" do it "does not raise error when there is a default_scope that joins" do JoinedVersion.subsequent(version).first end end describe "#preceding" do it "does not raise error when there is a default scope that joins" do JoinedVersion.preceding(version).first end end describe "#between" do it "does not raise error when there is a default scope that joins" do JoinedVersion.between(Time.current, 1.minute.from_now).first end end end describe "#index" do it { is_expected.to respond_to(:index) } it "does not raise error when there is a default scope that joins" do widget # persist a widget version.index end end end paper_trail-12.0.0/spec/models/json_version_spec.rb000066400000000000000000000111041403037104100223540ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" # The `json_versions` table tests postgres' `json` data type. So, that # table is only created when testing against postgres. if JsonVersion.table_exists? RSpec.describe JsonVersion, type: :model do it "includes the VersionConcern module" do expect(described_class).to include(PaperTrail::VersionConcern) end describe "#where_object" do it { expect(described_class).to respond_to(:where_object) } it "escapes values" do f = Fruit.create(name: "Bobby") expect( f. versions. where_object(name: "Robert'; DROP TABLE Students;--"). count ).to eq(0) end context "invalid arguments" do it "raises an error" do expect { described_class.where_object(:foo) }.to raise_error(ArgumentError) expect { described_class.where_object([]) }.to raise_error(ArgumentError) end end context "valid arguments", versioning: true do it "locates versions according to their `object` contents" do fruit = Fruit.create!(name: "apple") expect(fruit.versions.length).to eq(1) fruit.update!(name: "banana", color: "aqua") expect(fruit.versions.length).to eq(2) fruit.update!(name: "coconut", color: "black") expect(fruit.versions.length).to eq(3) where_apple = described_class.where_object(name: "apple") expect(where_apple.to_sql).to eq( <<-SQL.squish SELECT "json_versions".* FROM "json_versions" WHERE (object->>'name' = 'apple') SQL ) expect(where_apple).to eq([fruit.versions[1]]) expect( described_class.where_object(color: "aqua") ).to eq([fruit.versions[2]]) end end end describe "#where_object_changes" do it "escapes values" do f = Fruit.create(name: "Bobby") expect( f. versions. where_object_changes(name: "Robert'; DROP TABLE Students;--"). count ).to eq(0) end context "invalid arguments" do it "raises an error" do expect { described_class.where_object_changes(:foo) }.to raise_error(ArgumentError) expect { described_class.where_object_changes([]) }.to raise_error(ArgumentError) end end context "valid arguments", versioning: true do it "finds versions according to their `object_changes` contents" do fruit = Fruit.create!(name: "apple") fruit.update!(name: "banana", color: "red") fruit.update!(name: "coconut", color: "green") where_apple = fruit.versions.where_object_changes(name: "apple") expect(where_apple.to_sql.squish).to eq( <<-SQL.squish SELECT "json_versions".* FROM "json_versions" WHERE "json_versions"."item_id" = #{fruit.id} AND "json_versions"."item_type" = 'Fruit' AND (((object_changes->>'name' ILIKE '["apple",%') OR (object_changes->>'name' ILIKE '[%,"apple"]%'))) ORDER BY "json_versions"."created_at" ASC, "json_versions"."id" ASC SQL ) expect(where_apple).to match_array(fruit.versions[0..1]) expect( fruit.versions.where_object_changes(color: "red") ).to match_array(fruit.versions[1..2]) end it "finds versions with multiple attributes changed" do fruit = Fruit.create!(name: "apple") fruit.update!(name: "banana", color: "red") fruit.update!(name: "coconut", color: "green") where_red_apple = fruit.versions.where_object_changes(color: "red", name: "apple") expect(where_red_apple.to_sql.squish).to eq( <<-SQL.squish SELECT "json_versions".* FROM "json_versions" WHERE "json_versions"."item_id" = #{fruit.id} AND "json_versions"."item_type" = 'Fruit' AND (((object_changes->>'color' ILIKE '["red",%') OR (object_changes->>'color' ILIKE '[%,"red"]%')) and ((object_changes->>'name' ILIKE '["apple",%') OR (object_changes->>'name' ILIKE '[%,"apple"]%'))) ORDER BY "json_versions"."created_at" ASC, "json_versions"."id" ASC SQL ) expect(where_red_apple).to match_array([fruit.versions[1]]) end end end end end paper_trail-12.0.0/spec/models/kitchen/000077500000000000000000000000001403037104100177275ustar00rootroot00000000000000paper_trail-12.0.0/spec/models/kitchen/banana_spec.rb000066400000000000000000000006111403037104100225040ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module Kitchen RSpec.describe Banana, type: :model do it { is_expected.to be_versioned } describe "#versions" do it "returns instances of Kitchen::BananaVersion", versioning: true do banana = described_class.create! expect(banana.versions.first).to be_a(Kitchen::BananaVersion) end end end end paper_trail-12.0.0/spec/models/legacy_widget_spec.rb000066400000000000000000000025221403037104100224510ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe LegacyWidget, type: :model, versioning: true do describe "#custom_version" do it "knows which version it came from" do widget = described_class.create(name: "foo", version: 2) %w[bar baz].each { |name| widget.update(name: name) } version = widget.versions.last reified = version.reify expect(reified.custom_version).to(eq(version)) end end describe "#previous_version" do it "return its previous self" do widget = described_class.create(name: "foo", version: 2) %w[bar baz].each { |name| widget.update(name: name) } version = widget.versions.last reified = version.reify expect(reified.paper_trail.previous_version).to(eq(reified.versions[-2].reify)) end end describe "#update" do it "does not create a PT version record because the updated column is ignored" do described_class.create.update(version: 1) expect(PaperTrail::Version.count).to(eq(1)) end end describe "#version" do it "is a normal attribute and has nothing to do with PT" do widget = described_class.create(name: "foo", version: 2) expect(widget.versions.size).to(eq(1)) expect(widget.version).to(eq(2)) widget.update(version: 3) expect(widget.version).to(eq(3)) end end end paper_trail-12.0.0/spec/models/management_spec.rb000066400000000000000000000020711403037104100217550ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" ::RSpec.describe(::Management, type: :model, versioning: true) do it "utilises the base_class for STI classes having no type column" do expect(Management.inheritance_column).to eq("type") expect(Management.columns.map(&:name)).not_to include("type") # Create, update, and destroy a Management and a Customer customer1 = Customer.create(name: "Cust 1") customer2 = Management.create(name: "Cust 2") customer1.update(name: "Cust 1a") customer2.update(name: "Cust 2a") customer1.destroy customer2.destroy # All versions end up with an `item_type` of Customer expect( PaperTrail::Version.where(item_type: "Customer").count ).to eq(6) expect( PaperTrail::Version.where(item_type: "Management").count ).to eq(0) # The item_subtype, on the other hand, is 3 and 3 expect( PaperTrail::Version.where(item_subtype: "Customer").count ).to eq(3) expect( PaperTrail::Version.where(item_subtype: "Management").count ).to eq(3) end end paper_trail-12.0.0/spec/models/no_object_spec.rb000066400000000000000000000034661403037104100216140ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe NoObject, versioning: true do it "still creates version records" do n = described_class.create!(letter: "A") a = n.versions.last.attributes expect(a).not_to include "object" expect(a["event"]).to eq "create" expect(a["object_changes"]).to start_with("---") expect(a["metadatum"]).to eq(42) n.update!(letter: "B") a = n.versions.last.attributes expect(a).not_to include "object" expect(a["event"]).to eq "update" expect(a["object_changes"]).to start_with("---") expect(a["metadatum"]).to eq(42) n.destroy! a = n.versions.last.attributes expect(a).not_to include "object" expect(a["event"]).to eq "destroy" expect(a["object_changes"]).to start_with("---") expect(a["metadatum"]).to eq(42) # New feature: destroy populates object_changes # https://github.com/paper-trail-gem/paper_trail/pull/1123 h = YAML.load a["object_changes"] expect(h["id"]).to eq([n.id, nil]) expect(h["letter"]).to eq([n.letter, nil]) expect(h["created_at"][0]).to be_present expect(h["created_at"][1]).to be_nil expect(h["updated_at"][0]).to be_present expect(h["updated_at"][1]).to be_nil end describe "reify" do it "raises error" do n = NoObject.create!(letter: "A") v = n.versions.last expect { v.reify }.to( raise_error( ::RuntimeError, "reify can't be called without an object column" ) ) end end describe "where_object" do it "raises error" do n = NoObject.create!(letter: "A") expect { n.versions.where_object(foo: "bar") }.to( raise_error( ::RuntimeError, "where_object can't be called without an object column" ) ) end end end paper_trail-12.0.0/spec/models/not_on_update_spec.rb000066400000000000000000000005651403037104100225050ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe NotOnUpdate, type: :model do describe "#save_with_version", versioning: true do let!(:record) { described_class.create! } it "creates a version, regardless" do expect { record.paper_trail.save_with_version }.to change { PaperTrail::Version.count }.by(+1) end end end paper_trail-12.0.0/spec/models/on/000077500000000000000000000000001403037104100167165ustar00rootroot00000000000000paper_trail-12.0.0/spec/models/on/create_spec.rb000066400000000000000000000020601403037104100215160ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_dependency "on/create" module On RSpec.describe Create, type: :model, versioning: true do describe "#versions" do it "only have a version for the create event" do record = described_class.create(name: "Alice") record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("create")) end end describe "#paper_trail_event" do it "rembembers the custom event name" do record = described_class.new record.paper_trail_event = "banana" record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("banana")) end end describe "#touch" do it "does not create a version" do record = described_class.create(name: "Alice") expect { record.touch }.not_to( change { record.versions.count } ) end end end end paper_trail-12.0.0/spec/models/on/destroy_spec.rb000066400000000000000000000015531403037104100217520ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_dependency "on/destroy" module On RSpec.describe Destroy, type: :model, versioning: true do describe "#versions" do it "only creates one version record, for the destroy event" do record = described_class.create(name: "Alice") record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("destroy")) end end describe "#paper_trail_event" do it "rembembers the custom event name" do record = described_class.create(name: "Alice") record.paper_trail_event = "banana" record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("banana")) end end end end paper_trail-12.0.0/spec/models/on/empty_array_spec.rb000066400000000000000000000015741403037104100226200ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_dependency "on/empty_array" module On RSpec.describe EmptyArray, type: :model, versioning: true do describe "#create" do it "does not create any version records" do record = described_class.create(name: "Alice") expect(record.versions.length).to(eq(0)) end end describe ".paper_trail.update_columns" do it "creates a version record" do widget = Widget.create assert_equal 1, widget.versions.length widget.paper_trail.update_columns(name: "Bugle") assert_equal 2, widget.versions.length end end describe "#update" do it "does not create any version records" do record = described_class.create(name: "Alice") record.update(name: "blah") expect(record.versions.length).to(eq(0)) end end end end paper_trail-12.0.0/spec/models/on/touch_spec.rb000066400000000000000000000020171403037104100213770ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_dependency "on/create" module On RSpec.describe Touch, type: :model, versioning: true do describe "#create" do it "does not create a version" do record = described_class.create(name: "Alice") expect(record.versions.count).to eq(0) end end describe "#touch" do it "creates a version" do record = described_class.create(name: "Alice") expect { record.touch }.to( change { record.versions.count }.by(+1) ) expect(record.versions.last.event).to eq("update") end it "saves a object" do record = described_class.create(name: "Alice") record.touch expect(record.versions.last.reify.name).to eq("Alice") end end describe "#update" do it "does not create a version" do record = described_class.create(name: "Alice") record.update(name: "Andrew") expect(record.versions.count).to eq(0) end end end end paper_trail-12.0.0/spec/models/on/update_spec.rb000066400000000000000000000021171403037104100215400ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_dependency "on/update" module On RSpec.describe Update, type: :model, versioning: true do describe "#versions" do it "only creates one version record, for the update event" do record = described_class.create(name: "Alice") record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("update")) end end describe "#paper_trail_event" do it "rembembers the custom event name" do record = described_class.create(name: "Alice") record.paper_trail_event = "banana" record.update(name: "blah") record.destroy expect(record.versions.length).to(eq(1)) expect(record.versions.last.event).to(eq("banana")) end end describe "#touch" do it "does not create a version" do record = described_class.create(name: "Alice") expect { record.touch }.not_to( change { record.versions.count } ) end end end end paper_trail-12.0.0/spec/models/person_spec.rb000066400000000000000000000162511403037104100211540ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" # The `Person` model: # # - has a dozen associations of various types # - has a custom serializer, TimeZoneSerializer, for its `time_zone` attribute RSpec.describe Person, type: :model, versioning: true do describe "#time_zone" do it "returns an ActiveSupport::TimeZone" do person = Person.new(time_zone: "Samoa") expect(person.time_zone.class).to(eq(ActiveSupport::TimeZone)) end end context "when the model is saved" do it "version.object_changes should store long serialization of TimeZone object" do person = Person.new(time_zone: "Samoa") person.save! len = person.versions.last.object_changes.length expect((len < 105)).to(be_truthy) end it "version.object_changes attribute should have stored the value from serializer" do person = Person.new(time_zone: "Samoa") person.save! as_stored_in_version = HashWithIndifferentAccess[ YAML.load(person.versions.last.object_changes) ] expect(as_stored_in_version[:time_zone]).to(eq([nil, "Samoa"])) serialized_value = Person::TimeZoneSerializer.dump(person.time_zone) expect(as_stored_in_version[:time_zone].last).to(eq(serialized_value)) end it "version.changeset should convert attribute to original, unserialized value" do person = Person.new(time_zone: "Samoa") person.save! unserialized_value = Person::TimeZoneSerializer.load(person.time_zone) expect(person.versions.last.changeset[:time_zone].last).to(eq(unserialized_value)) end it "record.changes (before save) returns the original, unserialized values" do person = Person.new(time_zone: "Samoa") changes_before_save = person.changes.dup person.save! expect( changes_before_save[:time_zone].map(&:class) ).to(eq([NilClass, ActiveSupport::TimeZone])) end it "version.changeset should be the same as record.changes was before the save" do person = Person.new(time_zone: "Samoa") changes_before_save = person.changes.dup person.save! actual = person.versions.last.changeset.delete_if { |k, _v| (k.to_sym == :id) } expect(actual).to(eq(changes_before_save)) actual = person.versions.last.changeset[:time_zone].map(&:class) expect(actual).to(eq([NilClass, ActiveSupport::TimeZone])) end context "when that attribute is updated" do it "object should not store long serialization of TimeZone object" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! len = person.versions.last.object.length expect((len < 105)).to(be_truthy) end it "object_changes should not store long serialization of TimeZone object" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! len = person.versions.last.object_changes.length expect(len < 118).to eq(true) end it "version.object attribute should have stored value from serializer" do person = Person.new(time_zone: "Samoa") person.save! attribute_value_before_change = person.time_zone person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! as_stored_in_version = HashWithIndifferentAccess[ YAML.load(person.versions.last.object) ] expect(as_stored_in_version[:time_zone]).to(eq("Samoa")) serialized_value = Person::TimeZoneSerializer.dump(attribute_value_before_change) expect(as_stored_in_version[:time_zone]).to(eq(serialized_value)) end it "version.object_changes attribute should have stored value from serializer" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! as_stored_in_version = HashWithIndifferentAccess[ YAML.load(person.versions.last.object_changes) ] expect(as_stored_in_version[:time_zone]).to(eq(["Samoa", "Pacific Time (US & Canada)"])) serialized_value = Person::TimeZoneSerializer.dump(person.time_zone) expect(as_stored_in_version[:time_zone].last).to(eq(serialized_value)) end it "version.reify should convert attribute to original, unserialized value" do person = Person.new(time_zone: "Samoa") person.save! attribute_value_before_change = person.time_zone person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! unserialized_value = Person::TimeZoneSerializer.load(attribute_value_before_change) expect(person.versions.last.reify.time_zone).to(eq(unserialized_value)) end it "version.changeset should convert attribute to original, unserialized value" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") person.save! unserialized_value = Person::TimeZoneSerializer.load(person.time_zone) expect(person.versions.last.changeset[:time_zone].last).to(eq(unserialized_value)) end it "record.changes (before save) returns the original, unserialized values" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") changes_before_save = person.changes.dup person.save! expect( changes_before_save[:time_zone].map(&:class) ).to(eq([ActiveSupport::TimeZone, ActiveSupport::TimeZone])) end it "version.changeset should be the same as record.changes was before the save" do person = Person.new(time_zone: "Samoa") person.save! person.assign_attributes(time_zone: "Pacific Time (US & Canada)") changes_before_save = person.changes.dup person.save! expect(person.versions.last.changeset).to(eq(changes_before_save)) expect( person.versions.last.changeset[:time_zone].map(&:class) ).to(eq([ActiveSupport::TimeZone, ActiveSupport::TimeZone])) end end end describe "#cars and bicycles" do it "can be reified" do person = Person.create(name: "Frank") car = Car.create(name: "BMW 325") bicycle = Bicycle.create(name: "BMX 1.0") person.car = car person.bicycle = bicycle person.update(name: "Steve") car.update(name: "BMW 330") bicycle.update(name: "BMX 2.0") person.update(name: "Peter") expect(person.reload.versions.length).to(eq(3)) # These will work when PT-AT adds support for the new `item_subtype` column # # - https://github.com/westonganger/paper_trail-association_tracking/pull/5 # - https://github.com/paper-trail-gem/paper_trail/pull/1143 # - https://github.com/paper-trail-gem/paper_trail/issues/594 # # second_version = person.reload.versions.second.reify(has_one: true) # expect(second_version.car.name).to(eq("BMW 325")) # expect(second_version.bicycle.name).to(eq("BMX 1.0")) end end end paper_trail-12.0.0/spec/models/pet_spec.rb000066400000000000000000000142151403037104100204340ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require "rails/generators" RSpec.describe Pet, type: :model, versioning: true do it "baseline test setup" do expect(Pet.new).to be_versioned end it "can be reified" do person = Person.create(name: "Frank") dog = Dog.create(name: "Snoopy") cat = Cat.create(name: "Garfield") person.pets << Pet.create(animal: dog) person.pets << Pet.create(animal: cat) person.update(name: "Steve") dog.update(name: "Beethoven") cat.update(name: "Sylvester") person.update(name: "Peter") expect(person.reload.versions.length).to(eq(3)) end context "Older version entry present where item_type refers to the base_class" do let(:cat) { Cat.create(name: "Garfield") } # Index 0 let(:animal) { Animal.create } # Index 4 before do # This line runs the `let` for :cat, creating two entries cat.update(name: "Sylvester") # Index 1 - second cat.update(name: "Cheshire") # Index 2 - third cat.destroy # Index 3 - fourth # Prior to PR#1143 a subclassed version's item_subtype would be nil. In order to simulate # an entry having been made in the old way, set one of the item_subtype entries to be nil # instead of "Cat". versions = PaperTrail::Version.order(:id) versions.second.update(item_subtype: nil) # This line runs the `let` for :animal, creating two entries animal.update(name: "Muppets Drummer") # Index 5 animal.destroy # Index 6 end it "can reify a subclassed item" do versions = PaperTrail::Version.order(:id) # Still the reification process correctly brings back Cat since `species` is # properly set to this sub-classed name. expect(versions.second.reify).to be_a(Cat) # Sylvester expect(versions.third.reify).to be_a(Cat) # Cheshire expect(versions.fourth.reify).to be_a(Cat) # Cheshire that was destroyed # Creating an object from the base class is correctly identified as "Animal" expect(versions[5].reify).to be_an(Animal) # Muppets Drummer expect(versions[6].reify).to be_an(Animal) # Animal that was destroyed end it "has a generator that builds migrations to upgrade older entries" do # Only newer versions have item_subtype that refers directly to the subclass name. expect(PaperTrail::Version.where(item_subtype: "Cat").count).to eq(3) # To have has_many :versions work properly, you can generate and run a migration # that examines all existing models to identify use of STI, then updates all older # version entries that may refer to the base_class so they refer to the subclass. # (This is the same as running: rails g paper_trail:update_sti; rails db:migrate) migrator = ::PaperTrailSpecMigrator.new expect { migrator.generate_and_migrate("paper_trail:update_item_subtype", []) }.to output(/Associated 1 record to Cat/).to_stdout # And now it finds all four changes cat_versions = PaperTrail::Version.where(item_subtype: "Cat").order(:id).to_a expect(cat_versions.length).to eq(4) expect(cat_versions.map(&:event)).to eq(%w[create update update destroy]) # And Animal is unaffected animal_versions = PaperTrail::Version.where(item_subtype: "Animal").order(:id).to_a expect(animal_versions.length).to eq(3) expect(animal_versions.map(&:event)).to eq(%w[create update destroy]) end # After creating a bunch of records above, we change the inheritance_column # so that we can demonstrate passing hints to the migration generator. context "simulate a historical change to inheritance_column" do before do Animal.inheritance_column = "species_xyz" end after do # Clean up the temporary switch-up Animal.inheritance_column = "species" end it "no hints given to generator, does not generate the correct migration" do # Because of the change to inheritance_column, the generator `rails g # paper_trail:update_sti` would be unable to determine the previous # inheritance_column, so a generated migration *with no hints* would # accomplish nothing. migrator = ::PaperTrailSpecMigrator.new hints = [] expect { migrator.generate_and_migrate("paper_trail:update_item_subtype", hints) }.not_to output(/Associated 1 record to Cat/).to_stdout expect(PaperTrail::Version.where(item_subtype: "Cat").count).to eq(3) # And older Cat changes remain as nil. expect(PaperTrail::Version.where(item_subtype: nil, item_id: cat.id).count).to eq(1) end it "giving hints to the generator, updates older entries in a custom way" do # Pick up all version IDs regarding our single cat Garfield / Sylvester / Cheshire cat_ids = PaperTrail::Version.where(item_type: "Animal", item_id: cat.id). order(:id).pluck(:id) # This time (as opposed to above example) we are going to provide hints # to the generator. # # You can specify custom inheritance_column settings over a range of # IDs so that the generated migration will properly update all your historic versions, # having them now to refer to the proper subclass. # This is the same as running: # rails g paper_trail:update_sti Animal(species):1..4; rails db:migrate migrator = ::PaperTrailSpecMigrator.new hints = ["Animal(species):#{cat_ids.first}..#{cat_ids.last}"] expect { migrator.generate_and_migrate("paper_trail:update_item_subtype", hints) }.to output(/Associated 1 record to Cat/).to_stdout # And now the has_many :versions properly finds all four changes cat_versions = cat.versions.order(:id).to_a expect(cat_versions.length).to eq(4) expect(cat_versions.map(&:event)).to eq(%w[create update update destroy]) # And Animal is still unaffected animal_versions = animal.versions.order(:id).to_a expect(animal_versions.length).to eq(3) expect(animal_versions.map(&:event)).to eq(%w[create update destroy]) end end end end paper_trail-12.0.0/spec/models/post_spec.rb000066400000000000000000000017551403037104100206360ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" # The `Post` model uses a custom version class, `PostVersion` RSpec.describe Post, type: :model, versioning: true do it "inserts records into the correct table, post_versions" do post = Post.create expect(PostVersion.count).to(eq(1)) post.update(content: "Some new content") expect(PostVersion.count).to(eq(2)) expect(PaperTrail::Version.count).to(eq(0)) end context "on the first version" do it "have the correct index" do post = Post.create version = post.versions.first expect(version.index).to(eq(0)) end end it "have versions of the custom class" do post = Post.create expect(post.versions.first.class.name).to(eq("PostVersion")) end describe "#changeset" do it "returns nil because the object_changes column doesn't exist" do post = Post.create post.update(content: "Some new content") expect(post.versions.last.changeset).to(be_nil) end end end paper_trail-12.0.0/spec/models/post_with_status_spec.rb000066400000000000000000000035541403037104100232730ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe PostWithStatus, type: :model do with_versioning do let(:post) { described_class.create!(status: "draft") } it "saves the enum value in versions" do post.published! post.archived! expect(post.paper_trail.previous_version.published?).to be true end it "can read enums in version records written by PT 4" do post = described_class.create(status: "draft") post.published! version = post.versions.last # Simulate behavior PT 4, which used to save the string version of # enums to `object_changes` version.update(object_changes: "---\nid:\n- \n- 1\nstatus:\n- draft\n- published\n") assert_equal %w[draft published], version.changeset["status"] end context "storing enum object_changes" do it "saves the enum value properly in versions object_changes" do post.published! post.archived! post_version = post.versions.last expect(post_version.changeset["status"]).to eql(%w[published archived]) end end describe "#save_with_version" do context "when passing *args" do it "passes *args down correctly" do post = described_class.create(status: :draft) expect do post.paper_trail.save_with_version(validate: false) end.to change(post.versions, :count).by(1) end end it "preserves the enum value (and all other attributes)" do post = described_class.create(status: :draft) expect(post.versions.count).to eq(1) expect(post.status).to eq("draft") post.paper_trail.save_with_version expect(post.versions.count).to eq(2) expect(post.versions.last[:object]).to include("status: 0") expect(post.paper_trail.previous_version.status).to eq("draft") end end end end paper_trail-12.0.0/spec/models/skipper_spec.rb000066400000000000000000000025721403037104100213240ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Skipper, type: :model, versioning: true do it { is_expected.to be_versioned } describe "#update!", versioning: true do context "updating a skipped attribute" do let(:t1) { Time.zone.local(2015, 7, 15, 20, 34, 0) } let(:t2) { Time.zone.local(2015, 7, 15, 20, 34, 30) } it "does not create a version" do skipper = Skipper.create!(another_timestamp: t1) expect { skipper.update!(another_timestamp: t2) }.not_to(change { skipper.versions.length }) end end end describe "#reify" do let(:t1) { Time.zone.local(2015, 7, 15, 20, 34, 0) } let(:t2) { Time.zone.local(2015, 7, 15, 20, 34, 30) } context "without preserve (default)" do it "has no timestamp" do skipper = Skipper.create!(another_timestamp: t1) skipper.update!(another_timestamp: t2, name: "Foobar") skipper = skipper.versions.last.reify expect(skipper.another_timestamp).to be(nil) end end context "with preserve" do it "preserves its timestamp" do skipper = Skipper.create!(another_timestamp: t1) skipper.update!(another_timestamp: t2, name: "Foobar") skipper = skipper.versions.last.reify(unversioned_attributes: :preserve) expect(skipper.another_timestamp).to eq(t2) end end end end paper_trail-12.0.0/spec/models/song_spec.rb000066400000000000000000000006231403037104100206100ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" ::RSpec.describe(::Song, type: :model, versioning: true) do describe "#joins" do it "works" do described_class.create! result = described_class. joins(:versions). select("songs.id, max(versions.event) as event"). group("songs.id"). first expect(result.event).to eq("create") end end end paper_trail-12.0.0/spec/models/thing_spec.rb000066400000000000000000000010631403037104100207520ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Thing, type: :model do describe "#versions", versioning: true do let(:thing) { Thing.create! } it "applies the scope option" do expect(Thing.reflect_on_association(:versions).scope).to be_a Proc expect(thing.versions.to_sql).to end_with "ORDER BY id desc" end it "applies the extend option" do expect(thing.versions.singleton_class).to be < PrefixVersionsInspectWithCount expect(thing.versions.inspect).to start_with("1 versions:") end end end paper_trail-12.0.0/spec/models/translation_spec.rb000066400000000000000000000045651403037104100222110ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Translation, type: :model, versioning: true do context "for non-US translations" do it "not change the number of versions" do described_class.create!(headline: "Headline") expect(PaperTrail::Version.count).to(eq(0)) end context "after update" do it "not change the number of versions" do translation = described_class.create!(headline: "Headline") translation.update(content: "Content") expect(PaperTrail::Version.count).to(eq(0)) end end context "after destroy" do it "not change the number of versions" do translation = described_class.create!(headline: "Headline") translation.destroy expect(PaperTrail::Version.count).to(eq(0)) end end end context "for US translations" do context "that are drafts" do it "creation does not change the number of versions" do translation = described_class.new(headline: "Headline") translation.language_code = "US" translation.type = "DRAFT" translation.save! expect(PaperTrail::Version.count).to(eq(0)) end it "update does not change the number of versions" do translation = described_class.new(headline: "Headline") translation.language_code = "US" translation.type = "DRAFT" translation.save! translation.update(content: "Content") expect(PaperTrail::Version.count).to(eq(0)) end end context "that are not drafts" do it "create changes the number of versions" do described_class.create!(headline: "Headline", language_code: "US") expect(PaperTrail::Version.count).to(eq(1)) end it "update does not change the number of versions" do translation = described_class.create!(headline: "Headline", language_code: "US") translation.update(content: "Content") expect(PaperTrail::Version.count).to(eq(2)) expect(translation.versions.size).to(eq(2)) end it "destroy does not change the number of versions" do translation = described_class.new(headline: "Headline") translation.language_code = "US" translation.save! translation.destroy expect(PaperTrail::Version.count).to(eq(2)) expect(translation.versions.size).to(eq(2)) end end end end paper_trail-12.0.0/spec/models/vehicle_spec.rb000066400000000000000000000002131403037104100212540ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Vehicle, type: :model do it { is_expected.not_to be_versioned } end paper_trail-12.0.0/spec/models/version_spec.rb000066400000000000000000000337031403037104100213340ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe Version, type: :model do describe "object_changes column", versioning: true do let(:widget) { Widget.create!(name: "Dashboard") } let(:value) { widget.versions.last.object_changes } context "serializer is YAML" do specify { expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML } it "store out as a plain hash" do expect(value).not_to include("HashWithIndifferentAccess") end end context "with object_changes_adapter" do after do PaperTrail.config.object_changes_adapter = nil end it "creates a version with custom changes" do adapter = instance_spy("CustomObjectChangesAdapter") PaperTrail.config.object_changes_adapter = adapter custom_changes_value = [["name", nil, "Dashboard"]] allow(adapter).to( receive(:diff).with( hash_including("name" => [nil, "Dashboard"]) ).and_return(custom_changes_value) ) yaml = widget.versions.last.object_changes expect(YAML.load(yaml)).to eq(custom_changes_value) expect(adapter).to have_received(:diff) end it "defaults to the original behavior" do adapter = Class.new.new PaperTrail.config.object_changes_adapter = adapter expect(widget.versions.last.object_changes).to start_with("---") end end context "serializer is JSON" do before do PaperTrail.serializer = PaperTrail::Serializers::JSON end after do PaperTrail.serializer = PaperTrail::Serializers::YAML end it "store out as a plain hash" do expect(value).not_to include("HashWithIndifferentAccess") end end end describe "#paper_trail_originator" do context "no previous versions" do it "returns nil" do expect(PaperTrail::Version.new.paper_trail_originator).to be_nil end end context "has previous version", versioning: true do it "returns name of whodunnit" do name = FFaker::Name.name widget = Widget.create!(name: FFaker::Name.name) widget.versions.first.update!(whodunnit: name) widget.update!(name: FFaker::Name.first_name) expect(widget.versions.last.paper_trail_originator).to eq(name) end end end describe "#previous" do context "no previous versions" do it "returns nil" do expect(PaperTrail::Version.new.previous).to be_nil end end context "has previous version", versioning: true do it "returns a PaperTrail::Version" do name = FFaker::Name.name widget = Widget.create!(name: FFaker::Name.name) widget.versions.first.update!(whodunnit: name) widget.update!(name: FFaker::Name.first_name) expect(widget.versions.last.previous).to be_instance_of(PaperTrail::Version) end end end describe "#terminator" do it "is an alias for the `whodunnit` attribute" do attributes = { whodunnit: FFaker::Name.first_name } version = PaperTrail::Version.new(attributes) expect(version.terminator).to eq(attributes[:whodunnit]) end end describe "#version_author" do it "is an alias for the `terminator` method" do version = PaperTrail::Version.new expect(version.method(:version_author)).to eq(version.method(:terminator)) end end context "changing the data type of database columns on the fly" do # TODO: Changing the data type of these database columns in the middle # of the test suite adds a fair amount of complexity. Is there a better # way? We already have a `json_versions` table in our tests, maybe we # could use that and add a `jsonb_versions` table? column_overrides = [false] if ENV["DB"] == "postgres" column_overrides += %w[json jsonb] end column_overrides.shuffle.each do |column_datatype_override| context "with a #{column_datatype_override || 'text'} column" do let(:widget) { Widget.new } let(:name) { FFaker::Name.first_name } let(:int) { column_datatype_override ? 1 : rand(2..6) } before do if column_datatype_override ActiveRecord::Base.connection.execute("SAVEPOINT pgtest;") %w[object object_changes].each do |column| ActiveRecord::Base.connection.execute( "ALTER TABLE versions DROP COLUMN #{column};" ) ActiveRecord::Base.connection.execute( "ALTER TABLE versions ADD COLUMN #{column} #{column_datatype_override};" ) end PaperTrail::Version.reset_column_information end end after do PaperTrail.serializer = PaperTrail::Serializers::YAML if column_datatype_override ActiveRecord::Base.connection.execute("ROLLBACK TO SAVEPOINT pgtest;") PaperTrail::Version.reset_column_information end end describe "#where_object", versioning: true do it "requires its argument to be a Hash" do widget.update!(name: name, an_integer: int) widget.update!(name: "foobar", an_integer: 100) widget.update!(name: FFaker::Name.last_name, an_integer: 15) expect { PaperTrail::Version.where_object(:foo) }.to raise_error(ArgumentError) expect { PaperTrail::Version.where_object([]) }.to raise_error(ArgumentError) end context "YAML serializer" do it "locates versions according to their `object` contents" do expect(PaperTrail.serializer).to be PaperTrail::Serializers::YAML widget.update!(name: name, an_integer: int) widget.update!(name: "foobar", an_integer: 100) widget.update!(name: FFaker::Name.last_name, an_integer: 15) expect( PaperTrail::Version.where_object(an_integer: int) ).to eq([widget.versions[1]]) expect( PaperTrail::Version.where_object(name: name) ).to eq([widget.versions[1]]) expect( PaperTrail::Version.where_object(an_integer: 100) ).to eq([widget.versions[2]]) end end context "JSON serializer" do it "locates versions according to their `object` contents" do PaperTrail.serializer = PaperTrail::Serializers::JSON expect(PaperTrail.serializer).to be PaperTrail::Serializers::JSON widget.update!(name: name, an_integer: int) widget.update!(name: "foobar", an_integer: 100) widget.update!(name: FFaker::Name.last_name, an_integer: 15) expect( PaperTrail::Version.where_object(an_integer: int) ).to eq([widget.versions[1]]) expect( PaperTrail::Version.where_object(name: name) ).to eq([widget.versions[1]]) expect( PaperTrail::Version.where_object(an_integer: 100) ).to eq([widget.versions[2]]) end end end describe "#where_object_changes", versioning: true do it "requires its argument to be a Hash" do expect { PaperTrail::Version.where_object_changes(:foo) }.to raise_error(ArgumentError) expect { PaperTrail::Version.where_object_changes([]) }.to raise_error(ArgumentError) end context "with object_changes_adapter configured" do after do PaperTrail.config.object_changes_adapter = nil end it "calls the adapter's where_object_changes method" do adapter = instance_spy("CustomObjectChangesAdapter") bicycle = Bicycle.create!(name: "abc") allow(adapter).to( receive(:where_object_changes).with(Version, name: "abc") ).and_return(bicycle.versions[0..1]) PaperTrail.config.object_changes_adapter = adapter expect( bicycle.versions.where_object_changes(name: "abc") ).to match_array(bicycle.versions[0..1]) expect(adapter).to have_received(:where_object_changes) end it "defaults to the original behavior" do adapter = Class.new.new PaperTrail.config.object_changes_adapter = adapter bicycle = Bicycle.create!(name: "abc") if column_datatype_override expect( bicycle.versions.where_object_changes(name: "abc") ).to match_array(bicycle.versions[0..1]) else expect do bicycle.versions.where_object_changes(name: "abc") end.to raise_error(/no longer supports reading YAML/) end end end # Only test json and jsonb columns. where_object_changes no longer # supports text columns. if column_datatype_override it "locates versions according to their object_changes contents" do widget.update!(name: name, an_integer: 0) widget.update!(name: "foobar", an_integer: 100) widget.update!(name: FFaker::Name.last_name, an_integer: int) expect( widget.versions.where_object_changes(name: name) ).to eq(widget.versions[0..1]) expect( widget.versions.where_object_changes(an_integer: 100) ).to eq(widget.versions[1..2]) expect( widget.versions.where_object_changes(an_integer: int) ).to eq([widget.versions.last]) expect( widget.versions.where_object_changes(an_integer: 100, name: "foobar") ).to eq(widget.versions[1..2]) end else it "raises error" do expect { widget.versions.where_object_changes(name: "foo").to_a }.to(raise_error(/no longer supports reading YAML from a text column/)) end end end describe "#where_object_changes_from", versioning: true do it "requires its argument to be a Hash" do expect { PaperTrail::Version.where_object_changes_from(:foo) }.to raise_error(ArgumentError) expect { PaperTrail::Version.where_object_changes_from([]) }.to raise_error(ArgumentError) end context "with object_changes_adapter configured" do after do PaperTrail.config.object_changes_adapter = nil end it "calls the adapter's where_object_changes_from method" do adapter = instance_spy("CustomObjectChangesAdapter") bicycle = Bicycle.create!(name: "abc") bicycle.update!(name: "xyz") allow(adapter).to( receive(:where_object_changes_from).with(Version, name: "abc") ).and_return([bicycle.versions[1]]) PaperTrail.config.object_changes_adapter = adapter expect( bicycle.versions.where_object_changes_from(name: "abc") ).to match_array([bicycle.versions[1]]) expect(adapter).to have_received(:where_object_changes_from) end it "defaults to the original behavior" do adapter = Class.new.new PaperTrail.config.object_changes_adapter = adapter bicycle = Bicycle.create!(name: "abc") bicycle.update!(name: "xyz") if column_datatype_override expect( bicycle.versions.where_object_changes_from(name: "abc") ).to match_array([bicycle.versions[1]]) else expect do bicycle.versions.where_object_changes_from(name: "abc") end.to raise_error(/does not support reading YAML/) end end end # Only test json and jsonb columns. where_object_changes_from does # not support text columns. if column_datatype_override it "locates versions according to their object_changes contents" do widget.update!(name: name, an_integer: 0) widget.update!(name: "foobar", an_integer: 100) widget.update!(name: FFaker::Name.last_name, an_integer: int) expect( widget.versions.where_object_changes_from(name: name) ).to eq([widget.versions[1]]) expect( widget.versions.where_object_changes_from(an_integer: 100) ).to eq([widget.versions[2]]) expect( widget.versions.where_object_changes_from(an_integer: int) ).to eq([]) expect( widget.versions.where_object_changes_from(an_integer: 100, name: "foobar") ).to eq([widget.versions[2]]) end else it "raises error" do expect { widget.versions.where_object_changes_from(name: "foo").to_a }.to(raise_error(/does not support reading YAML from a text column/)) end end end end end end end end paper_trail-12.0.0/spec/models/widget_spec.rb000066400000000000000000000176161403037104100211370ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe Widget, type: :model do let(:widget) { Widget.create! name: "Bob", an_integer: 1 } describe "`be_versioned` matcher" do it { is_expected.to be_versioned } end describe "`have_a_version_with` matcher", versioning: true do before do widget.update!(name: "Leonard", an_integer: 1) widget.update!(name: "Tom") widget.update!(name: "Bob") end it "is possible to do assertions on version attributes" do expect(widget).to have_a_version_with name: "Leonard", an_integer: 1 expect(widget).to have_a_version_with an_integer: 1 expect(widget).to have_a_version_with name: "Tom" end end describe "versioning option" do context "enabled", versioning: true do it "enables versioning" do expect(widget.versions.size).to eq(1) end end context "disabled (default)" do it "does not enable versioning" do expect(widget.versions.size).to eq(0) end end end describe "Callbacks", versioning: true do describe "before_save" do it "resets value for timestamp attrs for update so that value gets updated properly" do widget.update!(name: "Foobar") w = widget.versions.last.reify expect { w.save! }.to change(w, :updated_at) end end describe "after_create" do let(:widget) { Widget.create!(name: "Foobar", created_at: Time.current - 1.week) } it "corresponding version uses the widget's `updated_at`" do expect(widget.versions.last.created_at.to_i).to eq(widget.updated_at.to_i) end end describe "after_update" do before do widget.update!(name: "Foobar", updated_at: Time.current + 1.week) end it "clears the `versions_association_name` virtual attribute" do w = widget.versions.last.reify expect(w.paper_trail).not_to be_live w.save! expect(w.paper_trail).to be_live end it "corresponding version uses the widget updated_at" do expect(widget.versions.last.created_at.to_i).to eq(widget.updated_at.to_i) end end describe "after_destroy" do it "creates a version for that event" do expect { widget.destroy }.to change(widget.versions, :count).by(1) end it "assigns the version into the `versions_association_name`" do expect(widget.version).to be_nil widget.destroy expect(widget.version).not_to be_nil expect(widget.version).to eq(widget.versions.last) end end describe "after_rollback" do let(:rolled_back_name) { "Big Moo" } before do widget.transaction do widget.update!(name: rolled_back_name) widget.update!(name: Widget::EXCLUDED_NAME) end rescue ActiveRecord::RecordInvalid widget.reload widget.name = nil widget.save end it "does not create an event for changes that did not happen" do widget.versions.map(&:changeset).each do |changeset| expect(changeset.fetch("name", [])).not_to include(rolled_back_name) end end it "has not yet loaded the assocation" do expect(widget.versions).not_to be_loaded end end end describe "Association", versioning: true do describe "sort order" do it "sorts by the timestamp order from the `VersionConcern`" do expect(widget.versions.to_sql).to eq( widget.versions.reorder(PaperTrail::Version.timestamp_sort_order).to_sql ) end end end if defined?(ActiveRecord::IdentityMap) && ActiveRecord::IdentityMap.respond_to?(:without) describe "IdentityMap", versioning: true do it "does not clobber the IdentityMap when reifying" do widget.update name: "Henry", created_at: Time.current - 1.day widget.update name: "Harry" allow(ActiveRecord::IdentityMap).to receive(:without) widget.versions.last.reify expect(ActiveRecord::IdentityMap).to have_receive(:without).once end end end describe "#create", versioning: true do it "creates a version record" do wordget = Widget.create assert_equal 1, wordget.versions.length end end describe "#destroy", versioning: true do it "creates a version record" do widget = Widget.create assert_equal 1, widget.versions.length widget.destroy versions_for_widget = PaperTrail::Version.with_item_keys("Widget", widget.id) assert_equal 2, versions_for_widget.length end it "can have multiple destruction records" do versions = lambda { |widget| # Workaround for AR 3. When we drop AR 3 support, we can simply use # the `widget.versions` association, instead of `with_item_keys`. PaperTrail::Version.with_item_keys("Widget", widget.id) } widget = Widget.create assert_equal 1, widget.versions.length widget.destroy assert_equal 2, versions.call(widget).length widget = widget.version.reify widget.save assert_equal 3, versions.call(widget).length widget.destroy assert_equal 4, versions.call(widget).length assert_equal 2, versions.call(widget).where(event: "destroy").length end end describe "#paper_trail.originator", versioning: true do describe "return value" do let(:orig_name) { FFaker::Name.name } let(:new_name) { FFaker::Name.name } before do PaperTrail.request.whodunnit = orig_name end it "returns the originator for the model at a given state" do expect(widget.paper_trail).to be_live expect(widget.paper_trail.originator).to eq(orig_name) ::PaperTrail.request(whodunnit: new_name) { widget.update(name: "Elizabeth") } expect(widget.paper_trail.originator).to eq(new_name) end it "returns the appropriate originator" do widget.update(name: "Andy") PaperTrail.request.whodunnit = new_name widget.update(name: "Elizabeth") reified_widget = widget.versions[1].reify expect(reified_widget.paper_trail.originator).to eq(orig_name) expect(reified_widget).not_to be_new_record end it "can create a new instance with options[:dup]" do widget.update(name: "Andy") PaperTrail.request.whodunnit = new_name widget.update(name: "Elizabeth") reified_widget = widget.versions[1].reify(dup: true) expect(reified_widget.paper_trail.originator).to eq(orig_name) expect(reified_widget).to be_new_record end end end describe "#version_at", versioning: true do context "Timestamp argument is AFTER object has been destroyed" do it "returns nil" do widget.update_attribute(:name, "foobar") widget.destroy expect(widget.paper_trail.version_at(Time.current)).to be_nil end end end describe "touch", versioning: true do it "creates a version" do expect { widget.touch }.to change { widget.versions.count }.by(+1) end context "request is disabled" do it "does not create a version" do count = widget.versions.count PaperTrail.request(enabled: false) do widget.touch end expect(count).to eq(count) end end end describe ".paper_trail.update_columns", versioning: true do it "creates a version record" do widget = Widget.create expect(widget.versions.count).to eq(1) widget.paper_trail.update_columns(name: "Bugle") expect(widget.versions.count).to eq(2) expect(widget.versions.last.event).to(eq("update")) expect(widget.versions.last.changeset[:name]).to eq([nil, "Bugle"]) end end describe "#update", versioning: true do it "creates a version record" do widget = Widget.create assert_equal 1, widget.versions.length widget.update(name: "Bugle") assert_equal 2, widget.versions.length end end end paper_trail-12.0.0/spec/paper_trail/000077500000000000000000000000001403037104100173215ustar00rootroot00000000000000paper_trail-12.0.0/spec/paper_trail/attribute_serializers/000077500000000000000000000000001403037104100237405ustar00rootroot00000000000000paper_trail-12.0.0/spec/paper_trail/attribute_serializers/object_attribute_spec.rb000066400000000000000000000030631403037104100306320ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module AttributeSerializers ::RSpec.describe ObjectAttribute do if ENV["DB"] == "postgres" describe "postgres-specific column types" do describe "#serialize" do it "serializes a postgres array into a plain array" do attrs = { "post_ids" => [1, 2, 3] } described_class.new(PostgresUser).serialize(attrs) expect(attrs["post_ids"]).to eq [1, 2, 3] end end describe "#deserialize" do it "deserializes a plain array correctly" do attrs = { "post_ids" => [1, 2, 3] } described_class.new(PostgresUser).deserialize(attrs) expect(attrs["post_ids"]).to eq [1, 2, 3] end it "deserializes an array serialized with Rails <= 5.0.1 correctly" do attrs = { "post_ids" => "{1,2,3}" } described_class.new(PostgresUser).deserialize(attrs) expect(attrs["post_ids"]).to eq [1, 2, 3] end it "deserializes an array of time objects correctly" do date1 = 1.day.ago date2 = 2.days.ago date3 = 3.days.ago attrs = { "post_ids" => [date1, date2, date3] } described_class.new(PostgresUser).serialize(attrs) described_class.new(PostgresUser).deserialize(attrs) expect(attrs["post_ids"]).to eq [date1, date2, date3] end end end end end end end paper_trail-12.0.0/spec/paper_trail/cleaner_spec.rb000066400000000000000000000132471403037104100223000ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe Cleaner, versioning: true do describe "clean_versions!" do let(:animal) { ::Animal.new } let(:dog) { ::Dog.new } let(:cat) { ::Cat.new } let(:animals) { [animal, dog, cat] } before do animals.each do |animal| 3.times do animal.update_attribute(:name, FFaker::Name.name) end end end it "baseline test setup" do expect(PaperTrail::Version.count).to(eq(9)) animals.each { |animal| expect(animal.versions.size).to(eq(3)) } end context "no options provided" do it "removes extra versions for each item" do PaperTrail.clean_versions! expect(PaperTrail::Version.count).to(eq(3)) animals.each { |animal| expect(animal.versions.size).to(eq(1)) } end it "removes the earliest version(s)" do before = animals.map { |animal| animal.versions.last.reify.name } PaperTrail.clean_versions! after = animals.map { |animal| animal.versions.last.reify.name } expect(after).to(eq(before)) end end context "keeping 2" do it "keeps two records, instead of the usual one" do PaperTrail.clean_versions!(keeping: 2) expect(PaperTrail::Version.all.count).to(eq(6)) animals.each { |animal| expect(animal.versions.size).to(eq(2)) } end end context "with the :date option" do it "only deletes versions created on the given date" do animal.versions.each do |ver| ver.update_attribute(:created_at, (ver.created_at - 1.day)) end date = animal.versions.first.created_at.to_date animal.update_attribute(:name, FFaker::Name.name) expect(PaperTrail::Version.count).to(eq(10)) expect(animal.versions.size).to(eq(4)) expect(animal.paper_trail.versions_between(date, (date + 1.day)).size).to(eq(3)) PaperTrail.clean_versions!(date: date) expect(PaperTrail::Version.count).to(eq(8)) expect(animal.versions.reload.size).to(eq(2)) expect(animal.versions.first.created_at.to_date).to(eq(date)) # Why use `equal?` here instead of something less strict? # Doesn't `to_date` always produce a new date object? expect(date.equal?(animal.versions.last.created_at.to_date)).to eq(false) end end context "with the :item_id option" do context "single ID received" do it "only deletes the versions for the Item with that ID" do PaperTrail.clean_versions!(item_id: animal.id) expect(animal.versions.size).to(eq(1)) expect(PaperTrail::Version.count).to(eq(7)) end end context "collection of IDs received" do it "only deletes versions for the Item(s) with those IDs" do PaperTrail.clean_versions!(item_id: [animal.id, dog.id]) expect(animal.versions.size).to(eq(1)) expect(dog.versions.size).to(eq(1)) expect(PaperTrail::Version.count).to(eq(5)) end end end context "options combinations" do context ":date" do before do [animal, dog].each do |animal| animal.versions.each do |ver| ver.update_attribute(:created_at, (ver.created_at - 1.day)) end animal.update_attribute(:name, FFaker::Name.name) end end it "baseline test setup" do date = animal.versions.first.created_at.to_date expect(PaperTrail::Version.count).to(eq(11)) [animal, dog].each do |animal| expect(animal.versions.size).to(eq(4)) expect(animal.versions.between(date, (date + 1.day)).size).to(eq(3)) end end context "and :keeping" do it "restrict cleaning properly" do date = animal.versions.first.created_at.to_date PaperTrail.clean_versions!(date: date, keeping: 2) [animal, dog].each do |animal| animal.versions.reload expect(animal.versions.size).to(eq(3)) expect(animal.versions.between(date, (date + 1.day)).size).to(eq(2)) end expect(PaperTrail::Version.count).to(eq(9)) end end context "and :item_id" do it "restrict cleaning properly" do date = animal.versions.first.created_at.to_date PaperTrail.clean_versions!(date: date, item_id: dog.id) dog.versions.reload expect(dog.versions.size).to(eq(2)) expect(dog.versions.between(date, (date + 1.day)).size).to(eq(1)) expect(PaperTrail::Version.count).to(eq(9)) end end context ", :item_id, and :keeping" do it "restrict cleaning properly" do date = animal.versions.first.created_at.to_date PaperTrail.clean_versions!(date: date, item_id: dog.id, keeping: 2) dog.versions.reload expect(dog.versions.size).to(eq(3)) expect(dog.versions.between(date, (date + 1.day)).size).to(eq(2)) expect(PaperTrail::Version.count).to(eq(10)) end end end context ":keeping and :item_id" do it "restrict cleaning properly" do PaperTrail.clean_versions!(keeping: 2, item_id: animal.id) expect(animal.versions.size).to(eq(2)) expect(PaperTrail::Version.count).to(eq(8)) end end end end end end paper_trail-12.0.0/spec/paper_trail/compatibility_spec.rb000066400000000000000000000012561403037104100235350ustar00rootroot00000000000000# frozen_string_literal: true module PaperTrail ::RSpec.describe(Compatibility) do describe ".check_activerecord" do context "when compatible" do it "does not produce output" do ar_version = ::Gem::Version.new("6.0.0") expect { described_class.check_activerecord(ar_version) }.not_to output.to_stderr end end context "when incompatible" do it "writes a warning to stderr" do ar_version = ::Gem::Version.new("6.2.0") expect { described_class.check_activerecord(ar_version) }.to output(/not compatible/).to_stderr end end end end end paper_trail-12.0.0/spec/paper_trail/config_spec.rb000066400000000000000000000050531403037104100221300ustar00rootroot00000000000000# frozen_string_literal: true require "securerandom" require "spec_helper" module PaperTrail ::RSpec.describe Config do describe ".instance" do it "returns the singleton instance" do expect { described_class.instance }.not_to raise_error end end describe ".new" do it "raises NoMethodError" do expect { described_class.new }.to raise_error(NoMethodError) end end describe ".version_limit", versioning: true do after { PaperTrail.config.version_limit = nil } it "limits the number of versions to 3 (2 plus the created at event)" do PaperTrail.config.version_limit = 2 widget = Widget.create!(name: "Henry") 6.times { widget.update_attribute(:name, SecureRandom.hex(8)) } expect(widget.versions.first.event).to(eq("create")) expect(widget.versions.size).to(eq(3)) end it "overrides the general limits to 4 (3 plus the created at event)" do PaperTrail.config.version_limit = 100 bike = LimitedBicycle.create!(name: "Limited Bike") # has_paper_trail limit: 3 10.times { bike.update_attribute(:name, SecureRandom.hex(8)) } expect(bike.versions.first.event).to(eq("create")) expect(bike.versions.size).to(eq(4)) end it "overrides the general limits with unlimited versions for model" do PaperTrail.config.version_limit = 3 bike = UnlimitedBicycle.create!(name: "Unlimited Bike") # has_paper_trail limit: nil 6.times { bike.update_attribute(:name, SecureRandom.hex(8)) } expect(bike.versions.first.event).to(eq("create")) expect(bike.versions.size).to eq(7) end it "is not enabled on non-papertrail STI base classes, but enabled on subclasses" do PaperTrail.config.version_limit = 10 Vehicle.create!(name: "A Vehicle", type: "Vehicle") limited_bike = LimitedBicycle.create!(name: "Limited") limited_bike.name = "A new name" limited_bike.save assert_equal 2, limited_bike.versions.length end context "when item_subtype column is absent" do it "uses global version_limit" do PaperTrail.config.version_limit = 6 names = PaperTrail::Version.column_names - ["item_subtype"] allow(PaperTrail::Version).to receive(:column_names).and_return(names) bike = LimitedBicycle.create!(name: "My Bike") # has_paper_trail limit: 3 10.times { bike.update(name: SecureRandom.hex(8)) } assert_equal 7, bike.versions.length end end end end end paper_trail-12.0.0/spec/paper_trail/events/000077500000000000000000000000001403037104100206255ustar00rootroot00000000000000paper_trail-12.0.0/spec/paper_trail/events/base_spec.rb000066400000000000000000000044761403037104100231110ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module Events ::RSpec.describe Base do describe "#changed_notably?", versioning: true do context "new record" do it "returns true" do g = Gadget.new(created_at: Time.current) event = PaperTrail::Events::Base.new(g, false) expect(event.changed_notably?).to eq(true) end end context "persisted record without update timestamps" do it "only acknowledges non-ignored attrs" do gadget = Gadget.create!(created_at: Time.current) gadget.name = "Wrench" event = PaperTrail::Events::Base.new(gadget, false) expect(event.changed_notably?).to eq(true) end it "does not acknowledge ignored attr (brand)" do gadget = Gadget.create!(created_at: Time.current) gadget.brand = "Acme" event = PaperTrail::Events::Base.new(gadget, false) expect(event.changed_notably?).to eq(false) end end context "persisted record with update timestamps" do it "only acknowledges non-ignored attrs" do gadget = Gadget.create!(created_at: Time.current) gadget.name = "Wrench" gadget.updated_at = Time.current event = PaperTrail::Events::Base.new(gadget, false) expect(event.changed_notably?).to eq(true) end it "does not acknowledge ignored attrs and timestamps only" do gadget = Gadget.create!(created_at: Time.current) gadget.brand = "Acme" gadget.updated_at = Time.current event = PaperTrail::Events::Base.new(gadget, false) expect(event.changed_notably?).to eq(false) end end end describe "#nonskipped_attributes_before_change", versioning: true do it "returns a hash lacking the skipped attribute" do # Skipper has_paper_trail(..., skip: [:another_timestamp]) skipper = Skipper.create!(another_timestamp: Time.current) event = PaperTrail::Events::Base.new(skipper, false) attributes = event.send(:nonskipped_attributes_before_change, false) expect(attributes).not_to have_key("another_timestamp") end end end end end paper_trail-12.0.0/spec/paper_trail/events/destroy_spec.rb000066400000000000000000000030441403037104100236560ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module Events ::RSpec.describe Destroy do describe "#data", versioning: true do it "includes correct item_subtype" do carter = Family::CelebrityFamily.new( name: "Carter", path_to_stardom: "Mexican radio" ) data = PaperTrail::Events::Destroy.new(carter, true).data expect(data[:item_type]).to eq("Family::Family") expect(data[:item_subtype]).to eq("Family::CelebrityFamily") end context "skipper" do let(:skipper) { Skipper.create!(another_timestamp: Time.current) } let(:data) { PaperTrail::Events::Destroy.new(skipper, false).data } it "includes `object` without skipped attributes" do object = YAML.load(data[:object]) expect(object["id"]).to eq(skipper.id) expect(object).to have_key("updated_at") expect(object).to have_key("created_at") expect(object).not_to have_key("another_timestamp") end it "includes `object_changes` without skipped and ignored attributes" do changes = YAML.load(data[:object_changes]) expect(changes["id"]).to eq([skipper.id, nil]) expect(changes["updated_at"][0]).to be_present expect(changes["updated_at"][1]).to be_nil expect(changes).not_to have_key("created_at") expect(changes).not_to have_key("another_timestamp") end end end end end end paper_trail-12.0.0/spec/paper_trail/events/update_spec.rb000066400000000000000000000022701403037104100234470ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module Events ::RSpec.describe Update do describe "#data", versioning: true do context "is_touch false" do it "object_changes is present" do carter = Family::CelebrityFamily.create( name: "Carter", path_to_stardom: "Mexican radio" ) carter.path_to_stardom = "Johnny" data = PaperTrail::Events::Update.new(carter, false, false, nil).data expect(data[:object_changes]).to eq( <<~YAML --- path_to_stardom: - Mexican radio - Johnny YAML ) end end context "is_touch true" do it "object_changes is nil" do carter = Family::CelebrityFamily.create( name: "Carter", path_to_stardom: "Mexican radio" ) carter.path_to_stardom = "Johnny" data = PaperTrail::Events::Update.new(carter, false, true, nil).data expect(data[:object_changes]).to be_nil end end end end end end paper_trail-12.0.0/spec/paper_trail/model_config_spec.rb000066400000000000000000000067041403037104100233140ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe ModelConfig do describe "has_paper_trail" do describe "versions:" do it "name can be passed instead of an options hash", :deprecated do allow(::ActiveSupport::Deprecation).to receive(:warn) klass = Class.new(ActiveRecord::Base) do has_paper_trail versions: :drafts end expect(klass.reflect_on_association(:drafts)).to be_a( ActiveRecord::Reflection::HasManyReflection ) expect(::ActiveSupport::Deprecation).to have_received(:warn).once.with( a_string_starting_with("Passing versions association name"), array_including(/#{__FILE__}:/) ) end it "name can be passed in the options hash" do klass = Class.new(ActiveRecord::Base) do has_paper_trail versions: { name: :drafts } end expect(klass.reflect_on_association(:drafts)).to be_a( ActiveRecord::Reflection::HasManyReflection ) end it "class_name can be passed in the options hash" do klass = Class.new(ActiveRecord::Base) do has_paper_trail versions: { class_name: "NoObjectVersion" } end expect(klass.reflect_on_association(:versions).options[:class_name]).to eq( "NoObjectVersion" ) end it "allows any option that has_many supports" do klass = Class.new(ActiveRecord::Base) do has_paper_trail versions: { autosave: true, validate: true } end expect(klass.reflect_on_association(:versions).options[:autosave]).to eq true expect(klass.reflect_on_association(:versions).options[:validate]).to eq true end it "can even override options that PaperTrail adds to has_many" do klass = Class.new(ActiveRecord::Base) do has_paper_trail versions: { as: :foo } end expect(klass.reflect_on_association(:versions).options[:as]).to eq :foo end it "raises an error on unknown has_many options" do expect { Class.new(ActiveRecord::Base) do has_paper_trail versions: { read_my_mind: true, validate: true } end }.to raise_error( /Unknown key: :read_my_mind. Valid keys are: .*:class_name,/ ) end describe "passing an abstract class to class_name" do it "raises an error" do expect { Class.new(ActiveRecord::Base) do has_paper_trail versions: { class_name: "AbstractVersion" } end }.to raise_error( /use concrete \(not abstract\) version models/ ) end end end describe "class_name:" do it "can be used instead of versions: {class_name: ...}", :deprecated do allow(::ActiveSupport::Deprecation).to receive(:warn) klass = Class.new(ActiveRecord::Base) do has_paper_trail class_name: "NoObjectVersion" end expect(klass.reflect_on_association(:versions).options[:class_name]).to eq( "NoObjectVersion" ) expect(::ActiveSupport::Deprecation).to have_received(:warn).once.with( a_string_starting_with("Passing Version class name"), array_including(/#{__FILE__}:/) ) end end end end end paper_trail-12.0.0/spec/paper_trail/model_spec.rb000066400000000000000000000753041403037104100217710ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require "support/performance_helpers" RSpec.describe(::PaperTrail, versioning: true) do describe "#changeset" do it "has expected values" do widget = Widget.create(name: "Henry") changeset = widget.versions.last.changeset expect(changeset["name"]).to eq([nil, "Henry"]) expect(changeset["id"]).to eq([nil, widget.id]) # When comparing timestamps, round off to the nearest second, because # mysql doesn't do fractional seconds. expect(changeset["created_at"][0]).to be_nil expect(changeset["created_at"][1].to_i).to eq(widget.created_at.to_i) expect(changeset["updated_at"][0]).to be_nil expect(changeset["updated_at"][1].to_i).to eq(widget.updated_at.to_i) end context "custom object_changes_adapter" do after do PaperTrail.config.object_changes_adapter = nil end it "calls the adapter's load_changeset method" do widget = Widget.create(name: "Henry") adapter = instance_spy("CustomObjectChangesAdapter") PaperTrail.config.object_changes_adapter = adapter allow(adapter).to( receive(:load_changeset).with(widget.versions.last).and_return(a: "b", c: "d") ) changeset = widget.versions.last.changeset expect(changeset[:a]).to eq("b") expect(changeset[:c]).to eq("d") expect(adapter).to have_received(:load_changeset) end it "defaults to the original behavior" do adapter = Class.new.new PaperTrail.config.object_changes_adapter = adapter widget = Widget.create(name: "Henry") changeset = widget.versions.last.changeset expect(changeset[:name]).to eq([nil, "Henry"]) end end end context "a new record" do it "not have any previous versions" do expect(Widget.new.versions).to(eq([])) end it "be live" do expect(Widget.new.paper_trail.live?).to(eq(true)) end end context "a persisted record" do it "have one previous version" do widget = Widget.create(name: "Henry", created_at: (Time.current - 1.day)) expect(widget.versions.length).to(eq(1)) end it "be nil in its previous version" do widget = Widget.create(name: "Henry") expect(widget.versions.first.object).to(be_nil) expect(widget.versions.first.reify).to(be_nil) end it "record the correct event" do widget = Widget.create(name: "Henry") expect(widget.versions.first.event).to(match(/create/i)) end it "be live" do widget = Widget.create(name: "Henry") expect(widget.paper_trail.live?).to(eq(true)) end it "use the widget `updated_at` as the version's `created_at`" do widget = Widget.create(name: "Henry") expect(widget.versions.first.created_at.to_i).to(eq(widget.updated_at.to_i)) end context "and then updated without any changes" do it "to have two previous versions" do widget = Widget.create(name: "Henry") widget.touch expect(widget.versions.length).to eq(2) end end context "and then updated with changes" do it "have three previous versions" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") expect(widget.versions.length).to(eq(2)) end it "be available in its previous version" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") expect(widget.name).to(eq("Harry")) expect(widget.versions.last.object).not_to(be_nil) reified_widget = widget.versions.last.reify expect(reified_widget.name).to(eq("Henry")) expect(widget.name).to(eq("Harry")) end it "have the same ID in its previous version" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") expect(widget.versions.last.reify.id).to(eq(widget.id)) end it "record the correct event" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") expect(widget.versions.last.event).to(match(/update/i)) end it "have versions that are not live" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.versions.map(&:reify).compact.each do |v| expect(v.paper_trail).not_to be_live end end it "have stored changes" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") last_obj_changes = widget.versions.last.object_changes actual = PaperTrail.serializer.load(last_obj_changes).reject do |k, _v| (k.to_sym == :updated_at) end expect(actual).to(eq("name" => %w[Henry Harry])) actual = widget.versions.last.changeset.reject { |k, _v| (k.to_sym == :updated_at) } expect(actual).to(eq("name" => %w[Henry Harry])) end it "return changes with indifferent access" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") expect(widget.versions.last.changeset[:name]).to(eq(%w[Henry Harry])) expect(widget.versions.last.changeset["name"]).to(eq(%w[Henry Harry])) end end context "updated, and has one associated object" do it "not copy the has_one association by default when reifying" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") wotsit = widget.create_wotsit name: "John" reified_widget = widget.versions.last.reify expect(reified_widget.wotsit).to eq(wotsit) expect(widget.reload.wotsit).to eq(wotsit) end end context "updated, and has many associated objects" do it "copy the has_many associations when reifying" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.fluxors.create(name: "f-zero") widget.fluxors.create(name: "f-one") reified_widget = widget.versions.last.reify expect(reified_widget.fluxors.length).to(eq(widget.fluxors.length)) expect(reified_widget.fluxors).to match_array(widget.fluxors) expect(reified_widget.versions.length).to(eq(widget.versions.length)) expect(reified_widget.versions).to match_array(widget.versions) end end context "updated, and has many associated polymorphic objects" do it "copy the has_many associations when reifying" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.whatchamajiggers.create(name: "f-zero") widget.whatchamajiggers.create(name: "f-zero") reified_widget = widget.versions.last.reify expect(reified_widget.whatchamajiggers.length).to eq(widget.whatchamajiggers.length) expect(reified_widget.whatchamajiggers).to match_array(widget.whatchamajiggers) expect(reified_widget.versions.length).to(eq(widget.versions.length)) expect(reified_widget.versions).to match_array(widget.versions) end end context "updated, polymorphic objects by themselves" do it "not fail with a nil pointer on the polymorphic association" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget = Whatchamajigger.new(name: "f-zero") widget.save! end end context "updated, and then destroyed" do it "record the correct event" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.destroy expect(PaperTrail::Version.last.event).to(match(/destroy/i)) end it "have three previous versions" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.destroy expect(PaperTrail::Version.with_item_keys("Widget", widget.id).length).to(eq(3)) end it "returns the expected attributes for the reified widget" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.destroy reified_widget = PaperTrail::Version.last.reify expect(reified_widget.id).to eq(widget.id) expected = widget.attributes actual = reified_widget.attributes expect(expected["id"]).to eq(actual["id"]) expect(expected["name"]).to eq(actual["name"]) expect(expected["a_text"]).to eq(actual["a_text"]) expect(expected["an_integer"]).to eq(actual["an_integer"]) expect(expected["a_float"]).to eq(actual["a_float"]) expect(expected["a_decimal"]).to eq(actual["a_decimal"]) expect(expected["a_datetime"]).to eq(actual["a_datetime"]) expect(expected["a_time"]).to eq(actual["a_time"]) expect(expected["a_date"]).to eq(actual["a_date"]) expect(expected["a_boolean"]).to eq(actual["a_boolean"]) expect(expected["type"]).to eq(actual["type"]) # We are using `to_i` to truncate to the nearest second, but isn't # there still a chance of this failing intermittently if # ___ and ___ occured more than 0.5s apart? expect(expected["created_at"].to_i).to eq(actual["created_at"].to_i) expect(expected["updated_at"].to_i).to eq(actual["updated_at"].to_i) end it "be re-creatable from its previous version" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.destroy reified_widget = PaperTrail::Version.last.reify expect(reified_widget.save).to(be_truthy) end it "restore its associations on its previous version" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.fluxors.create(name: "flux") widget.destroy reified_widget = PaperTrail::Version.last.reify reified_widget.save expect(reified_widget.fluxors.length).to(eq(1)) end it "have nil item for last version" do widget = Widget.create(name: "Henry") widget.update(name: "Harry") widget.destroy expect(widget.versions.last.item).to be_nil end it "has changes" do book = Book.create! title: "A" changes = YAML.load book.versions.last.attributes["object_changes"] expect(changes).to eq("id" => [nil, book.id], "title" => [nil, "A"]) book.update! title: "B" changes = YAML.load book.versions.last.attributes["object_changes"] expect(changes).to eq("title" => %w[A B]) book.destroy changes = YAML.load book.versions.last.attributes["object_changes"] expect(changes).to eq("id" => [book.id, nil], "title" => ["B", nil]) end end end context "a record's papertrail" do let!(:d0) { Date.new(2009, 5, 29) } let!(:t0) { Time.current } let(:previous_widget) { widget.versions.last.reify } let(:widget) { Widget.create( name: "Warble", a_text: "The quick brown fox", an_integer: 42, a_float: 153.01, a_decimal: 2.71828, a_datetime: t0, a_time: t0, a_date: d0, a_boolean: true ) } before do widget.update( name: nil, a_text: nil, an_integer: nil, a_float: nil, a_decimal: nil, a_datetime: nil, a_time: nil, a_date: nil, a_boolean: false ) end it "handle strings" do expect(previous_widget.name).to(eq("Warble")) end it "handle text" do expect(previous_widget.a_text).to(eq("The quick brown fox")) end it "handle integers" do expect(previous_widget.an_integer).to(eq(42)) end it "handle floats" do assert_in_delta(153.01, previous_widget.a_float, 0.001) end it "handle decimals" do assert_in_delta(2.7183, previous_widget.a_decimal, 0.0001) end it "handle datetimes" do expect(previous_widget.a_datetime.to_time.utc.to_i).to(eq(t0.to_time.utc.to_i)) end it "handle times (time only, no date)" do format = ->(t) { t.utc.strftime "%H:%M:%S" } expect(format[previous_widget.a_time]).to eq(format[t0]) end it "handle dates" do expect(previous_widget.a_date).to(eq(d0)) end it "handle booleans" do expect(previous_widget.a_boolean).to(be_truthy) end context "after a column is removed from the record's schema" do let(:last_version) { widget.versions.last } it "reify previous version" do assert_kind_of(Widget, last_version.reify) end it "restore all forward-compatible attributes" do reified = last_version.reify expect(reified.name).to(eq("Warble")) expect(reified.a_text).to(eq("The quick brown fox")) expect(reified.an_integer).to(eq(42)) assert_in_delta(153.01, reified.a_float, 0.001) assert_in_delta(2.7183, reified.a_decimal, 0.0001) expect(reified.a_datetime.to_time.utc.to_i).to(eq(t0.to_time.utc.to_i)) format = ->(t) { t.utc.strftime "%H:%M:%S" } expect(format[reified.a_time]).to eq(format[t0]) expect(reified.a_date).to(eq(d0)) expect(reified.a_boolean).to(be_truthy) end end end context "A record" do context "with PaperTrail globally disabled, when updated" do after { PaperTrail.enabled = true } it "not add to its trail" do widget = Widget.create(name: "Zaphod") PaperTrail.enabled = false count = widget.versions.length widget.update(name: "Beeblebrox") expect(widget.versions.length).to(eq(count)) end end context "with its paper trail turned off, when updated" do after do PaperTrail.request.enable_model(Widget) end it "not add to its trail" do widget = Widget.create(name: "Zaphod") PaperTrail.request.disable_model(Widget) count = widget.versions.length widget.update(name: "Beeblebrox") expect(widget.versions.length).to(eq(count)) end it "add to its trail" do widget = Widget.create(name: "Zaphod") PaperTrail.request.disable_model(Widget) count = widget.versions.length widget.update(name: "Beeblebrox") PaperTrail.request.enable_model(Widget) widget.update(name: "Ford") expect(widget.versions.length).to(eq((count + 1))) end end end context "A papertrail with somebody making changes" do context "when a record is created" do it "tracks who made the change" do widget = Widget.new(name: "Fidget") PaperTrail.request.whodunnit = "Alice" widget.save version = widget.versions.last expect(version.whodunnit).to(eq("Alice")) expect(version.paper_trail_originator).to(be_nil) expect(version.terminator).to(eq("Alice")) expect(widget.paper_trail.originator).to(eq("Alice")) end end context "when created, then updated" do it "tracks who made the change" do widget = Widget.new(name: "Fidget") PaperTrail.request.whodunnit = "Alice" widget.save PaperTrail.request.whodunnit = "Bob" widget.update(name: "Rivet") version = widget.versions.last expect(version.whodunnit).to(eq("Bob")) expect(version.paper_trail_originator).to(eq("Alice")) expect(version.terminator).to(eq("Bob")) expect(widget.paper_trail.originator).to(eq("Bob")) end end context "when created, updated, and destroyed" do it "tracks who made the change" do widget = Widget.new(name: "Fidget") PaperTrail.request.whodunnit = "Alice" widget.save PaperTrail.request.whodunnit = "Bob" widget.update(name: "Rivet") PaperTrail.request.whodunnit = "Charlie" widget.destroy version = PaperTrail::Version.last expect(version.whodunnit).to(eq("Charlie")) expect(version.paper_trail_originator).to(eq("Bob")) expect(version.terminator).to(eq("Charlie")) expect(widget.paper_trail.originator).to(eq("Charlie")) end end end it "update! records timestamps" do wotsit = Wotsit.create!(name: "wotsit") wotsit.update!(name: "changed") reified = wotsit.versions.last.reify expect(reified.created_at).not_to(be_nil) expect(reified.updated_at).not_to(be_nil) end it "update! does not raise error" do wotsit = Wotsit.create!(name: "name1") expect { wotsit.update!(name: "name2") }.not_to(raise_error) end context "A subclass" do let(:foo) { FooWidget.create } before do foo.update!(name: "Foo") end it "reify with the correct type" do expect(PaperTrail::Version.last.previous).to(eq(foo.versions.first)) expect(PaperTrail::Version.last.next).to(be_nil) end it "returns the correct originator" do PaperTrail.request.whodunnit = "Ben" foo.update_attribute(:name, "Geoffrey") expect(foo.paper_trail.originator).to(eq(PaperTrail.request.whodunnit)) end context "when destroyed" do before { foo.destroy } it "reify with the correct type" do assert_kind_of(FooWidget, foo.versions.last.reify) expect(PaperTrail::Version.last.previous).to(eq(foo.versions[1])) expect(PaperTrail::Version.last.next).to(be_nil) end end end context "An item with versions" do context "which were created over time" do let(:widget) { Widget.create(name: "Widget") } let(:t0) { 2.days.ago } let(:t1) { 1.day.ago } let(:t2) { 1.hour.ago } before do widget.update(name: "Fidget") widget.update(name: "Digit") widget.versions[0].update(created_at: t0) widget.versions[1].update(created_at: t1) widget.versions[2].update(created_at: t2) widget.update_attribute(:updated_at, t2) end it "return nil for version_at before it was created" do expect(widget.paper_trail.version_at((t0 - 1))).to(be_nil) end it "return how it looked when created for version_at its creation" do expect(widget.paper_trail.version_at(t0).name).to(eq("Widget")) end it "return how it looked before its first update" do expect(widget.paper_trail.version_at((t1 - 1)).name).to(eq("Widget")) end it "return how it looked after its first update" do expect(widget.paper_trail.version_at(t1).name).to(eq("Fidget")) end it "return how it looked before its second update" do expect(widget.paper_trail.version_at((t2 - 1)).name).to(eq("Fidget")) end it "return how it looked after its second update" do expect(widget.paper_trail.version_at(t2).name).to(eq("Digit")) end it "return the current object for version_at after latest update" do expect(widget.paper_trail.version_at(1.day.from_now).name).to(eq("Digit")) end it "still return a widget when appropriate, when passing timestamp as string" do expect( widget.paper_trail.version_at((t0 + 1.second).to_s).name ).to(eq("Widget")) expect( widget.paper_trail.version_at((t1 + 1.second).to_s).name ).to(eq("Fidget")) expect( widget.paper_trail.version_at((t2 + 1.second).to_s).name ).to(eq("Digit")) end end describe ".versions_between" do it "return versions in the time period" do widget = Widget.create(name: "Widget") widget.update(name: "Fidget") widget.update(name: "Digit") widget.versions[0].update(created_at: 30.days.ago) widget.versions[1].update(created_at: 15.days.ago) widget.versions[2].update(created_at: 1.day.ago) widget.update_attribute(:updated_at, 1.day.ago) expect( widget.paper_trail.versions_between(20.days.ago, 10.days.ago).map(&:name) ).to(eq(["Fidget"])) expect( widget.paper_trail.versions_between(45.days.ago, 10.days.ago).map(&:name) ).to(eq(%w[Widget Fidget])) expect( widget.paper_trail.versions_between(16.days.ago, 1.minute.ago).map(&:name) ).to(eq(%w[Fidget Digit Digit])) expect( widget.paper_trail.versions_between(60.days.ago, 45.days.ago).map(&:name) ).to(eq([])) end end context "on the first version" do let(:widget) { Widget.create(name: "Widget") } let(:version) { widget.versions.last } before do widget = Widget.create(name: "Widget") widget.update(name: "Fidget") widget.update(name: "Digit") end it "have a nil previous version" do expect(version.previous).to(be_nil) end it "return the next version" do expect(version.next).to(eq(widget.versions[1])) end it "return the correct index" do expect(version.index).to(eq(0)) end end context "on the last version" do let(:widget) { Widget.create(name: "Widget") } let(:version) { widget.versions.last } before do widget.update(name: "Fidget") widget.update(name: "Digit") end it "return the previous version" do expect(version.previous).to(eq(widget.versions[(widget.versions.length - 2)])) end it "have a nil next version" do expect(version.next).to(be_nil) end it "return the correct index" do expect(version.index).to(eq((widget.versions.length - 1))) end end end context "An item" do let(:article) { Article.new(title: initial_title) } let(:initial_title) { "Foobar" } context "which is created" do before { article.save } it "store fixed meta data" do expect(article.versions.last.answer).to(eq(42)) end it "store dynamic meta data which is independent of the item" do expect(article.versions.last.question).to(eq("31 + 11 = 42")) end it "store dynamic meta data which depends on the item" do expect(article.versions.last.article_id).to(eq(article.id)) end it "store dynamic meta data based on a method of the item" do expect(article.versions.last.action).to(eq(article.action_data_provider_method)) end it "store dynamic meta data based on an attribute of the item at creation" do expect(article.versions.last.title).to(eq(initial_title)) end end context "created, then updated" do before do article.save article.update!(content: "Better text.", title: "Rhubarb") end it "store fixed meta data" do expect(article.versions.last.answer).to(eq(42)) end it "store dynamic meta data which is independent of the item" do expect(article.versions.last.question).to(eq("31 + 11 = 42")) end it "store dynamic meta data which depends on the item" do expect(article.versions.last.article_id).to(eq(article.id)) end it "store dynamic meta data based on an attribute of the item prior to the update" do expect(article.versions.last.title).to(eq(initial_title)) end end context "created, then destroyed" do before do article.save article.destroy end it "store fixed metadata" do expect(article.versions.last.answer).to(eq(42)) end it "store dynamic metadata which is independent of the item" do expect(article.versions.last.question).to(eq("31 + 11 = 42")) end it "store dynamic metadata which depends on the item" do expect(article.versions.last.article_id).to(eq(article.id)) end it "store dynamic metadata based on attribute of item prior to destruction" do expect(article.versions.last.title).to(eq(initial_title)) end end end context "A reified item" do it "know which version it came from, and return its previous self" do widget = Widget.create(name: "Bob") %w[Tom Dick Jane].each do |name| widget.update(name: name) end version = widget.versions.last widget = version.reify expect(widget.version).to(eq(version)) expect(widget.paper_trail.previous_version).to(eq(widget.versions[-2].reify)) end end describe "#next_version" do context "a reified item" do it "returns the object (not a Version) as it became next" do widget = Widget.create(name: "Bob") %w[Tom Dick Jane].each do |name| widget.update(name: name) end second_widget = widget.versions[1].reify last_widget = widget.versions.last.reify expect(second_widget.paper_trail.next_version.name).to(eq(widget.versions[2].reify.name)) expect(widget.name).to(eq(last_widget.paper_trail.next_version.name)) end end context "a non-reified item" do it "always returns nil because cannot ever have a next version" do widget = Widget.new expect(widget.paper_trail.next_version).to(be_nil) widget.save %w[Tom Dick Jane].each do |name| widget.update(name: name) end expect(widget.paper_trail.next_version).to(be_nil) end end end describe "#previous_version" do context "a reified item" do it "returns the object (not a Version) as it was most recently" do widget = Widget.create(name: "Bob") %w[Tom Dick Jane].each do |name| widget.update(name: name) end second_widget = widget.versions[1].reify last_widget = widget.versions.last.reify expect(second_widget.paper_trail.previous_version).to(be_nil) expect(last_widget.paper_trail.previous_version.name).to(eq(widget.versions[-2].reify.name)) end end context "a non-reified item" do it "returns the object (not a Version) as it was most recently" do widget = Widget.new expect(widget.paper_trail.previous_version).to(be_nil) widget.save %w[Tom Dick Jane].each do |name| widget.update(name: name) end expect(widget.paper_trail.previous_version.name).to(eq(widget.versions.last.reify.name)) end end end context ":has_many :through" do it "store version on source <<" do book = Book.create(title: "War and Peace") dostoyevsky = Person.create(name: "Dostoyevsky") Person.create(name: "Solzhenitsyn") count = PaperTrail::Version.count (book.authors << dostoyevsky) expect((PaperTrail::Version.count - count)).to(eq(1)) expect(book.authorships.first.versions.first).to(eq(PaperTrail::Version.last)) end it "store version on source create" do book = Book.create(title: "War and Peace") Person.create(name: "Dostoyevsky") Person.create(name: "Solzhenitsyn") count = PaperTrail::Version.count book.authors.create(name: "Tolstoy") expect((PaperTrail::Version.count - count)).to(eq(2)) expect( [PaperTrail::Version.order(:id).to_a[-2].item, PaperTrail::Version.last.item] ).to match_array([Person.last, Authorship.last]) end it "store version on join destroy" do book = Book.create(title: "War and Peace") dostoyevsky = Person.create(name: "Dostoyevsky") Person.create(name: "Solzhenitsyn") (book.authors << dostoyevsky) count = PaperTrail::Version.count book.authorships.reload.last.destroy expect((PaperTrail::Version.count - count)).to(eq(1)) expect(PaperTrail::Version.last.reify.book).to(eq(book)) expect(PaperTrail::Version.last.reify.author).to(eq(dostoyevsky)) end it "store version on join clear" do book = Book.create(title: "War and Peace") dostoyevsky = Person.create(name: "Dostoyevsky") Person.create(name: "Solzhenitsyn") book.authors << dostoyevsky count = PaperTrail::Version.count book.authorships.reload.destroy_all expect((PaperTrail::Version.count - count)).to(eq(1)) expect(PaperTrail::Version.last.reify.book).to(eq(book)) expect(PaperTrail::Version.last.reify.author).to(eq(dostoyevsky)) end end context "the default accessor, length=, is overwritten" do it "returns overwritten value on reified instance" do song = Song.create(length: 4) song.update(length: 5) expect(song.length).to(eq(5)) expect(song.versions.last.reify.length).to(eq(4)) end end context "song name is a virtual attribute (no such db column)" do it "returns overwritten virtual attribute on the reified instance" do song = Song.create(length: 4) song.update(length: 5) song.name = "Good Vibrations" song.save song.name = "Yellow Submarine" expect(song.name).to(eq("Yellow Submarine")) expect(song.versions.last.reify.name).to(eq("Good Vibrations")) end end context "An unsaved record" do it "not have a version created on destroy" do widget = Widget.new widget.destroy expect(widget.versions.empty?).to(eq(true)) end end context "Memory allocation of" do let(:widget) do Widget.new( name: "Warble", a_text: "The quick brown fox", an_integer: 42, a_float: 153.01, a_decimal: 2.71828, a_boolean: true ) end before do # Json fields for `object` & `object_changes` attributes is most efficient way # to do the things - this way we will save even more RAM, as well as will skip # the whole YAML serialization allow(PaperTrail::Version).to receive(:object_changes_col_is_json?).and_return(true) allow(PaperTrail::Version).to receive(:object_col_is_json?).and_return(true) # Force the loading of all lazy things like class definitions, # in order to get the pure benchmark version_building.call end describe "#build_version_on_create" do let(:version_building) do lambda do widget.paper_trail.send( :build_version_on_create, in_after_callback: false ) end end it "is frugal enough" do # Some time ago there was 95kbs.. # At the time of commit the test passes with assertion on 17kbs. # Lets assert 20kbs then, to avoid flaky fails. expect(&version_building).to allocate_less_than(20).kilobytes end end describe "#build_version_on_update" do let(:widget) do super().tap do |w| w.save! w.attributes = { name: "Dostoyevsky", a_text: "The slow yellow mouse", an_integer: 84, a_float: 306.02, a_decimal: 5.43656, a_boolean: false } end end let(:version_building) do lambda do widget.paper_trail.send( :build_version_on_update, force: false, in_after_callback: false, is_touch: false ) end end it "is frugal enough" do # Some time ago there was 144kbs.. # At the time of commit the test passes with assertion on 27kbs. # Lets assert 35kbs then, to avoid flaky fails. expect(&version_building).to allocate_less_than(35).kilobytes end end end end paper_trail-12.0.0/spec/paper_trail/request_spec.rb000066400000000000000000000125521403037104100223550ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe(Request, versioning: true) do describe ".enabled_for_model?" do it "returns true" do expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(true) end end describe ".disable_model" do after do PaperTrail.request.enable_model(Widget) end it "sets enabled_for_model? to false" do expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(true) PaperTrail.request.disable_model(Widget) expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(false) end end describe ".enabled_for_model" do after do PaperTrail.request.enable_model(Widget) end it "sets enabled_for_model? to true" do PaperTrail.request.enabled_for_model(Widget, false) expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(false) PaperTrail.request.enabled_for_model(Widget, true) expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(true) end end describe ".enabled?" do it "returns true" do expect(PaperTrail.request.enabled?).to eq(true) end end describe ".enabled=" do after do PaperTrail.request.enabled = true end it "sets enabled? to true" do PaperTrail.request.enabled = true expect(PaperTrail.request.enabled?).to eq(true) PaperTrail.request.enabled = false expect(PaperTrail.request.enabled?).to eq(false) end end describe ".controller_info" do it "returns an empty hash" do expect(PaperTrail.request.controller_info).to eq({}) end end describe ".controller_info=" do after do PaperTrail.request.controller_info = {} end it "sets controller_info" do PaperTrail.request.controller_info = { foo: :bar } expect(PaperTrail.request.controller_info).to eq(foo: :bar) end end describe ".enable_model" do after do PaperTrail.request.enable_model(Widget) end it "sets enabled_for_model? to true" do PaperTrail.request.disable_model(Widget) expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(false) PaperTrail.request.enable_model(Widget) expect(PaperTrail.request.enabled_for_model?(Widget)).to eq(true) end end describe ".whodunnit" do context "when set to a proc" do it "evaluates the proc each time a version is made" do call_count = 0 described_class.whodunnit = proc { call_count += 1 } expect(described_class.whodunnit).to eq(1) expect(described_class.whodunnit).to eq(2) end end context "when set to a primtive value" do it "returns the primitive value" do described_class.whodunnit = :some_whodunnit expect(described_class.whodunnit).to eq(:some_whodunnit) end end end describe ".with" do context "block given" do context "all allowed options" do it "sets options only for the block passed" do described_class.whodunnit = "some_whodunnit" described_class.enabled_for_model(Widget, true) described_class.with(whodunnit: "foo", enabled_for_Widget: false) do expect(described_class.whodunnit).to eq("foo") expect(described_class.enabled_for_model?(Widget)).to eq false end expect(described_class.whodunnit).to eq "some_whodunnit" expect(described_class.enabled_for_model?(Widget)).to eq true end it "sets options only for the current thread" do described_class.whodunnit = "some_whodunnit" described_class.enabled_for_model(Widget, true) described_class.with(whodunnit: "foo", enabled_for_Widget: false) do expect(described_class.whodunnit).to eq("foo") expect(described_class.enabled_for_model?(Widget)).to eq false Thread.new { expect(described_class.whodunnit).to be_nil }.join Thread.new { expect(described_class.enabled_for_model?(Widget)).to eq true }.join end expect(described_class.whodunnit).to eq "some_whodunnit" expect(described_class.enabled_for_model?(Widget)).to eq true end end context "some invalid options" do it "raises an invalid option error" do subject = proc do described_class.with(whodunnit: "blah", invalid_option: "foo") do raise "This block should not be reached" end end expect { subject.call }.to raise_error(PaperTrail::Request::InvalidOption) do |e| expect(e.message).to eq "Invalid option: invalid_option" end end end context "all invalid options" do it "raises an invalid option error" do subject = proc do described_class.with(invalid_option: "foo", other_invalid_option: "blah") do raise "This block should not be reached" end end expect { subject.call }.to raise_error(PaperTrail::Request::InvalidOption) do |e| expect(e.message).to eq "Invalid option: invalid_option" end end end end end end end paper_trail-12.0.0/spec/paper_trail/serializer_spec.rb000066400000000000000000000066011403037104100230340ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require "support/custom_json_serializer" RSpec.describe(PaperTrail, versioning: true) do context "YAML serializer" do it "saves the expected YAML in the object column" do customer = Customer.create(name: "Some text.") original_attributes = PaperTrail::Events::Base. new(customer, false). send(:nonskipped_attributes_before_change, false) customer.update(name: "Some more text.") expect(customer.versions.length).to(eq(2)) expect(customer.versions[0].reify).to(be_nil) expect(customer.versions[1].reify.name).to(eq("Some text.")) expect(YAML.load(customer.versions[1].object)).to(eq(original_attributes)) expect(customer.versions[1].object).to(eq(YAML.dump(original_attributes))) end end context "JSON Serializer" do before do PaperTrail.configure do |config| config.serializer = PaperTrail::Serializers::JSON end end after do PaperTrail.config.serializer = PaperTrail::Serializers::YAML end it "reify with JSON serializer" do customer = Customer.create(name: "Some text.") original_attributes = PaperTrail::Events::Base. new(customer, false). send(:nonskipped_attributes_before_change, false) customer.update(name: "Some more text.") expect(customer.versions.length).to(eq(2)) expect(customer.versions[0].reify).to(be_nil) expect(customer.versions[1].reify.name).to(eq("Some text.")) expect(ActiveSupport::JSON.decode(customer.versions[1].object)).to(eq(original_attributes)) expect(customer.versions[1].object).to(eq(ActiveSupport::JSON.encode(original_attributes))) end describe "#changeset" do it "returns the expected hash" do customer = Customer.create(name: "Some text.") customer.update(name: "Some more text.") initial_changeset = { "name" => [nil, "Some text."], "id" => [nil, customer.id] } second_changeset = { "name" => ["Some text.", "Some more text."] } expect(customer.versions[0].changeset).to(eq(initial_changeset)) expect(customer.versions[1].changeset).to(eq(second_changeset)) end end end context "Custom Serializer" do before do PaperTrail.configure { |config| config.serializer = CustomJsonSerializer } end after do PaperTrail.config.serializer = PaperTrail::Serializers::YAML end it "reify with custom serializer" do customer = Customer.create original_attributes = PaperTrail::Events::Base. new(customer, false). send(:nonskipped_attributes_before_change, false). compact customer.update(name: "Some more text.") expect(customer.versions.length).to(eq(2)) expect(customer.versions[0].reify).to(be_nil) expect(customer.versions[1].reify.name).to(be_nil) expect( ActiveSupport::JSON.decode(customer.versions[1].object) ).to eq(original_attributes) expect( customer.versions[1].object ).to eq(ActiveSupport::JSON.encode(original_attributes)) end describe "#changeset" do it "store object_changes" do customer = Customer.create customer.update(name: "banana") expect(customer.versions[0].changeset).to eq("id" => [nil, customer.id]) expect(customer.versions[1].changeset).to eq("name" => [nil, "banana"]) end end end end paper_trail-12.0.0/spec/paper_trail/serializers/000077500000000000000000000000001403037104100216555ustar00rootroot00000000000000paper_trail-12.0.0/spec/paper_trail/serializers/custom_json_serializer_spec.rb000066400000000000000000000011761403037104100300150ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" require_relative "../../support/custom_json_serializer" RSpec.describe CustomJsonSerializer do describe ".load" do it "deserializes, removing pairs with blank keys or values" do hash = { "key1" => "banana", "tkey" => nil, "" => "foo" } expect(described_class.load(hash.to_json)).to(eq("key1" => "banana")) end end describe ".dump" do it "serializes to JSON, removing pairs with nil values" do hash = { "key1" => "banana", "tkey" => nil, "" => "foo" } expect(described_class.dump(hash)).to(eq('{"key1":"banana","":"foo"}')) end end end paper_trail-12.0.0/spec/paper_trail/serializers/custom_yaml_serializer_spec.rb000066400000000000000000000020041403037104100277750ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module CustomYamlSerializer extend PaperTrail::Serializers::YAML def self.load(string) parsed_value = super(string) if parsed_value.is_a?(Hash) parsed_value.reject { |k, v| (k.blank? || v.blank?) } else parsed_value end end def self.dump(object) object.is_a?(Hash) ? super(object.compact) : super end end RSpec.describe CustomYamlSerializer do let(:word_hash) { { "key1" => ::FFaker::Lorem.word, "key2" => nil, "tkey" => nil, "" => "foo" } } describe ".load" do it("deserializes YAML to Ruby, removing pairs with blank keys or values") do expect(described_class.load(word_hash.to_yaml)).to eq( word_hash.reject { |k, v| (k.blank? || v.blank?) } ) end end describe ".dump" do it("serializes Ruby to YAML, removing pairs with nil values") do expect(described_class.dump(word_hash)).to eq( word_hash.compact.to_yaml ) end end end paper_trail-12.0.0/spec/paper_trail/serializers/json_spec.rb000066400000000000000000000051501403037104100241660ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module Serializers ::RSpec.describe JSON do let(:word_hash) { (1..4).each_with_object({}) { |i, a| a["key#{i}"] = ::FFaker::Lorem.word } } let(:word_array) { [].fill(0, rand(4..8)) { ::FFaker::Lorem.word } } describe ".load" do it "deserialize JSON to Ruby" do expect(described_class.load(word_hash.to_json)).to eq(word_hash) expect(described_class.load(word_array.to_json)).to eq(word_array) end end describe ".dump" do it "serializes Ruby to JSON" do expect(described_class.dump(word_hash)).to eq(word_hash.to_json) expect(described_class.dump(word_array)).to eq(word_array.to_json) end end describe ".where_object_condition" do context "when value is a string" do it "construct correct WHERE query" do matches = described_class. where_object_condition(PaperTrail::Version.arel_table[:object], :arg1, "Val 1") expect(matches.instance_of?(Arel::Nodes::Matches)).to(eq(true)) expect(arel_value(matches.right)).to eq("%\"arg1\":\"Val 1\"%") end end context "when value is null" do it "construct correct WHERE query" do matches = described_class. where_object_condition(PaperTrail::Version.arel_table[:object], :arg1, nil) expect(matches.instance_of?(Arel::Nodes::Matches)).to(eq(true)) expect(arel_value(matches.right)).to(eq("%\"arg1\":null%")) end end context "when value is a number" do it "construct correct WHERE query" do grouping = described_class. where_object_condition(PaperTrail::Version.arel_table[:object], :arg1, -3.5) expect(grouping.instance_of?(Arel::Nodes::Grouping)).to(eq(true)) disjunction = grouping.expr expect(disjunction).to be_an(Arel::Nodes::Or) dj_left = disjunction.left expect(dj_left).to be_an(Arel::Nodes::Matches) expect(arel_value(dj_left.right)).to eq("%\"arg1\":-3.5,%") dj_right = disjunction.right expect(dj_right).to be_an(Arel::Nodes::Matches) expect(arel_value(dj_right.right)).to eq("%\"arg1\":-3.5}%") end end end describe ".where_object_changes_condition" do it "raises error" do expect { described_class.where_object_changes_condition }.to raise_error(/no longer supports/) end end end end end paper_trail-12.0.0/spec/paper_trail/serializers/yaml_spec.rb000066400000000000000000000026311403037104100241600ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail module Serializers ::RSpec.describe(YAML, versioning: true) do let(:array) { ::Array.new(10) { ::FFaker::Lorem.word } } let(:hash) { { alice: "bob", binary: 0xdeadbeef, octal_james_bond: 0o7, int: 42, float: 4.2 } } let(:hash_with_indifferent_access) { HashWithIndifferentAccess.new(hash) } describe ".load" do it "deserializes YAML to Ruby" do expect(described_class.load(hash.to_yaml)).to eq(hash) expect(described_class.load(array.to_yaml)).to eq(array) end end describe ".dump" do it "serializes Ruby to YAML" do expect(described_class.dump(hash)).to eq(hash.to_yaml) expect(described_class.dump(hash_with_indifferent_access)). to eq(hash.stringify_keys.to_yaml) expect(described_class.dump(array)).to eq(array.to_yaml) end end describe ".where_object" do it "constructs the correct WHERE query" do matches = described_class.where_object_condition( ::PaperTrail::Version.arel_table[:object], :arg1, "Val 1" ) expect(matches.instance_of?(Arel::Nodes::Matches)).to(eq(true)) expect(arel_value(matches.right)).to eq("%\narg1: Val 1\n%") end end end end end paper_trail-12.0.0/spec/paper_trail/version_limit_spec.rb000066400000000000000000000045001403037104100235420ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe Cleaner, versioning: true do after do PaperTrail.config.version_limit = nil end it "cleans up old versions with limit specified in model" do PaperTrail.config.version_limit = 10 # LimitedBicycle overrides the global version_limit bike = LimitedBicycle.create(name: "Bike") # has_paper_trail limit: 3 15.times do |i| bike.update(name: "Name #{i}") end expect(LimitedBicycle.find(bike.id).versions.count).to eq(4) # 4 versions = 3 updates + 1 create. end it "cleans up old versions" do PaperTrail.config.version_limit = 10 widget = Widget.create 100.times do |i| widget.update(name: "Name #{i}") expect(Widget.find(widget.id).versions.count).to be <= 11 # 11 versions = 10 updates + 1 create. end end it "deletes oldest versions, when the database returns them in a different order" do epoch = Date.new(2017, 1, 1) widget = Widget.create(created_at: epoch) # Sometimes a database will returns records in a different order than # they were inserted. That's hard to get the database to do, so we'll # just create them out-of-order: (1..5).to_a.shuffle.each do |n| PaperTrail::Version.create!( created_at: epoch + n.hours, item: widget, event: "update", object: { "id" => widget.id, "name" => "Name #{n}" }.to_yaml ) end expect(Widget.find(widget.id).versions.count).to eq(6) # 1 create + 5 updates # Now, we've recreated the scenario where we can accidentally clean up # the wrong versions. Re-enable the version_limit, and trigger the # clean-up: PaperTrail.config.version_limit = 3 widget.versions.last.send(:enforce_version_limit!) # Verify that we have fewer versions: expect(widget.reload.versions.count).to eq(4) # 1 create + 4 updates # Exclude the create, because the create will return nil for `#reify`. last_names = widget.versions.not_creates.map(&:reify).map(&:name) expect(last_names).to eq(["Name 3", "Name 4", "Name 5"]) # No matter what order the version records are returned it, we should # always keep the most-recent changes. end end end paper_trail-12.0.0/spec/paper_trail/version_number_spec.rb000066400000000000000000000007111403037104100237140ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe VERSION do describe "STRING" do it "joins the numbers into a period separated string" do expect(described_class::STRING).to eq( [ described_class::MAJOR, described_class::MINOR, described_class::TINY, described_class::PRE ].compact.join(".") ) end end end end paper_trail-12.0.0/spec/paper_trail/version_spec.rb000066400000000000000000000056541403037104100223570ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" module PaperTrail ::RSpec.describe(Version, versioning: true) do describe ".creates" do it "returns only create events" do animal = Animal.create(name: "Foo") animal.update(name: "Bar") expect(described_class.creates.pluck(:event)).to eq(["create"]) end end describe ".updates" do it "returns only update events" do animal = Animal.create animal.update(name: "Animal") expect(described_class.updates.pluck(:event)).to eq(["update"]) end end describe ".destroys" do it "returns only destroy events" do animal = Animal.create animal.destroy expect(described_class.destroys.pluck(:event)).to eq(["destroy"]) end end describe ".not_creates" do it "returns all versions except create events" do animal = Animal.create animal.update(name: "Animal") animal.destroy expect( described_class.not_creates.pluck(:event) ).to match_array(%w[update destroy]) end end describe ".subsequent" do context "given a timestamp" do it "returns all versions that were created after the timestamp" do animal = Animal.create 2.times do animal.update(name: FFaker::Lorem.word) end value = described_class.subsequent(1.hour.ago, true) expect(value).to eq(animal.versions.to_a) expect(value.to_sql).to match( /ORDER BY #{described_class.arel_table[:created_at].asc.to_sql}/ ) end end context "given a Version" do it "grab the timestamp from the version and use that as the value" do animal = Animal.create 2.times do animal.update(name: FFaker::Lorem.word) end expect(described_class.subsequent(animal.versions.first)).to eq( animal.versions.to_a.drop(1) ) end end end describe ".preceding" do context "given a timestamp" do it "returns all versions that were created before the timestamp" do animal = Animal.create 2.times do animal.update(name: FFaker::Lorem.word) end value = described_class.preceding(5.seconds.from_now, true) expect(value).to eq(animal.versions.reverse) expect(value.to_sql).to match( /ORDER BY #{described_class.arel_table[:created_at].desc.to_sql}/ ) end end context "given a Version" do it "grab the timestamp from the version and use that as the value" do animal = Animal.create 2.times do animal.update(name: FFaker::Lorem.word) end expect(described_class.preceding(animal.versions.last)).to eq( animal.versions.to_a.tap(&:pop).reverse ) end end end end end paper_trail-12.0.0/spec/paper_trail_spec.rb000066400000000000000000000057421403037104100206700ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe PaperTrail do describe ".request" do it "returns the value returned by the block" do expect(described_class.request(whodunnit: "abe lincoln") { "A test" }).to eq("A test") end end describe "#config", versioning: true do it "allows for config values to be set" do expect(described_class.config.enabled).to eq(true) described_class.config.enabled = false expect(described_class.config.enabled).to eq(false) end it "accepts blocks and yield the config instance" do expect(described_class.config.enabled).to eq(true) described_class.config { |c| c.enabled = false } expect(described_class.config.enabled).to eq(false) end end describe "#configure" do it "is an alias for the `config` method" do expect(described_class.method(:configure)).to eq( described_class.method(:config) ) end end describe ".gem_version" do it "returns a Gem::Version" do v = described_class.gem_version expect(v).to be_a(::Gem::Version) expect(v.to_s).to eq(::PaperTrail::VERSION::STRING) end end context "when enabled" do after do described_class.enabled = true end it "affects all threads" do Thread.new { described_class.enabled = false }.join assert_equal false, described_class.enabled? end end context "default" do it "has versioning off by default" do expect(described_class).not_to be_enabled end it "has versioning on in a `with_versioning` block" do expect(described_class).not_to be_enabled with_versioning do expect(described_class).to be_enabled end expect(described_class).not_to be_enabled end context "error within `with_versioning` block" do it "reverts the value of `PaperTrail.enabled?` to its previous state" do expect(described_class).not_to be_enabled expect { with_versioning { raise } }.to raise_error(RuntimeError) expect(described_class).not_to be_enabled end end end context "`versioning: true`", versioning: true do it "has versioning on by default" do expect(described_class).to be_enabled end it "keeps versioning on after a with_versioning block" do expect(described_class).to be_enabled with_versioning do expect(described_class).to be_enabled end expect(described_class).to be_enabled end end context "`with_versioning` block at class level" do it { expect(described_class).not_to be_enabled } with_versioning do it "has versioning on by default" do expect(described_class).to be_enabled end end it "does not leak the `enabled?` state into successive tests" do expect(described_class).not_to be_enabled end end describe ".version" do it "returns the expected String" do expect(described_class.version).to eq(described_class::VERSION::STRING) end end end paper_trail-12.0.0/spec/requests/000077500000000000000000000000001403037104100166725ustar00rootroot00000000000000paper_trail-12.0.0/spec/requests/articles_spec.rb000066400000000000000000000016561403037104100220470ustar00rootroot00000000000000# frozen_string_literal: true require "spec_helper" RSpec.describe "Articles management", type: :request, order: :defined do let(:valid_params) { { article: { title: "Doh", content: FFaker::Lorem.sentence } } } context "versioning disabled" do specify { expect(PaperTrail).not_to be_enabled } it "does not create a version" do expect(PaperTrail.request).to be_enabled expect { post articles_path, params: valid_params }.not_to change(PaperTrail::Version, :count) end end with_versioning do let(:article) { Article.last } context "`current_user` method returns a `String`" do it "sets that value as the `whodunnit`" do expect { post articles_path, params: valid_params }.to change(PaperTrail::Version, :count).by(1) expect(article.title).to eq("Doh") expect(article.versions.last.whodunnit).to eq("foobar") end end end end paper_trail-12.0.0/spec/spec_helper.rb000066400000000000000000000054671403037104100176510ustar00rootroot00000000000000# frozen_string_literal: true ENV["RAILS_ENV"] ||= "test" ENV["DB"] ||= "sqlite" require "byebug" require_relative "support/pt_arel_helpers" unless ENV["BUNDLE_GEMFILE"].match?(/rails_\d\.\d\.gemfile/) warn( "It looks like you're trying to run the PT test suite, but you're not " \ 'using appraisal. Please see "Development" in CONTRIBUTING.md.' ) exit 1 end unless File.exist?(File.expand_path("dummy_app/config/database.yml", __dir__)) warn "No database.yml detected for the dummy app, please run `rake install_database_yml` first" exit 1 end RSpec.configure do |config| config.example_status_persistence_file_path = ".rspec_results" config.expect_with :rspec do |expectations| expectations.include_chain_clauses_in_custom_matcher_descriptions = true end config.mock_with :rspec do |mocks| mocks.verify_partial_doubles = true end config.filter_run :focus config.run_all_when_everything_filtered = true config.disable_monkey_patching! config.warnings = false if config.files_to_run.one? config.default_formatter = "doc" end config.order = :random config.include PTArelHelpers Kernel.srand config.seed end # At this point, totally isolated unit tests could be run. But the PT test suite # also has "integration" tests, via a "dummy" Rails app. Here, we boot that # "dummy" app. The following process follows the same order, roughly, as a # conventional Rails app. # # In the past, this boot process was partially implemented here, and partially # in `dummy_app/config/*`. By consolidating it here, # # - It can better be understood, and documented in one place # - It can more closely resememble a conventional app boot. For example, loading # gems (like rspec-rails) _before_ loading the app. # First, `config/boot.rb` would add gems to $LOAD_PATH. Bundler.setup # Then, the chosen components of Rails would be loaded. In our case, we only # test with AR and AC. require "active_record/railtie" require "action_controller/railtie" # Then, gems are loaded. In a conventional Rails app, this would be done with # by the `Bundler.require` in `config/application.rb`. require "paper_trail" require "ffaker" require "rspec/rails" require "rails-controller-testing" # Now we can load our dummy app. Its boot process does not perfectly match a # conventional Rails app, but it's what we were able to fit in our test suite. require File.expand_path("dummy_app/config/environment", __dir__) # Now that AR has a connection pool, we can migrate the database. require_relative "support/paper_trail_spec_migrator" ::PaperTrailSpecMigrator.new.migrate # This final section reselmbles what might be dummy_app's spec_helper, if it # had one. require "paper_trail/frameworks/rspec" RSpec.configure do |config| config.fixture_path = nil # we use factories, not fixtures config.use_transactional_fixtures = true end paper_trail-12.0.0/spec/support/000077500000000000000000000000001403037104100165335ustar00rootroot00000000000000paper_trail-12.0.0/spec/support/custom_json_serializer.rb000066400000000000000000000006131403037104100236540ustar00rootroot00000000000000# frozen_string_literal: true # This custom serializer excludes nil values module CustomJsonSerializer extend PaperTrail::Serializers::JSON def self.load(string) parsed_value = super(string) parsed_value.is_a?(Hash) ? parsed_value.reject { |k, v| k.blank? || v.blank? } : parsed_value end def self.dump(object) object.is_a?(Hash) ? super(object.compact) : super end end paper_trail-12.0.0/spec/support/custom_object_changes_adapter.rb000066400000000000000000000005711403037104100251130ustar00rootroot00000000000000# frozen_string_literal: true # This custom serializer excludes nil values class CustomObjectChangesAdapter def diff(changes) changes end def load_changeset(version) version.changeset end def where_object_changes(klass, attributes) klass.where(attributes) end def where_object_changes_from(klass, attributes) klass.where(attributes) end end paper_trail-12.0.0/spec/support/paper_trail_spec_migrator.rb000066400000000000000000000057001403037104100243020ustar00rootroot00000000000000# frozen_string_literal: true # AR 6.1 does not autoload MigrationContext, so we must `require` it. # # ``` # # lib/active_record.rb # autoload :Migration # autoload :Migrator, "active_record/migration" # ``` # # The above may indicate that we should use `Migrator` instead of # MigrationContext. require "active_record/migration" # Manage migrations including running generators to build them, and cleaning up strays class PaperTrailSpecMigrator def initialize @migrations_path = dummy_app_migrations_dir end # Looks like the API for programatically running migrations will change # in rails 5.2. This is an undocumented change, AFAICT. Then again, # how many people use the programmatic interface? Most people probably # just use rake. Maybe we're doing it wrong. # # See also discussion in https://github.com/rails/rails/pull/40806, when # MigrationContext#migrate became public. def migrate if ::ActiveRecord.gem_version >= ::Gem::Version.new("6.0.0.rc2") ::ActiveRecord::MigrationContext.new( @migrations_path, ::ActiveRecord::Base.connection.schema_migration ).migrate else ::ActiveRecord::MigrationContext.new(@migrations_path).migrate end end # Generate a migration, run it, and delete it. We use this for testing the # UpdateStiGenerator. We delete the file because we don't want it to exist # when we run migrations at the beginning of the next full test suite run. # # - generator [String] - name of generator, eg. "paper_trail:update_sti" # - generator_invoke_args [Array] - arguments to `Generators#invoke` def generate_and_migrate(generator, generator_invoke_args) generate(generator, generator_invoke_args) begin migrate ensure cmd = "git clean -x --force --quiet " + dummy_app_migrations_dir.to_s unless system(cmd) raise "Unable to clean up after PT migration generator test" end end end private def dummy_app_migrations_dir Pathname.new(File.expand_path("../dummy_app/db/migrate", __dir__)) end # Run the specified migration generator. # # We sleep until the next whole second because that is the precision of the # timestamp that rails puts in generator filenames. If we didn't sleep, # there's a good chance two tests would run within the same second and # generate the same exact migration filename. Then, even though we delete the # generated migrations after running them, some form of caching (perhaps # filesystem, perhaps rails) will run the cached migration file. # # - generator [String] - name of generator, eg. "paper_trail:update_sti" # - generator_invoke_args [Array] - arguments to `Generators#invoke` def generate(generator, generator_invoke_args) sleep_until_the_next_whole_second Rails::Generators.invoke(generator, generator_invoke_args, destination_root: Rails.root) end def sleep_until_the_next_whole_second t = Time.current.to_f sleep((t.ceil - t).abs + 0.01) end end paper_trail-12.0.0/spec/support/performance_helpers.rb000066400000000000000000000014131403037104100231020ustar00rootroot00000000000000# frozen_string_literal: true require "memory_profiler" RSpec::Matchers.define :allocate_less_than do |expected| supports_block_expectations chain :bytes do @scale = :bs end chain :kilobytes do @scale = :kbs end chain :and_print_report do @report = true end match do |actual| @scale ||= :bs benchmark = MemoryProfiler.report(ignore_files: /rspec/) { actual.call } if @report benchmark.pretty_print(detailed_report: true, scale_bytes: true) end @allocated = benchmark.total_allocated_memsize @allocated /= 1024 if @scale == :kbs @allocated <= expected end failure_message do "expected that example will allocate less than #{expected}#{@scale},"\ " but allocated #{@allocated}#{@scale}" end end paper_trail-12.0.0/spec/support/pt_arel_helpers.rb000066400000000000000000000002621403037104100222300ustar00rootroot00000000000000# frozen_string_literal: true module PTArelHelpers def arel_value(node) if node.respond_to?(:val) # rails < 6.1 node.val else node.value end end end