mirror of
https://github.com/paper-trail-gem/paper_trail.git
synced 2022-11-09 11:33:19 -05:00
Merge pull request #1103 from paper-trail-gem/remove_association_tracking
Extract the experimental association tracking feature into separate gem
This commit is contained in:
commit
80aeebf562
25 changed files with 50 additions and 1143 deletions
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
16
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -48,27 +48,11 @@ ActiveRecord::Schema.define do
|
|||
t.string :whodunnit
|
||||
t.text :object, limit: 1_073_741_823
|
||||
t.text :object_changes, limit: 1_073_741_823
|
||||
t.integer :transaction_id
|
||||
t.datetime :created_at
|
||||
end
|
||||
add_index :versions, %i[item_type item_id]
|
||||
add_index :versions, [:transaction_id]
|
||||
|
||||
create_table :version_associations do |t|
|
||||
t.integer :version_id
|
||||
t.string :foreign_key_name, null: false
|
||||
t.integer :foreign_key_id
|
||||
end
|
||||
add_index :version_associations, [:version_id]
|
||||
add_index :version_associations, %i[foreign_key_name foreign_key_id],
|
||||
name: "index_version_associations_on_foreign_key"
|
||||
end
|
||||
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
||||
require "paper_trail/config"
|
||||
|
||||
# STEP THREE: Configure PaperTrail as you would in your initializer
|
||||
PaperTrail::Config.instance.track_associations = false
|
||||
|
||||
require "paper_trail"
|
||||
|
||||
# STEP FOUR: Define your AR models here.
|
||||
|
|
|
@ -11,6 +11,11 @@ recommendations of [keepachangelog.com](http://keepachangelog.com/).
|
|||
|
||||
### Added
|
||||
|
||||
- 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` - Allows specifying an adapter that will
|
||||
determine how the changes for each version are stored in the object_changes column.
|
||||
|
|
222
README.md
222
README.md
|
@ -61,8 +61,9 @@ has been destroyed.
|
|||
- [7.c. Cucumber](#7c-cucumber)
|
||||
- [7.d. Spork](#7d-spork)
|
||||
- [7.e. Zeus or Spring](#7e-zeus-or-spring)
|
||||
- [8. Integration with Other Libraries](#8-integration-with-other-libraries)
|
||||
- [9. Related Libraries and Ports](#9-related-libraries-and-ports)
|
||||
- [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)
|
||||
|
@ -841,204 +842,18 @@ string, please try the [paper_trail-globalid][37] gem.
|
|||
|
||||
### 4.b. Associations
|
||||
|
||||
**Experimental feature with many known issues. Not recommended for production.**
|
||||
See known issues below.
|
||||
To track and reify associations, use [paper_trail-association_tracking][6] (PT-AT).
|
||||
|
||||
PaperTrail can restore three types of associations: Has-One, Has-Many, and
|
||||
Has-Many-Through. In order to do this, you will need to do two things:
|
||||
From 2014 to 2018, association tracking was part of PT core as 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).
|
||||
|
||||
1. Create a `version_associations` table
|
||||
2. Set `PaperTrail.config.track_associations = true` (e.g. in an initializer)
|
||||
|
||||
Both will be done for you automatically if you install PaperTrail with the
|
||||
`--with_associations` option
|
||||
(e.g. `rails generate paper_trail:install --with-associations`)
|
||||
|
||||
If you want to add this functionality after the initial installation, you will
|
||||
need to create the `version_associations` table manually, and you will need to
|
||||
ensure that `PaperTrail.config.track_associations = true` is set.
|
||||
|
||||
PaperTrail will store in the `version_associations` table additional information
|
||||
to correlate versions of the association and versions of the model when the
|
||||
associated record is changed. When reifying the model, PaperTrail can use this
|
||||
table, together with the `transaction_id` to find the correct version of the
|
||||
association and reify it. The `transaction_id` is a unique id for version records
|
||||
created in the same transaction. It is used to associate the version of the model
|
||||
and the version of the association that are created in the same transaction.
|
||||
|
||||
To restore Has-One associations as they were at the time, pass option `has_one:
|
||||
true` to `reify`. To restore Has-Many and Has-Many-Through associations, use
|
||||
option `has_many: true`. To restore Belongs-To association, use
|
||||
option `belongs_to: true`. For example:
|
||||
|
||||
```ruby
|
||||
class Location < ActiveRecord::Base
|
||||
belongs_to :treasure
|
||||
has_paper_trail
|
||||
end
|
||||
|
||||
class Treasure < ActiveRecord::Base
|
||||
has_one :location
|
||||
has_paper_trail
|
||||
end
|
||||
|
||||
treasure.amount # 100
|
||||
treasure.location.latitude # 12.345
|
||||
|
||||
treasure.update_attributes amount: 153
|
||||
treasure.location.update_attributes latitude: 54.321
|
||||
|
||||
t = treasure.versions.last.reify(has_one: true)
|
||||
t.amount # 100
|
||||
t.location.latitude # 12.345
|
||||
```
|
||||
|
||||
If the parent and child are updated in one go, PaperTrail can use the
|
||||
aforementioned `transaction_id` to reify the models as they were before the
|
||||
transaction (instead of before the update to the model).
|
||||
|
||||
```ruby
|
||||
treasure.amount # 100
|
||||
treasure.location.latitude # 12.345
|
||||
|
||||
Treasure.transaction do
|
||||
treasure.location.update_attributes latitude: 54.321
|
||||
treasure.update_attributes amount: 153
|
||||
end
|
||||
|
||||
t = treasure.versions.last.reify(has_one: true)
|
||||
t.amount # 100
|
||||
t.location.latitude # 12.345, instead of 54.321
|
||||
```
|
||||
|
||||
By default, PaperTrail excludes an associated record from the reified parent
|
||||
model if the associated record exists in the live model but did not exist as at
|
||||
the time the version was created. This is usually what you want if you just want
|
||||
to look at the reified version. But if you want to persist it, it would be
|
||||
better to pass in option `mark_for_destruction: true` so that the associated
|
||||
record is included and marked for destruction. Note that `mark_for_destruction`
|
||||
only has [an effect on associations marked with `autosave: true`][32].
|
||||
|
||||
```ruby
|
||||
class Widget < ActiveRecord::Base
|
||||
has_paper_trail
|
||||
has_one :wotsit, autosave: true
|
||||
end
|
||||
|
||||
class Wotsit < ActiveRecord::Base
|
||||
has_paper_trail
|
||||
belongs_to :widget
|
||||
end
|
||||
|
||||
widget = Widget.create(name: 'widget_0')
|
||||
widget.update_attributes(name: 'widget_1')
|
||||
widget.create_wotsit(name: 'wotsit')
|
||||
|
||||
widget_0 = widget.versions.last.reify(has_one: true)
|
||||
widget_0.wotsit # nil
|
||||
|
||||
widget_0 = widget.versions.last.reify(has_one: true, mark_for_destruction: true)
|
||||
widget_0.wotsit.marked_for_destruction? # true
|
||||
widget_0.save!
|
||||
widget.reload.wotsit # nil
|
||||
```
|
||||
|
||||
#### 4.b.1. Known Issues
|
||||
|
||||
Associations are an **experimental feature** and have the following known
|
||||
issues, in order of descending importance.
|
||||
|
||||
1. PaperTrail only reifies the first level of associations.
|
||||
1. Sometimes the has_one association will find more than one possible candidate
|
||||
and will raise a `PaperTrail::Reifiers::HasOne::FoundMoreThanOne` error. For
|
||||
example, see `spec/models/person_spec.rb`
|
||||
- If you are not using STI, you may want to just assume the first result (of
|
||||
multiple) is the correct one and continue. Versions pre v8.1.2 and below did
|
||||
this without error or warning. To do so add the following line to your
|
||||
initializer: `PaperTrail.config.association_reify_error_behaviour = :warn`.
|
||||
Valid options are: `[:error, :warn, :ignore]`
|
||||
- When using STI, even if you enable `:warn` you will likely still end up
|
||||
recieving an `ActiveRecord::AssociationTypeMismatch` error.
|
||||
1. [#542](https://github.com/paper-trail-gem/paper_trail/issues/542) -
|
||||
Not compatible with [transactional tests][34], aka. transactional fixtures.
|
||||
1. Requires database timestamp columns with fractional second precision.
|
||||
- Sqlite and postgres timestamps have fractional second precision by default.
|
||||
[MySQL timestamps do not][35]. Furthermore, MySQL 5.5 and earlier do not
|
||||
support fractional second precision at all.
|
||||
- Also, support for fractional seconds in MySQL was not added to
|
||||
rails until ActiveRecord 4.2 (https://github.com/rails/rails/pull/14359).
|
||||
1. PaperTrail can't restore an association properly if the association record
|
||||
can be updated to replace its parent model (by replacing the foreign key)
|
||||
1. Currently PaperTrail only supports a single `version_associations` table.
|
||||
Therefore, you can only use a single table to store the versions for
|
||||
all related models. Sorry for those who use multiple version tables.
|
||||
1. PaperTrail relies on the callbacks on the association model (and the :through
|
||||
association model for Has-Many-Through associations) to record the versions
|
||||
and the relationship between the versions. If the association is changed
|
||||
without invoking the callbacks, Reification won't work. Below are some
|
||||
examples:
|
||||
|
||||
Given these models:
|
||||
|
||||
```ruby
|
||||
class Book < ActiveRecord::Base
|
||||
has_many :authorships, dependent: :destroy
|
||||
has_many :authors, through: :authorships, source: :person
|
||||
has_paper_trail
|
||||
end
|
||||
|
||||
class Authorship < ActiveRecord::Base
|
||||
belongs_to :book
|
||||
belongs_to :person
|
||||
has_paper_trail # NOTE
|
||||
end
|
||||
|
||||
class Person < ActiveRecord::Base
|
||||
has_many :authorships, dependent: :destroy
|
||||
has_many :books, through: :authorships
|
||||
has_paper_trail
|
||||
end
|
||||
```
|
||||
|
||||
Then each of the following will store authorship versions:
|
||||
|
||||
```ruby
|
||||
@book.authors << @dostoyevsky
|
||||
@book.authors.create name: 'Tolstoy'
|
||||
@book.authorships.last.destroy
|
||||
@book.authorships.clear
|
||||
@book.author_ids = [@solzhenistyn.id, @dostoyevsky.id]
|
||||
```
|
||||
|
||||
But none of these will:
|
||||
|
||||
```ruby
|
||||
@book.authors.delete @tolstoy
|
||||
@book.author_ids = []
|
||||
@book.authors = []
|
||||
```
|
||||
|
||||
Having said that, you can apparently get all these working (I haven't tested it
|
||||
myself) with this patch:
|
||||
|
||||
```ruby
|
||||
# In config/initializers/active_record_patch.rb
|
||||
module ActiveRecord
|
||||
# = Active Record Has Many Through Association
|
||||
module Associations
|
||||
class HasManyThroughAssociation < HasManyAssociation #:nodoc:
|
||||
alias_method :original_delete_records, :delete_records
|
||||
|
||||
def delete_records(records, method)
|
||||
method ||= :destroy
|
||||
original_delete_records(records, method)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
See [issue 113][16] for a discussion about this.
|
||||
Even though this has always been an experimental feature, we don't want the
|
||||
extraction of PT-AT to be a breaking change. So, `paper_trail` will have a
|
||||
runtime dependency on this gem and will keep running the existing tests related
|
||||
to association tracking. This arrangement will be maintained for a few years, if
|
||||
practical.
|
||||
|
||||
### 4.c. Storing Metadata
|
||||
|
||||
|
@ -1158,7 +973,6 @@ Usage:
|
|||
|
||||
Options:
|
||||
[--with-changes], [--no-with-changes] # Store changeset (diff) with each version
|
||||
[--with-associations], [--no-with-associations] # Store transactional IDs to support association restoration
|
||||
|
||||
Runtime options:
|
||||
-f, [--force] # Overwrite files that already exist
|
||||
|
@ -1621,7 +1435,11 @@ require 'rspec/rails'
|
|||
require 'paper_trail/frameworks/rspec'
|
||||
```
|
||||
|
||||
## 8. Integration with Other Libraries
|
||||
## 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
|
||||
|
@ -1630,11 +1448,10 @@ require 'paper_trail/frameworks/rspec'
|
|||
- Sinatra - [paper_trail-sinatra][41]
|
||||
- [globalize][45] - [globalize-versioning][44]
|
||||
- [solidus_papertrail][47] - PT integration for Solidus
|
||||
- [paper_trail-globalid][49] - enhances whodunnint by adding an `actor`
|
||||
method to instances of PaperTrail::Version that returns the ActiveRecord
|
||||
object who was responsible for change
|
||||
|
||||
## 9. Related Libraries and Ports
|
||||
## 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
|
||||
|
@ -1683,6 +1500,7 @@ Released under the MIT licence.
|
|||
[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
|
||||
|
|
|
@ -35,27 +35,11 @@ ActiveRecord::Schema.define do
|
|||
t.string :whodunnit
|
||||
t.text :object, limit: 1_073_741_823
|
||||
t.text :object_changes, limit: 1_073_741_823
|
||||
t.integer :transaction_id
|
||||
t.datetime :created_at
|
||||
end
|
||||
add_index :versions, %i[item_type item_id]
|
||||
add_index :versions, [:transaction_id]
|
||||
|
||||
create_table :version_associations do |t|
|
||||
t.integer :version_id
|
||||
t.string :foreign_key_name, null: false
|
||||
t.integer :foreign_key_id
|
||||
end
|
||||
add_index :version_associations, [:version_id]
|
||||
add_index :version_associations, %i[foreign_key_name foreign_key_id],
|
||||
name: "index_version_associations_on_foreign_key"
|
||||
end
|
||||
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
||||
require "paper_trail/config"
|
||||
|
||||
# STEP THREE: Configure PaperTrail as you would in your initializer
|
||||
PaperTrail::Config.instance.track_associations = true
|
||||
|
||||
require "paper_trail"
|
||||
|
||||
# STEP FOUR: Define your AR models here.
|
||||
|
|
|
@ -23,12 +23,6 @@ module PaperTrail
|
|||
default: false,
|
||||
desc: "Store changeset (diff) with each version"
|
||||
)
|
||||
class_option(
|
||||
:with_associations,
|
||||
type: :boolean,
|
||||
default: false,
|
||||
desc: "Store transactional IDs to support association restoration"
|
||||
)
|
||||
|
||||
desc "Generates (but does not run) a migration to add a versions table." \
|
||||
" Also generates an initializer file for configuring PaperTrail"
|
||||
|
@ -36,18 +30,6 @@ module PaperTrail
|
|||
def create_migration_file
|
||||
add_paper_trail_migration("create_versions")
|
||||
add_paper_trail_migration("add_object_changes_to_versions") if options.with_changes?
|
||||
if options.with_associations?
|
||||
add_paper_trail_migration("create_version_associations")
|
||||
add_paper_trail_migration("add_transaction_id_column_to_versions")
|
||||
end
|
||||
end
|
||||
|
||||
def create_initializer
|
||||
create_file(
|
||||
"config/initializers/paper_trail.rb",
|
||||
"PaperTrail.config.track_associations = #{!!options.with_associations?}\n",
|
||||
"PaperTrail.config.association_reify_error_behaviour = :error"
|
||||
)
|
||||
end
|
||||
|
||||
def self.next_migration_number(dirname)
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# This migration and CreateVersionAssociations provide the necessary
|
||||
# schema for tracking associations.
|
||||
class AddTransactionIdColumnToVersions < ActiveRecord::Migration<%= migration_version %>
|
||||
def self.up
|
||||
add_column :versions, :transaction_id, :integer
|
||||
add_index :versions, [:transaction_id]
|
||||
end
|
||||
|
||||
def self.down
|
||||
remove_index :versions, [:transaction_id]
|
||||
remove_column :versions, :transaction_id
|
||||
end
|
||||
end
|
|
@ -1,22 +0,0 @@
|
|||
# This migration and AddTransactionIdColumnToVersions provide the necessary
|
||||
# schema for tracking associations.
|
||||
class CreateVersionAssociations < ActiveRecord::Migration<%= migration_version %>
|
||||
def self.up
|
||||
create_table :version_associations do |t|
|
||||
t.integer :version_id
|
||||
t.string :foreign_key_name, null: false
|
||||
t.integer :foreign_key_id
|
||||
end
|
||||
add_index :version_associations, [:version_id]
|
||||
add_index :version_associations,
|
||||
%i(foreign_key_name foreign_key_id),
|
||||
name: "index_version_associations_on_foreign_key"
|
||||
end
|
||||
|
||||
def self.down
|
||||
remove_index :version_associations, [:version_id]
|
||||
remove_index :version_associations,
|
||||
name: "index_version_associations_on_foreign_key"
|
||||
drop_table :version_associations
|
||||
end
|
||||
end
|
|
@ -21,7 +21,6 @@ require "paper_trail/has_paper_trail"
|
|||
require "paper_trail/record_history"
|
||||
require "paper_trail/reifier"
|
||||
require "paper_trail/request"
|
||||
require "paper_trail/version_association_concern"
|
||||
require "paper_trail/version_concern"
|
||||
require "paper_trail/version_number"
|
||||
require "paper_trail/serializers/json"
|
||||
|
@ -45,16 +44,6 @@ module PaperTrail
|
|||
extend PaperTrail::Cleaner
|
||||
|
||||
class << self
|
||||
# @api private
|
||||
def clear_transaction_id
|
||||
::ActiveSupport::Deprecation.warn(
|
||||
"PaperTrail.clear_transaction_id is deprecated, " \
|
||||
"use PaperTrail.request.clear_transaction_id",
|
||||
caller(1)
|
||||
)
|
||||
request.clear_transaction_id
|
||||
end
|
||||
|
||||
# Switches PaperTrail on or off, for all threads.
|
||||
# @api public
|
||||
def enabled=(value)
|
||||
|
@ -207,29 +196,6 @@ module PaperTrail
|
|||
PaperTrail.config.serializer
|
||||
end
|
||||
|
||||
# @api public
|
||||
def transaction?
|
||||
::ActiveRecord::Base.connection.open_transactions.positive?
|
||||
end
|
||||
|
||||
# @deprecated
|
||||
def transaction_id
|
||||
::ActiveSupport::Deprecation.warn(
|
||||
"PaperTrail.transaction_id is deprecated without replacement.",
|
||||
caller(1)
|
||||
)
|
||||
request.transaction_id
|
||||
end
|
||||
|
||||
# @deprecated
|
||||
def transaction_id=(id)
|
||||
::ActiveSupport::Deprecation.warn(
|
||||
"PaperTrail.transaction_id= is deprecated without replacement.",
|
||||
caller(1)
|
||||
)
|
||||
request.transaction_id = id
|
||||
end
|
||||
|
||||
# Returns PaperTrail's global configuration object, a singleton. These
|
||||
# settings affect all threads.
|
||||
# @api private
|
||||
|
@ -262,3 +228,7 @@ if defined?(::Rails)
|
|||
else
|
||||
require "paper_trail/frameworks/active_record"
|
||||
end
|
||||
|
||||
# https://github.com/paper-trail-gem/paper_trail/issues/1070
|
||||
# https://github.com/westonganger/paper_trail-association_tracking/issues/2
|
||||
require "paper_trail-association_tracking"
|
||||
|
|
|
@ -7,24 +7,6 @@ module PaperTrail
|
|||
# Global configuration affecting all threads. Some thread-specific
|
||||
# configuration can be found in `paper_trail.rb`, others in `controller.rb`.
|
||||
class Config
|
||||
DPR_TRACK_ASSOC = <<~STR
|
||||
Association tracking is an endangered feature. For the past three or four
|
||||
years it has been an experimental feature, not recommended for production.
|
||||
It has a long list of known issues
|
||||
(https://github.com/paper-trail-gem/paper_trail#4b1-known-issues) and has no
|
||||
regular volunteers caring for it.
|
||||
|
||||
If you don't use this feature, I strongly recommend disabling it.
|
||||
|
||||
If you do use this feature, please head over to
|
||||
https://github.com/paper-trail-gem/paper_trail/issues/1070 and volunteer to work
|
||||
on the known issues.
|
||||
|
||||
If we can't make a serious dent in the list of known issues over the next
|
||||
few years, then I'm inclined to delete it, though that would make me sad
|
||||
because I've put dozens of hours into it, and I know others have too.
|
||||
STR
|
||||
|
||||
include Singleton
|
||||
attr_accessor :serializer, :version_limit, :association_reify_error_behaviour,
|
||||
:object_changes_adapter
|
||||
|
@ -38,30 +20,6 @@ module PaperTrail
|
|||
@serializer = PaperTrail::Serializers::YAML
|
||||
end
|
||||
|
||||
def track_associations=(value)
|
||||
@track_associations = !!value
|
||||
if @track_associations
|
||||
::ActiveSupport::Deprecation.warn(DPR_TRACK_ASSOC, caller(1))
|
||||
end
|
||||
end
|
||||
|
||||
# As of PaperTrail 5, `track_associations?` defaults to false. Tracking
|
||||
# associations is an experimental feature so we recommend setting
|
||||
# PaperTrail.config.track_associations = false in your
|
||||
# config/initializers/paper_trail.rb
|
||||
#
|
||||
# In PT 4, we checked `PaperTrail::VersionAssociation.table_exists?`
|
||||
# here, but that proved to be problematic in situations when the database
|
||||
# connection had not been established, or when the database does not exist
|
||||
# yet (as with `rake db:create`).
|
||||
def track_associations?
|
||||
if @track_associations.nil?
|
||||
false
|
||||
else
|
||||
@track_associations
|
||||
end
|
||||
end
|
||||
|
||||
# Indicates whether PaperTrail is on or off. Default: true.
|
||||
def enabled
|
||||
@mutex.synchronize { !!@enabled }
|
||||
|
|
|
@ -2,5 +2,4 @@
|
|||
|
||||
# This file only needs to be loaded if the gem is being used outside of Rails,
|
||||
# since otherwise the model(s) will get loaded in via the `Rails::Engine`.
|
||||
require "paper_trail/frameworks/active_record/models/paper_trail/version_association"
|
||||
require "paper_trail/frameworks/active_record/models/paper_trail/version"
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "paper_trail/version_association_concern"
|
||||
|
||||
module PaperTrail
|
||||
# This is the default ActiveRecord model provided by PaperTrail. Most simple
|
||||
# applications will only use this and its partner, `Version`, but it is
|
||||
# possible to sub-class, extend, or even do without this model entirely.
|
||||
# See the readme for details.
|
||||
class VersionAssociation < ::ActiveRecord::Base
|
||||
include PaperTrail::VersionAssociationConcern
|
||||
end
|
||||
end
|
|
@ -141,9 +141,8 @@ module PaperTrail
|
|||
@model_class.send :include, ::PaperTrail::Model::InstanceMethods
|
||||
setup_options(options)
|
||||
setup_associations(options)
|
||||
setup_transaction_callbacks
|
||||
@model_class.after_rollback { paper_trail.clear_rolled_back_versions }
|
||||
setup_callbacks_from_options options[:on]
|
||||
setup_callbacks_for_habtm options[:join_tables]
|
||||
end
|
||||
|
||||
def version_class
|
||||
|
@ -169,11 +168,6 @@ module PaperTrail
|
|||
::ActiveRecord::Base.belongs_to_required_by_default
|
||||
end
|
||||
|
||||
def habtm_assocs_not_skipped
|
||||
@model_class.reflect_on_all_associations(:has_and_belongs_to_many).
|
||||
reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) }
|
||||
end
|
||||
|
||||
def setup_associations(options)
|
||||
@model_class.class_attribute :version_association_name
|
||||
@model_class.version_association_name = options[:version] || :version
|
||||
|
@ -199,33 +193,12 @@ module PaperTrail
|
|||
)
|
||||
end
|
||||
|
||||
# Adds callbacks to record changes to habtm associations such that on save
|
||||
# the previous version of the association (if changed) can be reconstructed.
|
||||
def setup_callbacks_for_habtm(join_tables)
|
||||
@model_class.send :attr_accessor, :paper_trail_habtm
|
||||
@model_class.class_attribute :paper_trail_save_join_tables
|
||||
@model_class.paper_trail_save_join_tables = Array.wrap(join_tables)
|
||||
habtm_assocs_not_skipped.each(&method(:setup_habtm_change_callbacks))
|
||||
end
|
||||
|
||||
def setup_callbacks_from_options(options_on = [])
|
||||
options_on.each do |event|
|
||||
public_send("on_#{event}")
|
||||
end
|
||||
end
|
||||
|
||||
def setup_habtm_change_callbacks(assoc)
|
||||
assoc_name = assoc.name
|
||||
%w[add remove].each do |verb|
|
||||
@model_class.send(:"before_#{verb}_for_#{assoc_name}").send(
|
||||
:<<,
|
||||
lambda do |*args|
|
||||
update_habtm_state(assoc_name, :"before_#{verb}", args[-2], args.last)
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def setup_options(options)
|
||||
@model_class.class_attribute :paper_trail_options
|
||||
@model_class.paper_trail_options = options.dup
|
||||
|
@ -242,29 +215,5 @@ module PaperTrail
|
|||
@model_class.paper_trail_options[:save_changes] = true
|
||||
end
|
||||
end
|
||||
|
||||
# Reset the transaction id when the transaction is closed.
|
||||
def setup_transaction_callbacks
|
||||
@model_class.after_commit { PaperTrail.request.clear_transaction_id }
|
||||
@model_class.after_rollback { PaperTrail.request.clear_transaction_id }
|
||||
@model_class.after_rollback { paper_trail.clear_rolled_back_versions }
|
||||
end
|
||||
|
||||
def update_habtm_state(name, callback, model, assoc)
|
||||
model.paper_trail_habtm ||= {}
|
||||
model.paper_trail_habtm[name] ||= { removed: [], added: [] }
|
||||
state = model.paper_trail_habtm[name]
|
||||
assoc_id = assoc.id
|
||||
case callback
|
||||
when :before_add
|
||||
state[:added] |= [assoc_id]
|
||||
state[:removed] -= [assoc_id]
|
||||
when :before_remove
|
||||
state[:removed] |= [assoc_id]
|
||||
state[:added] -= [assoc_id]
|
||||
else
|
||||
raise "Invalid callback: #{callback}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -45,34 +45,6 @@ module PaperTrail
|
|||
@in_after_callback = false
|
||||
end
|
||||
|
||||
# Utility method for reifying. Anything executed inside the block will
|
||||
# appear like a new record.
|
||||
#
|
||||
# > .. as best as I can tell, the purpose of
|
||||
# > appear_as_new_record was to attempt to prevent the callbacks in
|
||||
# > AutosaveAssociation (which is the module responsible for persisting
|
||||
# > foreign key changes earlier than most people want most of the time
|
||||
# > because backwards compatibility or the maintainer hates himself or
|
||||
# > something) from running. By also stubbing out persisted? we can
|
||||
# > actually prevent those. A more stable option might be to use suppress
|
||||
# > instead, similar to the other branch in reify_has_one.
|
||||
# > -Sean Griffin (https://github.com/paper-trail-gem/paper_trail/pull/899)
|
||||
#
|
||||
# @api private
|
||||
def appear_as_new_record
|
||||
@record.instance_eval {
|
||||
alias :old_new_record? :new_record?
|
||||
alias :new_record? :present?
|
||||
alias :old_persisted? :persisted?
|
||||
alias :persisted? :nil?
|
||||
}
|
||||
yield
|
||||
@record.instance_eval {
|
||||
alias :new_record? :old_new_record?
|
||||
alias :persisted? :old_persisted?
|
||||
}
|
||||
end
|
||||
|
||||
def attributes_before_change(is_touch)
|
||||
Hash[@record.attributes.map do |k, v|
|
||||
if @record.class.column_names.include?(k)
|
||||
|
@ -265,9 +237,7 @@ module PaperTrail
|
|||
@in_after_callback = true
|
||||
return unless enabled?
|
||||
versions_assoc = @record.send(@record.class.versions_association_name)
|
||||
version = versions_assoc.create! data_for_create
|
||||
update_transaction_id(version)
|
||||
save_associations(version)
|
||||
versions_assoc.create! data_for_create
|
||||
ensure
|
||||
@in_after_callback = false
|
||||
end
|
||||
|
@ -285,13 +255,14 @@ module PaperTrail
|
|||
if record_object_changes? && changed_notably?
|
||||
data[:object_changes] = recordable_object_changes(changes)
|
||||
end
|
||||
add_transaction_id_to(data)
|
||||
merge_metadata_into(data)
|
||||
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)
|
||||
@in_after_callback = recording_order == "after"
|
||||
if enabled? && !@record.new_record?
|
||||
|
@ -301,8 +272,7 @@ module PaperTrail
|
|||
else
|
||||
@record.send("#{@record.class.version_association_name}=", version)
|
||||
@record.send(@record.class.versions_association_name).reset
|
||||
update_transaction_id(version)
|
||||
save_associations(version)
|
||||
version
|
||||
end
|
||||
end
|
||||
ensure
|
||||
|
@ -319,7 +289,6 @@ module PaperTrail
|
|||
object: recordable_object(false),
|
||||
whodunnit: PaperTrail.request.whodunnit
|
||||
}
|
||||
add_transaction_id_to(data)
|
||||
merge_metadata_into(data)
|
||||
end
|
||||
|
||||
|
@ -331,6 +300,9 @@ module PaperTrail
|
|||
@record.class.paper_trail.version_class.column_names.include?("object_changes")
|
||||
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:)
|
||||
@in_after_callback = in_after_callback
|
||||
if enabled? && (force || changed_notably?)
|
||||
|
@ -339,8 +311,7 @@ module PaperTrail
|
|||
if version.errors.any?
|
||||
log_version_errors(version, :update)
|
||||
else
|
||||
update_transaction_id(version)
|
||||
save_associations(version)
|
||||
version
|
||||
end
|
||||
end
|
||||
ensure
|
||||
|
@ -363,11 +334,12 @@ module PaperTrail
|
|||
if record_object_changes?
|
||||
data[:object_changes] = recordable_object_changes(changes)
|
||||
end
|
||||
add_transaction_id_to(data)
|
||||
merge_metadata_into(data)
|
||||
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?
|
||||
versions_assoc = @record.send(@record.class.versions_association_name)
|
||||
|
@ -375,8 +347,7 @@ module PaperTrail
|
|||
if version.errors.any?
|
||||
log_version_errors(version, :update)
|
||||
else
|
||||
update_transaction_id(version)
|
||||
save_associations(version)
|
||||
version
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -391,7 +362,6 @@ module PaperTrail
|
|||
if record_object_changes?
|
||||
data[:object_changes] = recordable_object_changes(changes)
|
||||
end
|
||||
add_transaction_id_to(data)
|
||||
merge_metadata_into(data)
|
||||
end
|
||||
|
||||
|
@ -438,39 +408,6 @@ module PaperTrail
|
|||
end
|
||||
end
|
||||
|
||||
# Saves associations if the join table for `VersionAssociation` exists.
|
||||
def save_associations(version)
|
||||
return unless PaperTrail.config.track_associations?
|
||||
save_bt_associations(version)
|
||||
save_habtm_associations(version)
|
||||
end
|
||||
|
||||
# Save all `belongs_to` associations.
|
||||
# @api private
|
||||
def save_bt_associations(version)
|
||||
@record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
||||
save_bt_association(assoc, version)
|
||||
end
|
||||
end
|
||||
|
||||
# When a record is created, updated, or destroyed, we determine what the
|
||||
# HABTM associations looked like before any changes were made, by using
|
||||
# the `paper_trail_habtm` data structure. Then, we create
|
||||
# `VersionAssociation` records for each of the associated records.
|
||||
# @api private
|
||||
def save_habtm_associations(version)
|
||||
@record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
|
||||
next unless save_habtm_association?(a)
|
||||
habtm_assoc_ids(a).each do |id|
|
||||
PaperTrail::VersionAssociation.create(
|
||||
version_id: version.transaction_id,
|
||||
foreign_key_name: a.name,
|
||||
foreign_key_id: id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# AR callback.
|
||||
# @api private
|
||||
def save_version?
|
||||
|
@ -602,11 +539,6 @@ module PaperTrail
|
|||
|
||||
private
|
||||
|
||||
def add_transaction_id_to(data)
|
||||
return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
|
||||
data[:transaction_id] = PaperTrail.request.transaction_id
|
||||
end
|
||||
|
||||
# Rails 5.1 changed the API of `ActiveRecord::Dirty`. See
|
||||
# https://github.com/paper-trail-gem/paper_trail/pull/899
|
||||
#
|
||||
|
@ -665,16 +597,6 @@ module PaperTrail
|
|||
end
|
||||
end
|
||||
|
||||
# Given a HABTM association, returns an array of ids.
|
||||
#
|
||||
# @api private
|
||||
def habtm_assoc_ids(habtm_assoc)
|
||||
current = @record.send(habtm_assoc.name).to_a.map(&:id) # TODO: `pluck` would use less memory
|
||||
removed = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :removed) || []
|
||||
added = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :added) || []
|
||||
current + removed - added
|
||||
end
|
||||
|
||||
def log_version_errors(version, action)
|
||||
version.logger&.warn(
|
||||
"Unable to create version for #{action} of #{@record.class.name}" \
|
||||
|
@ -682,44 +604,6 @@ module PaperTrail
|
|||
)
|
||||
end
|
||||
|
||||
# Save a single `belongs_to` association.
|
||||
# @api private
|
||||
def save_bt_association(assoc, version)
|
||||
assoc_version_args = {
|
||||
version_id: version.id,
|
||||
foreign_key_name: assoc.foreign_key
|
||||
}
|
||||
|
||||
if assoc.options[:polymorphic]
|
||||
associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type)
|
||||
if associated_record && PaperTrail.request.enabled_for_model?(associated_record.class)
|
||||
assoc_version_args[:foreign_key_id] = associated_record.id
|
||||
end
|
||||
elsif PaperTrail.request.enabled_for_model?(assoc.klass)
|
||||
assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key)
|
||||
end
|
||||
|
||||
if assoc_version_args.key?(:foreign_key_id)
|
||||
PaperTrail::VersionAssociation.create(assoc_version_args)
|
||||
end
|
||||
end
|
||||
|
||||
# Returns true if the given HABTM association should be saved.
|
||||
# @api private
|
||||
def save_habtm_association?(assoc)
|
||||
@record.class.paper_trail_save_join_tables.include?(assoc.name) ||
|
||||
PaperTrail.request.enabled_for_model?(assoc.klass)
|
||||
end
|
||||
|
||||
def update_transaction_id(version)
|
||||
return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
|
||||
if PaperTrail.transaction? && PaperTrail.request.transaction_id.nil?
|
||||
PaperTrail.request.transaction_id = version.id
|
||||
version.transaction_id = version.id
|
||||
version.save
|
||||
end
|
||||
end
|
||||
|
||||
def version
|
||||
@record.public_send(@record.class.version_association_name)
|
||||
end
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require "paper_trail/attribute_serializers/object_attribute"
|
||||
require "paper_trail/reifiers/belongs_to"
|
||||
require "paper_trail/reifiers/has_and_belongs_to_many"
|
||||
require "paper_trail/reifiers/has_many"
|
||||
require "paper_trail/reifiers/has_many_through"
|
||||
require "paper_trail/reifiers/has_one"
|
||||
|
||||
module PaperTrail
|
||||
# Given a version record and some options, builds a new model object.
|
||||
|
@ -20,22 +15,9 @@ module PaperTrail
|
|||
model = init_model(attrs, options, version)
|
||||
reify_attributes(model, version, attrs)
|
||||
model.send "#{model.class.version_association_name}=", version
|
||||
reify_associations(model, options, version)
|
||||
model
|
||||
end
|
||||
|
||||
# Restore the `model`'s has_many associations as they were at version_at
|
||||
# timestamp We lookup the first child versions after version_at timestamp or
|
||||
# in same transaction.
|
||||
# @api private
|
||||
def reify_has_manys(transaction_id, model, options = {})
|
||||
assoc_has_many_through, assoc_has_many_directly =
|
||||
model.class.reflect_on_all_associations(:has_many).
|
||||
partition { |assoc| assoc.options[:through] }
|
||||
reify_has_many_associations(transaction_id, assoc_has_many_directly, model, options)
|
||||
reify_has_many_through_associations(transaction_id, assoc_has_many_through, model, options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Given a hash of `options` for `.reify`, return a new hash with default
|
||||
|
@ -53,14 +35,6 @@ module PaperTrail
|
|||
}.merge(options)
|
||||
end
|
||||
|
||||
# @api private
|
||||
def each_enabled_association(associations)
|
||||
associations.each do |assoc|
|
||||
next unless ::PaperTrail.request.enabled_for_model?(assoc.klass)
|
||||
yield assoc
|
||||
end
|
||||
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
|
||||
|
@ -136,70 +110,6 @@ module PaperTrail
|
|||
end
|
||||
end
|
||||
|
||||
# @api private
|
||||
def reify_associations(model, options, version)
|
||||
if options[:has_one]
|
||||
reify_has_one_associations(version.transaction_id, model, options)
|
||||
end
|
||||
if options[:belongs_to]
|
||||
reify_belongs_to_associations(version.transaction_id, model, options)
|
||||
end
|
||||
if options[:has_many]
|
||||
reify_has_manys(version.transaction_id, model, options)
|
||||
end
|
||||
if options[:has_and_belongs_to_many]
|
||||
reify_habtm_associations version.transaction_id, model, options
|
||||
end
|
||||
end
|
||||
|
||||
# Restore the `model`'s has_one associations as they were when this
|
||||
# version was superseded by the next (because that's what the user was
|
||||
# looking at when they made the change).
|
||||
# @api private
|
||||
def reify_has_one_associations(transaction_id, model, options = {})
|
||||
associations = model.class.reflect_on_all_associations(:has_one)
|
||||
each_enabled_association(associations) do |assoc|
|
||||
Reifiers::HasOne.reify(assoc, model, options, transaction_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Reify all `belongs_to` associations of `model`.
|
||||
# @api private
|
||||
def reify_belongs_to_associations(transaction_id, model, options = {})
|
||||
associations = model.class.reflect_on_all_associations(:belongs_to)
|
||||
each_enabled_association(associations) do |assoc|
|
||||
Reifiers::BelongsTo.reify(assoc, model, options, transaction_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Reify all direct (not `through`) `has_many` associations of `model`.
|
||||
# @api private
|
||||
def reify_has_many_associations(transaction_id, associations, model, options = {})
|
||||
version_table_name = model.class.paper_trail.version_class.table_name
|
||||
each_enabled_association(associations) do |assoc|
|
||||
Reifiers::HasMany.reify(assoc, model, options, transaction_id, version_table_name)
|
||||
end
|
||||
end
|
||||
|
||||
# Reify all HMT associations of `model`. This must be called after the
|
||||
# direct (non-`through`) has_manys have been reified.
|
||||
# @api private
|
||||
def reify_has_many_through_associations(transaction_id, associations, model, options = {})
|
||||
each_enabled_association(associations) do |assoc|
|
||||
Reifiers::HasManyThrough.reify(assoc, model, options, transaction_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Reify all HABTM associations of `model`.
|
||||
# @api private
|
||||
def reify_habtm_associations(transaction_id, model, options = {})
|
||||
model.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |assoc|
|
||||
pt_enabled = ::PaperTrail.request.enabled_for_model?(assoc.klass)
|
||||
next unless model.class.paper_trail_save_join_tables.include?(assoc.name) || pt_enabled
|
||||
Reifiers::HasAndBelongsToMany.reify(pt_enabled, assoc, model, options, transaction_id)
|
||||
end
|
||||
end
|
||||
|
||||
# Given a `version`, return the class to reify. This method supports
|
||||
# Single Table Inheritance (STI) with custom inheritance columns.
|
||||
#
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
module Reifiers
|
||||
# Reify a single `belongs_to` association of `model`.
|
||||
# @api private
|
||||
module BelongsTo
|
||||
class << self
|
||||
# @api private
|
||||
def reify(assoc, model, options, transaction_id)
|
||||
id = model.send(assoc.foreign_key)
|
||||
version = load_version(assoc, id, transaction_id, options[:version_at])
|
||||
record = load_record(assoc, id, options, version)
|
||||
model.send("#{assoc.name}=".to_sym, record)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Given a `belongs_to` association and a `version`, return a record that
|
||||
# can be assigned in order to reify that association.
|
||||
# @api private
|
||||
def load_record(assoc, id, options, version)
|
||||
if version.nil?
|
||||
assoc.klass.where(assoc.klass.primary_key => id).first
|
||||
else
|
||||
version.reify(
|
||||
options.merge(
|
||||
has_many: false,
|
||||
has_one: false,
|
||||
belongs_to: false,
|
||||
has_and_belongs_to_many: false
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Given a `belongs_to` association and an `id`, return a version record
|
||||
# from the point in time identified by `transaction_id` or `version_at`.
|
||||
# @api private
|
||||
def load_version(assoc, id, transaction_id, version_at)
|
||||
assoc.klass.paper_trail.version_class.
|
||||
where("item_type = ?", assoc.klass.base_class.name).
|
||||
where("item_id = ?", id).
|
||||
where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
|
||||
order("id").limit(1).first
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,52 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
module Reifiers
|
||||
# Reify a single HABTM association of `model`.
|
||||
# @api private
|
||||
module HasAndBelongsToMany
|
||||
class << self
|
||||
# @api private
|
||||
def reify(pt_enabled, assoc, model, options, transaction_id)
|
||||
version_ids = ::PaperTrail::VersionAssociation.
|
||||
where("foreign_key_name = ?", assoc.name).
|
||||
where("version_id = ?", transaction_id).
|
||||
pluck(:foreign_key_id)
|
||||
|
||||
model.send(assoc.name).proxy_association.target =
|
||||
version_ids.map do |id|
|
||||
if pt_enabled
|
||||
version = load_version(assoc, id, transaction_id, options[:version_at])
|
||||
if version
|
||||
next version.reify(
|
||||
options.merge(
|
||||
has_many: false,
|
||||
has_one: false,
|
||||
belongs_to: false,
|
||||
has_and_belongs_to_many: false
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
assoc.klass.where(assoc.klass.primary_key => id).first
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Given a HABTM association `assoc` and an `id`, return a version record
|
||||
# from the point in time identified by `transaction_id` or `version_at`.
|
||||
# @api private
|
||||
def load_version(assoc, id, transaction_id, version_at)
|
||||
assoc.klass.paper_trail.version_class.
|
||||
where("item_type = ?", assoc.klass.base_class.name).
|
||||
where("item_id = ?", id).
|
||||
where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
|
||||
order("id").
|
||||
limit(1).
|
||||
first
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,112 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
module Reifiers
|
||||
# Reify a single, direct (not `through`) `has_many` association of `model`.
|
||||
# @api private
|
||||
module HasMany
|
||||
class << self
|
||||
# @api private
|
||||
def reify(assoc, model, options, transaction_id, version_table_name)
|
||||
versions = load_versions_for_hm_association(
|
||||
assoc,
|
||||
model,
|
||||
version_table_name,
|
||||
transaction_id,
|
||||
options[:version_at]
|
||||
)
|
||||
collection = Array.new model.send(assoc.name).reload # to avoid cache
|
||||
prepare_array(collection, options, versions)
|
||||
model.send(assoc.name).proxy_association.target = collection
|
||||
end
|
||||
|
||||
# Replaces each record in `array` with its reified version, if present
|
||||
# in `versions`.
|
||||
#
|
||||
# @api private
|
||||
# @param array - The collection to be modified.
|
||||
# @param options
|
||||
# @param versions - A `Hash` mapping IDs to `Version`s
|
||||
# @return nil - Always returns `nil`
|
||||
#
|
||||
# Once modified by this method, `array` will be assigned to the
|
||||
# AR association currently being reified.
|
||||
#
|
||||
def prepare_array(array, options, versions)
|
||||
# Iterate each child to replace it with the previous value if there is
|
||||
# a version after the timestamp.
|
||||
array.map! do |record|
|
||||
if (version = versions.delete(record.id)).nil?
|
||||
record
|
||||
elsif version.event == "create"
|
||||
options[:mark_for_destruction] ? record.tap(&:mark_for_destruction) : nil
|
||||
else
|
||||
version.reify(
|
||||
options.merge(
|
||||
has_many: false,
|
||||
has_one: false,
|
||||
belongs_to: false,
|
||||
has_and_belongs_to_many: false
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
# Reify the rest of the versions and add them to the collection, these
|
||||
# versions are for those that have been removed from the live
|
||||
# associations.
|
||||
array.concat(
|
||||
versions.values.map { |v|
|
||||
v.reify(
|
||||
options.merge(
|
||||
has_many: false,
|
||||
has_one: false,
|
||||
belongs_to: false,
|
||||
has_and_belongs_to_many: false
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
array.compact!
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
# Given a SQL fragment that identifies the IDs of version records,
|
||||
# returns a `Hash` mapping those IDs to `Version`s.
|
||||
#
|
||||
# @api private
|
||||
# @param klass - An ActiveRecord class.
|
||||
# @param version_id_subquery - String. A SQL subquery that selects
|
||||
# the IDs of version records.
|
||||
# @return A `Hash` mapping IDs to `Version`s
|
||||
#
|
||||
def versions_by_id(klass, version_id_subquery)
|
||||
klass.
|
||||
paper_trail.version_class.
|
||||
where("id IN (#{version_id_subquery})").
|
||||
inject({}) { |a, e| a.merge!(e.item_id => e) }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Given a `has_many` association on `model`, return the version records
|
||||
# from the point in time identified by `tx_id` or `version_at`.
|
||||
# @api private
|
||||
def load_versions_for_hm_association(assoc, model, version_table, tx_id, version_at)
|
||||
version_id_subquery = ::PaperTrail::VersionAssociation.
|
||||
joins(model.class.version_association_name).
|
||||
select("MIN(version_id)").
|
||||
where("foreign_key_name = ?", assoc.foreign_key).
|
||||
where("foreign_key_id = ?", model.id).
|
||||
where("#{version_table}.item_type = ?", assoc.klass.base_class.name).
|
||||
where("created_at >= ? OR transaction_id = ?", version_at, tx_id).
|
||||
group("item_id").
|
||||
to_sql
|
||||
versions_by_id(model.class, version_id_subquery)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,92 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
module Reifiers
|
||||
# Reify a single HMT association of `model`.
|
||||
# @api private
|
||||
module HasManyThrough
|
||||
class << self
|
||||
# @api private
|
||||
def reify(assoc, model, options, transaction_id)
|
||||
# Load the collection of through-models. For example, if `model` is a
|
||||
# Chapter, having many Paragraphs through Sections, then
|
||||
# `through_collection` will contain Sections.
|
||||
through_collection = model.send(assoc.options[:through])
|
||||
|
||||
# Now, given the collection of "through" models (e.g. sections), load
|
||||
# the collection of "target" models (e.g. paragraphs)
|
||||
collection = collection(through_collection, assoc, options, transaction_id)
|
||||
|
||||
# Finally, assign the `collection` of "target" models, e.g. to
|
||||
# `model.paragraphs`.
|
||||
model.send(assoc.name).proxy_association.target = collection
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Examine the `source_reflection`, i.e. the "source" of `assoc` the
|
||||
# `ThroughReflection`. The source can be a `BelongsToReflection`
|
||||
# or a `HasManyReflection`.
|
||||
#
|
||||
# If the association is a has_many association again, then call
|
||||
# reify_has_manys for each record in `through_collection`.
|
||||
#
|
||||
# @api private
|
||||
def collection(through_collection, assoc, options, transaction_id)
|
||||
if !assoc.source_reflection.belongs_to? && through_collection.present?
|
||||
collection_through_has_many(through_collection, assoc, options, transaction_id)
|
||||
else
|
||||
collection_through_belongs_to(through_collection, assoc, options, transaction_id)
|
||||
end
|
||||
end
|
||||
|
||||
# @api private
|
||||
def collection_through_has_many(through_collection, assoc, options, transaction_id)
|
||||
through_collection.each do |through_model|
|
||||
::PaperTrail::Reifier.reify_has_manys(transaction_id, through_model, options)
|
||||
end
|
||||
|
||||
# At this point, the "through" part of the association chain has
|
||||
# been reified, but not the final, "target" part. To continue our
|
||||
# example, `model.sections` (including `model.sections.paragraphs`)
|
||||
# has been loaded. However, the final "target" part of the
|
||||
# association, that is, `model.paragraphs`, has not been loaded. So,
|
||||
# we do that now.
|
||||
through_collection.flat_map { |through_model|
|
||||
through_model.public_send(assoc.name.to_sym).to_a
|
||||
}
|
||||
end
|
||||
|
||||
# @api private
|
||||
def collection_through_belongs_to(through_collection, assoc, options, tx_id)
|
||||
ids = through_collection.map { |through_model|
|
||||
through_model.send(assoc.source_reflection.foreign_key)
|
||||
}
|
||||
versions = load_versions_for_hmt_association(assoc, ids, tx_id, options[:version_at])
|
||||
collection = Array.new assoc.klass.where(assoc.klass.primary_key => ids)
|
||||
Reifiers::HasMany.prepare_array(collection, options, versions)
|
||||
collection
|
||||
end
|
||||
|
||||
# Given a `has_many(through:)` association and an array of `ids`, return
|
||||
# the version records from the point in time identified by `tx_id` or
|
||||
# `version_at`.
|
||||
# @api private
|
||||
def load_versions_for_hmt_association(assoc, ids, tx_id, version_at)
|
||||
version_id_subquery = assoc.klass.paper_trail.version_class.
|
||||
select("MIN(id)").
|
||||
where("item_type = ?", assoc.klass.base_class.name).
|
||||
where("item_id IN (?)", ids).
|
||||
where(
|
||||
"created_at >= ? OR transaction_id = ?",
|
||||
version_at,
|
||||
tx_id
|
||||
).
|
||||
group("item_id").
|
||||
to_sql
|
||||
Reifiers::HasMany.versions_by_id(assoc.klass, version_id_subquery)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,135 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
module Reifiers
|
||||
# Reify a single `has_one` association of `model`.
|
||||
# @api private
|
||||
module HasOne
|
||||
# A more helpful error message, instead of the AssociationTypeMismatch
|
||||
# you would get if, eg. we were to try to assign a Bicycle to the :car
|
||||
# association (before, if there were multiple records we would just take
|
||||
# the first and hope for the best).
|
||||
# @api private
|
||||
class FoundMoreThanOne < RuntimeError
|
||||
MESSAGE_FMT = <<~STR
|
||||
Unable to reify has_one association. Expected to find one %s,
|
||||
but found %d.
|
||||
|
||||
This is a known issue, and a good example of why association tracking
|
||||
is an experimental feature that should not be used in production.
|
||||
|
||||
That said, this is a rare error. In spec/models/person_spec.rb we
|
||||
reproduce it by having two STI models with the same foreign_key (Car
|
||||
and Bicycle are both Vehicles and the FK for both is owner_id)
|
||||
|
||||
If you'd like to help fix this error, please read
|
||||
https://github.com/paper-trail-gem/paper_trail/issues/594
|
||||
and see spec/models/person_spec.rb
|
||||
STR
|
||||
|
||||
def initialize(base_class_name, num_records_found)
|
||||
@base_class_name = base_class_name.to_s
|
||||
@num_records_found = num_records_found.to_i
|
||||
end
|
||||
|
||||
def message
|
||||
format(MESSAGE_FMT, @base_class_name, @num_records_found)
|
||||
end
|
||||
end
|
||||
|
||||
class << self
|
||||
# @api private
|
||||
def reify(assoc, model, options, transaction_id)
|
||||
version = load_version(assoc, model, transaction_id, options[:version_at])
|
||||
return unless version
|
||||
if version.event == "create"
|
||||
create_event(assoc, model, options)
|
||||
else
|
||||
noncreate_event(assoc, model, options, version)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# @api private
|
||||
def create_event(assoc, model, options)
|
||||
if options[:mark_for_destruction]
|
||||
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
|
||||
else
|
||||
model.paper_trail.appear_as_new_record do
|
||||
model.send "#{assoc.name}=", nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Given a has-one association `assoc` on `model`, return the version
|
||||
# record from the point in time identified by `transaction_id` or `version_at`.
|
||||
# @api private
|
||||
def load_version(assoc, model, transaction_id, version_at)
|
||||
base_class_name = assoc.klass.base_class.name
|
||||
versions = load_versions(assoc, model, transaction_id, version_at, base_class_name)
|
||||
case versions.length
|
||||
when 0
|
||||
nil
|
||||
when 1
|
||||
versions.first
|
||||
else
|
||||
case PaperTrail.config.association_reify_error_behaviour.to_s
|
||||
when "warn"
|
||||
version = versions.first
|
||||
version.logger&.warn(
|
||||
FoundMoreThanOne.new(base_class_name, versions.length).message
|
||||
)
|
||||
version
|
||||
when "ignore"
|
||||
versions.first
|
||||
else # "error"
|
||||
raise FoundMoreThanOne.new(base_class_name, versions.length)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# @api private
|
||||
def load_versions(assoc, model, transaction_id, version_at, base_class_name)
|
||||
version_table_name = model.class.paper_trail.version_class.table_name
|
||||
model.class.paper_trail.version_class.joins(:version_associations).
|
||||
where("version_associations.foreign_key_name = ?", assoc.foreign_key).
|
||||
where("version_associations.foreign_key_id = ?", model.id).
|
||||
where("#{version_table_name}.item_type = ?", base_class_name).
|
||||
where("created_at >= ? OR transaction_id = ?", version_at, transaction_id).
|
||||
order("#{version_table_name}.id ASC").
|
||||
load
|
||||
end
|
||||
|
||||
# @api private
|
||||
def noncreate_event(assoc, model, options, version)
|
||||
child = version.reify(
|
||||
options.merge(
|
||||
has_many: false,
|
||||
has_one: false,
|
||||
belongs_to: false,
|
||||
has_and_belongs_to_many: false
|
||||
)
|
||||
)
|
||||
model.paper_trail.appear_as_new_record do
|
||||
without_persisting(child) do
|
||||
model.send "#{assoc.name}=", child
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Temporarily suppress #save so we can reassociate with the reified
|
||||
# master of a has_one relationship. Since ActiveRecord 5 the related
|
||||
# object is saved when it is assigned to the association. ActiveRecord
|
||||
# 5 also happens to be the first version that provides #suppress.
|
||||
def without_persisting(record)
|
||||
if record.class.respond_to? :suppress
|
||||
record.class.suppress { yield }
|
||||
else
|
||||
yield
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -16,11 +16,6 @@ module PaperTrail
|
|||
end
|
||||
|
||||
class << self
|
||||
# @api private
|
||||
def clear_transaction_id
|
||||
self.transaction_id = nil
|
||||
end
|
||||
|
||||
# Sets any data from the controller that you want PaperTrail to store.
|
||||
# See also `PaperTrail::Rails::Controller#info_for_paper_trail`.
|
||||
#
|
||||
|
@ -105,16 +100,6 @@ module PaperTrail
|
|||
store.deep_dup
|
||||
end
|
||||
|
||||
# @api private
|
||||
def transaction_id
|
||||
store[:transaction_id]
|
||||
end
|
||||
|
||||
# @api private
|
||||
def transaction_id=(id)
|
||||
store[:transaction_id] = id
|
||||
end
|
||||
|
||||
# Temporarily set `options` and execute a block.
|
||||
# @api private
|
||||
def with(options)
|
||||
|
@ -171,8 +156,6 @@ module PaperTrail
|
|||
:enabled,
|
||||
:whodunnit
|
||||
next
|
||||
when :transaction_id
|
||||
raise InvalidOption, "Cannot set private option: #{k}"
|
||||
else
|
||||
raise InvalidOption, "Invalid option: #{k}"
|
||||
end
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PaperTrail
|
||||
# Functionality for `PaperTrail::VersionAssociation`. Exists in a module
|
||||
# for the same reasons outlined in version_concern.rb.
|
||||
module VersionAssociationConcern
|
||||
extend ::ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
belongs_to :version
|
||||
end
|
||||
end
|
||||
end
|
|
@ -19,17 +19,8 @@ module PaperTrail
|
|||
belongs_to :item, polymorphic: true
|
||||
end
|
||||
|
||||
# Since the test suite has test coverage for this, we want to declare
|
||||
# the association when the test suite is running. This makes it pass when
|
||||
# DB is not initialized prior to test runs such as when we run on Travis
|
||||
# CI (there won't be a db in `spec/dummy_app/db/`).
|
||||
if PaperTrail.config.track_associations?
|
||||
has_many :version_associations, dependent: :destroy
|
||||
end
|
||||
|
||||
validates_presence_of :event
|
||||
after_create :enforce_version_limit!
|
||||
scope(:within_transaction, ->(id) { where transaction_id: id })
|
||||
end
|
||||
|
||||
# :nodoc:
|
||||
|
|
|
@ -29,6 +29,15 @@ has been destroyed.
|
|||
|
||||
# Rails does not follow semver, makes breaking changes in minor versions.
|
||||
s.add_dependency "activerecord", [">= 4.2", "< 5.3"]
|
||||
|
||||
# This `PT_ASSOCIATION_TRACKING` variable is convenient for the test suite of
|
||||
# `paper_trail-association_tracking`. Normal users of paper_trail should not
|
||||
# set this variable. This variable may be removed in the future without
|
||||
# warning.
|
||||
unless ENV["PT_ASSOCIATION_TRACKING"] == "false"
|
||||
s.add_dependency "paper_trail-association_tracking", "0.0.1"
|
||||
end
|
||||
|
||||
s.add_dependency "request_store", "~> 1.1"
|
||||
|
||||
s.add_development_dependency "appraisal", "~> 2.2"
|
||||
|
|
|
@ -159,20 +159,6 @@ module PaperTrail
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "private options" do
|
||||
it "raises an invalid option error" do
|
||||
subject = proc do
|
||||
described_class.with(transaction_id: "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 "Cannot set private option: transaction_id"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -120,15 +120,12 @@ RSpec.describe PaperTrail do
|
|||
end
|
||||
end
|
||||
|
||||
it_behaves_like "it delegates to request", :clear_transaction_id, nil
|
||||
it_behaves_like "it delegates to request", :enabled_for_model, [Widget, true]
|
||||
it_behaves_like "it delegates to request", :enabled_for_model?, [Widget]
|
||||
it_behaves_like "it delegates to request", :whodunnit=, [:some_whodunnit]
|
||||
it_behaves_like "it delegates to request", :whodunnit, nil
|
||||
it_behaves_like "it delegates to request", :controller_info=, [:some_whodunnit]
|
||||
it_behaves_like "it delegates to request", :controller_info, nil
|
||||
it_behaves_like "it delegates to request", :transaction_id=, 123
|
||||
it_behaves_like "it delegates to request", :transaction_id, nil
|
||||
|
||||
describe "#enabled_for_controller=" do
|
||||
it "is deprecated" do
|
||||
|
|
Loading…
Reference in a new issue