1
0
Fork 0
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:
Jared Beck 2018-06-04 15:18:05 -04:00 committed by GitHub
commit 80aeebf562
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 50 additions and 1143 deletions

View file

@ -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.

View file

@ -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
View file

@ -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

View file

@ -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.

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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 }

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.
#

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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"

View file

@ -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

View file

@ -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