paper_trail 3.0.6 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +5 -0
  3. data/.rspec +1 -2
  4. data/.travis.yml +14 -5
  5. data/CHANGELOG.md +215 -8
  6. data/CONTRIBUTING.md +84 -0
  7. data/README.md +922 -502
  8. data/Rakefile +2 -2
  9. data/doc/bug_report_template.rb +65 -0
  10. data/gemfiles/ar3.gemfile +61 -0
  11. data/lib/generators/paper_trail/install_generator.rb +22 -3
  12. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +6 -1
  13. data/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb +11 -0
  14. data/lib/generators/paper_trail/templates/create_version_associations.rb +17 -0
  15. data/lib/generators/paper_trail/templates/create_versions.rb +22 -1
  16. data/lib/paper_trail.rb +52 -22
  17. data/lib/paper_trail/attributes_serialization.rb +89 -0
  18. data/lib/paper_trail/cleaner.rb +32 -15
  19. data/lib/paper_trail/config.rb +35 -2
  20. data/lib/paper_trail/frameworks/active_record.rb +4 -5
  21. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version_association.rb +7 -0
  22. data/lib/paper_trail/frameworks/rails.rb +1 -0
  23. data/lib/paper_trail/frameworks/rails/controller.rb +27 -11
  24. data/lib/paper_trail/frameworks/rspec.rb +5 -0
  25. data/lib/paper_trail/frameworks/sinatra.rb +3 -1
  26. data/lib/paper_trail/has_paper_trail.rb +304 -148
  27. data/lib/paper_trail/record_history.rb +59 -0
  28. data/lib/paper_trail/reifier.rb +270 -0
  29. data/lib/paper_trail/serializers/json.rb +13 -2
  30. data/lib/paper_trail/serializers/yaml.rb +16 -2
  31. data/lib/paper_trail/version_association_concern.rb +15 -0
  32. data/lib/paper_trail/version_concern.rb +160 -122
  33. data/lib/paper_trail/version_number.rb +3 -3
  34. data/paper_trail.gemspec +22 -9
  35. data/spec/generators/install_generator_spec.rb +4 -4
  36. data/spec/models/animal_spec.rb +36 -0
  37. data/spec/models/boolit_spec.rb +48 -0
  38. data/spec/models/callback_modifier_spec.rb +96 -0
  39. data/spec/models/fluxor_spec.rb +19 -0
  40. data/spec/models/gadget_spec.rb +14 -12
  41. data/spec/models/joined_version_spec.rb +9 -9
  42. data/spec/models/json_version_spec.rb +103 -0
  43. data/spec/models/kitchen/banana_spec.rb +14 -0
  44. data/spec/models/not_on_update_spec.rb +19 -0
  45. data/spec/models/post_with_status_spec.rb +3 -3
  46. data/spec/models/skipper_spec.rb +46 -0
  47. data/spec/models/thing_spec.rb +11 -0
  48. data/spec/models/version_spec.rb +195 -44
  49. data/spec/models/widget_spec.rb +136 -76
  50. data/spec/modules/paper_trail_spec.rb +27 -0
  51. data/spec/modules/version_concern_spec.rb +8 -8
  52. data/spec/modules/version_number_spec.rb +16 -16
  53. data/spec/paper_trail/config_spec.rb +52 -0
  54. data/spec/paper_trail_spec.rb +17 -17
  55. data/spec/rails_helper.rb +34 -0
  56. data/spec/requests/articles_spec.rb +10 -14
  57. data/spec/spec_helper.rb +81 -34
  58. data/spec/support/alt_db_init.rb +1 -1
  59. data/test/dummy/app/controllers/application_controller.rb +1 -1
  60. data/test/dummy/app/controllers/articles_controller.rb +4 -1
  61. data/test/dummy/app/models/animal.rb +2 -0
  62. data/test/dummy/app/models/book.rb +4 -0
  63. data/test/dummy/app/models/boolit.rb +4 -0
  64. data/test/dummy/app/models/callback_modifier.rb +45 -0
  65. data/test/dummy/app/models/chapter.rb +9 -0
  66. data/test/dummy/app/models/citation.rb +5 -0
  67. data/test/dummy/app/models/customer.rb +4 -0
  68. data/test/dummy/app/models/editor.rb +4 -0
  69. data/test/dummy/app/models/editorship.rb +5 -0
  70. data/test/dummy/app/models/fruit.rb +5 -0
  71. data/test/dummy/app/models/kitchen/banana.rb +5 -0
  72. data/test/dummy/app/models/line_item.rb +4 -0
  73. data/test/dummy/app/models/not_on_update.rb +4 -0
  74. data/test/dummy/app/models/order.rb +5 -0
  75. data/test/dummy/app/models/paragraph.rb +5 -0
  76. data/test/dummy/app/models/person.rb +13 -3
  77. data/test/dummy/app/models/post.rb +0 -1
  78. data/test/dummy/app/models/quotation.rb +5 -0
  79. data/test/dummy/app/models/section.rb +6 -0
  80. data/test/dummy/app/models/skipper.rb +6 -0
  81. data/test/dummy/app/models/song.rb +20 -0
  82. data/test/dummy/app/models/thing.rb +3 -0
  83. data/test/dummy/app/models/whatchamajigger.rb +4 -0
  84. data/test/dummy/app/models/widget.rb +5 -0
  85. data/test/dummy/app/versions/json_version.rb +3 -0
  86. data/test/dummy/app/versions/kitchen/banana_version.rb +5 -0
  87. data/test/dummy/config/application.rb +6 -0
  88. data/test/dummy/config/database.postgres.yml +1 -1
  89. data/test/dummy/config/environments/test.rb +5 -1
  90. data/test/dummy/config/initializers/paper_trail.rb +6 -1
  91. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +143 -3
  92. data/test/dummy/db/schema.rb +169 -25
  93. data/test/functional/controller_test.rb +4 -2
  94. data/test/functional/modular_sinatra_test.rb +1 -1
  95. data/test/functional/sinatra_test.rb +1 -1
  96. data/test/paper_trail_test.rb +7 -0
  97. data/test/test_helper.rb +38 -2
  98. data/test/time_travel_helper.rb +15 -0
  99. data/test/unit/associations_test.rb +726 -0
  100. data/test/unit/inheritance_column_test.rb +6 -6
  101. data/test/unit/model_test.rb +109 -125
  102. data/test/unit/protected_attrs_test.rb +4 -3
  103. data/test/unit/serializer_test.rb +6 -6
  104. data/test/unit/serializers/json_test.rb +17 -4
  105. data/test/unit/serializers/yaml_test.rb +5 -1
  106. data/test/unit/version_test.rb +87 -69
  107. metadata +172 -75
  108. data/gemfiles/3.0.gemfile +0 -42
  109. data/test/dummy/public/404.html +0 -26
  110. data/test/dummy/public/422.html +0 -26
  111. data/test/dummy/public/500.html +0 -26
  112. data/test/dummy/public/favicon.ico +0 -0
  113. data/test/dummy/public/javascripts/application.js +0 -2
  114. data/test/dummy/public/javascripts/controls.js +0 -965
  115. data/test/dummy/public/javascripts/dragdrop.js +0 -974
  116. data/test/dummy/public/javascripts/effects.js +0 -1123
  117. data/test/dummy/public/javascripts/prototype.js +0 -6001
  118. data/test/dummy/public/javascripts/rails.js +0 -175
  119. data/test/dummy/public/stylesheets/.gitkeep +0 -0
@@ -1,35 +1,52 @@
1
1
  module PaperTrail
2
2
  module Cleaner
3
- # Destroys all but the most recent version(s) for items on a given date (or on all dates). Useful for deleting drafts.
3
+ # Destroys all but the most recent version(s) for items on a given date
4
+ # (or on all dates). Useful for deleting drafts.
4
5
  #
5
6
  # Options:
6
- # :keeping An `integer` indicating the number of versions to be kept for each item per date.
7
- # Defaults to `1`.
8
- # :date Should either be a `Date` object specifying which date to destroy versions for or `:all`,
9
- # which will specify that all dates should be cleaned. Defaults to `:all`.
10
- # :item_id The `id` for the item to be cleaned on, or `nil`, which causes all items to be cleaned.
11
- # Defaults to `nil`.
7
+ #
8
+ # - :keeping - An `integer` indicating the number of versions to be kept for
9
+ # each item per date. Defaults to `1`.
10
+ # - :date - Should either be a `Date` object specifying which date to
11
+ # destroy versions for or `:all`, which will specify that all dates
12
+ # should be cleaned. Defaults to `:all`.
13
+ # - :item_id - The `id` for the item to be cleaned on, or `nil`, which
14
+ # causes all items to be cleaned. Defaults to `nil`.
15
+ #
12
16
  def clean_versions!(options = {})
13
17
  options = {:keeping => 1, :date => :all}.merge(options)
14
18
  gather_versions(options[:item_id], options[:date]).each do |item_id, versions|
15
- versions.group_by { |v| v.send(PaperTrail.timestamp_field).to_date }.each do |date, versions|
16
- # remove the number of versions we wish to keep from the collection of versions prior to destruction
17
- versions.pop(options[:keeping])
18
- versions.map(&:destroy)
19
+ group_versions_by_date(versions).each do |date, _versions|
20
+ # Remove the number of versions we wish to keep from the collection
21
+ # of versions prior to destruction.
22
+ _versions.pop(options[:keeping])
23
+ _versions.map(&:destroy)
19
24
  end
20
25
  end
21
26
  end
22
27
 
23
28
  private
24
29
 
25
- # Returns a hash of versions grouped by the `item_id` attribute formatted like this: {:item_id => PaperTrail::Version}.
26
- # If `item_id` or `date` is set, versions will be narrowed to those pointing at items with those ids that were created on specified date.
30
+ # Returns a hash of versions grouped by the `item_id` attribute formatted
31
+ # like this: {:item_id => PaperTrail::Version}. If `item_id` or `date` is
32
+ # set, versions will be narrowed to those pointing at items with those ids
33
+ # that were created on specified date.
27
34
  def gather_versions(item_id = nil, date = :all)
28
- raise "`date` argument must receive a Timestamp or `:all`" unless date == :all || date.respond_to?(:to_date)
35
+ raise ArgumentError.new("`date` argument must receive a Timestamp or `:all`") unless date == :all || date.respond_to?(:to_date)
29
36
  versions = item_id ? PaperTrail::Version.where(:item_id => item_id) : PaperTrail::Version
30
37
  versions = versions.between(date.to_date, date.to_date + 1.day) unless date == :all
31
- versions = PaperTrail::Version.all if versions == PaperTrail::Version # if versions has not been converted to an ActiveRecord::Relation yet, do so now
38
+
39
+ # If `versions` has not been converted to an ActiveRecord::Relation yet,
40
+ # do so now.
41
+ versions = PaperTrail::Version.all if versions == PaperTrail::Version
32
42
  versions.group_by(&:item_id)
33
43
  end
44
+
45
+ # Given an array of versions, returns a hash mapping dates to arrays of
46
+ # versions.
47
+ # @api private
48
+ def group_versions_by_date(versions)
49
+ versions.group_by { |v| v.send(PaperTrail.timestamp_field).to_date }
50
+ end
34
51
  end
35
52
  end
@@ -1,14 +1,47 @@
1
1
  require 'singleton'
2
+ require 'paper_trail/serializers/yaml'
2
3
 
3
4
  module PaperTrail
4
5
  class Config
5
6
  include Singleton
6
- attr_accessor :enabled, :timestamp_field, :serializer, :version_limit
7
+ attr_accessor :timestamp_field, :serializer, :version_limit
8
+ attr_writer :track_associations
7
9
 
8
10
  def initialize
9
- @enabled = true # Indicates whether PaperTrail is on or off.
10
11
  @timestamp_field = :created_at
11
12
  @serializer = PaperTrail::Serializers::YAML
12
13
  end
14
+
15
+ def serialized_attributes
16
+ ActiveSupport::Deprecation.warn(
17
+ "PaperTrail.config.serialized_attributes is deprecated without " +
18
+ "replacement and always returns false."
19
+ )
20
+ false
21
+ end
22
+
23
+ def serialized_attributes=(_)
24
+ ActiveSupport::Deprecation.warn(
25
+ "PaperTrail.config.serialized_attributes= is deprecated without " +
26
+ "replacement and no longer has any effect."
27
+ )
28
+ end
29
+
30
+ def track_associations
31
+ @track_associations.nil? ?
32
+ PaperTrail::VersionAssociation.table_exists? :
33
+ @track_associations
34
+ end
35
+ alias_method :track_associations?, :track_associations
36
+
37
+ # Indicates whether PaperTrail is on or off. Default: true.
38
+ def enabled
39
+ value = PaperTrail.paper_trail_store.fetch(:paper_trail_enabled, true)
40
+ value.nil? ? true : value
41
+ end
42
+
43
+ def enabled= enable
44
+ PaperTrail.paper_trail_store[:paper_trail_enabled] = enable
45
+ end
13
46
  end
14
47
  end
@@ -1,5 +1,4 @@
1
- # This file only needs to be loaded if the gem is being used outside of Rails, since otherwise
2
- # the model(s) will get loaded in via the `Rails::Engine`
3
- Dir[File.join(File.dirname(__FILE__), 'active_record', 'models', 'paper_trail', '*.rb')].each do |file|
4
- require "paper_trail/frameworks/active_record/models/paper_trail/#{File.basename(file, '.rb')}"
5
- end
1
+ # This file only needs to be loaded if the gem is being used outside of Rails,
2
+ # since otherwise the model(s) will get loaded in via the `Rails::Engine`.
3
+ require "paper_trail/frameworks/active_record/models/paper_trail/version_association"
4
+ require "paper_trail/frameworks/active_record/models/paper_trail/version"
@@ -0,0 +1,7 @@
1
+ require 'paper_trail/version_association_concern'
2
+
3
+ module PaperTrail
4
+ class VersionAssociation < ::ActiveRecord::Base
5
+ include PaperTrail::VersionAssociationConcern
6
+ end
7
+ end
@@ -1,4 +1,5 @@
1
1
  require 'paper_trail/frameworks/rails/controller'
2
+ require 'paper_trail/frameworks/rails/engine'
2
3
 
3
4
  module PaperTrail
4
5
  module Rails
@@ -3,8 +3,22 @@ module PaperTrail
3
3
  module Controller
4
4
 
5
5
  def self.included(base)
6
- base.before_filter :set_paper_trail_enabled_for_controller
7
- base.before_filter :set_paper_trail_whodunnit, :set_paper_trail_controller_info
6
+ before = [
7
+ :set_paper_trail_enabled_for_controller,
8
+ :set_paper_trail_whodunnit,
9
+ :set_paper_trail_controller_info
10
+ ]
11
+ after = []
12
+
13
+ if base.respond_to? :before_action
14
+ # Rails 4+
15
+ before.map {|sym| base.before_action sym }
16
+ after.map {|sym| base.after_action sym }
17
+ else
18
+ # Rails 3.
19
+ before.map {|sym| base.before_filter sym }
20
+ after.map {|sym| base.after_filter sym }
21
+ end
8
22
  end
9
23
 
10
24
  protected
@@ -36,24 +50,26 @@ module PaperTrail
36
50
  #
37
51
  # The columns `ip` and `user_agent` must exist in your `versions` # table.
38
52
  #
39
- # Use the `:meta` option to `PaperTrail::Model::ClassMethods.has_paper_trail`
40
- # to store any extra model-level data you need.
53
+ # Use the `:meta` option to
54
+ # `PaperTrail::Model::ClassMethods.has_paper_trail` to store any extra
55
+ # model-level data you need.
41
56
  def info_for_paper_trail
42
57
  {}
43
58
  end
44
59
 
45
- # Returns `true` (default) or `false` depending on whether PaperTrail should
46
- # be active for the current request.
60
+ # Returns `true` (default) or `false` depending on whether PaperTrail
61
+ # should be active for the current request.
47
62
  #
48
- # Override this method in your controller to specify when PaperTrail should
49
- # be off.
63
+ # Override this method in your controller to specify when PaperTrail
64
+ # should be off.
50
65
  def paper_trail_enabled_for_controller
51
66
  ::PaperTrail.enabled?
52
67
  end
53
68
 
54
69
  private
55
70
 
56
- # Tells PaperTrail whether versions should be saved in the current request.
71
+ # Tells PaperTrail whether versions should be saved in the current
72
+ # request.
57
73
  def set_paper_trail_enabled_for_controller
58
74
  ::PaperTrail.enabled_for_controller = paper_trail_enabled_for_controller
59
75
  end
@@ -63,8 +79,8 @@ module PaperTrail
63
79
  ::PaperTrail.whodunnit = user_for_paper_trail if ::PaperTrail.enabled_for_controller?
64
80
  end
65
81
 
66
- # Tells PaperTrail any information from the controller you want
67
- # to store alongside any changes that occur.
82
+ # Tells PaperTrail any information from the controller you want to store
83
+ # alongside any changes that occur.
68
84
  def set_paper_trail_controller_info
69
85
  ::PaperTrail.controller_info = info_for_paper_trail if ::PaperTrail.enabled_for_controller?
70
86
  end
@@ -22,3 +22,8 @@ RSpec::Matchers.define :be_versioned do
22
22
  # check to see if the model has `has_paper_trail` declared on it
23
23
  match { |actual| actual.kind_of?(::PaperTrail::Model::InstanceMethods) }
24
24
  end
25
+
26
+ RSpec::Matchers.define :have_a_version_with do |attributes|
27
+ # check if the model has a version with the specified attributes
28
+ match { |actual| actual.versions.where_object(attributes).any? }
29
+ end
@@ -3,8 +3,10 @@ require 'active_support/core_ext/object' # provides the `try` method
3
3
  module PaperTrail
4
4
  module Sinatra
5
5
 
6
- # Register this module inside your Sinatra application to gain access to controller-level methods used by PaperTrail
6
+ # Register this module inside your Sinatra application to gain access to
7
+ # controller-level methods used by PaperTrail.
7
8
  def self.registered(app)
9
+ app.use RequestStore::Middleware
8
10
  app.helpers self
9
11
  app.before { set_paper_trail_whodunnit }
10
12
  end
@@ -1,3 +1,6 @@
1
+ require 'active_support/core_ext/object' # provides the `try` method
2
+ require 'paper_trail/attributes_serialization'
3
+
1
4
  module PaperTrail
2
5
  module Model
3
6
 
@@ -6,34 +9,60 @@ module PaperTrail
6
9
  end
7
10
 
8
11
  module ClassMethods
9
- # Declare this in your model to track every create, update, and destroy. Each version of
10
- # the model is available in the `versions` association.
12
+ # Declare this in your model to track every create, update, and destroy.
13
+ # Each version of the model is available in the `versions` association.
11
14
  #
12
15
  # Options:
13
- # :on the events to track (optional; defaults to all of them). Set to an array of
14
- # `:create`, `:update`, `:destroy` as desired.
15
- # :class_name the name of a custom Version class. This class should inherit from `PaperTrail::Version`.
16
- # :ignore an array of attributes for which a new `Version` will not be created if only they change.
17
- # it can also aceept a Hash as an argument where the key is the attribute to ignore (a `String` or `Symbol`),
18
- # which will only be ignored if the value is a `Proc` which returns truthily.
19
- # :if, :unless Procs that allow to specify conditions when to save versions for an object
20
- # :only inverse of `ignore` - a new `Version` will be created only for these attributes if supplied
21
- # it can also aceept a Hash as an argument where the key is the attribute to track (a `String` or `Symbol`),
22
- # which will only be counted if the value is a `Proc` which returns truthily.
23
- # :skip fields to ignore completely. As with `ignore`, updates to these fields will not create
24
- # a new `Version`. In addition, these fields will not be included in the serialized versions
25
- # of the object whenever a new `Version` is created.
26
- # :meta a hash of extra data to store. You must add a column to the `versions` table for each key.
27
- # Values are objects or procs (which are called with `self`, i.e. the model with the paper
28
- # trail). See `PaperTrail::Controller.info_for_paper_trail` for how to store data from
29
- # the controller.
30
- # :versions the name to use for the versions association. Default is `:versions`.
31
- # :version the name to use for the method which returns the version the instance was reified from.
32
- # Default is `:version`.
16
+ #
17
+ # - :on - The events to track (optional; defaults to all of them). Set
18
+ # to an array of `:create`, `:update`, `:destroy` as desired.
19
+ # - :class_name - The name of a custom Version class. This class should
20
+ # inherit from `PaperTrail::Version`.
21
+ # - :ignore - An array of attributes for which a new `Version` will not be
22
+ # created if only they change. It can also aceept a Hash as an
23
+ # argument where the key is the attribute to ignore (a `String` or
24
+ # `Symbol`), which will only be ignored if the value is a `Proc` which
25
+ # returns truthily.
26
+ # - :if, :unless - Procs that allow to specify conditions when to save
27
+ # versions for an object.
28
+ # - :only - Inverse of `ignore`. A new `Version` will be created only
29
+ # for these attributes if supplied it can also aceept a Hash as an
30
+ # argument where the key is the attribute to track (a `String` or
31
+ # `Symbol`), which will only be counted if the value is a `Proc` which
32
+ # returns truthily.
33
+ # - :skip - Fields to ignore completely. As with `ignore`, updates to
34
+ # these fields will not create a new `Version`. In addition, these
35
+ # fields will not be included in the serialized versions of the object
36
+ # whenever a new `Version` is created.
37
+ # - :meta - A hash of extra data to store. You must add a column to the
38
+ # `versions` table for each key. Values are objects or procs (which
39
+ # are called with `self`, i.e. the model with the paper trail). See
40
+ # `PaperTrail::Controller.info_for_paper_trail` for how to store data
41
+ # from the controller.
42
+ # - :versions - The name to use for the versions association. Default
43
+ # is `:versions`.
44
+ # - :version - The name to use for the method which returns the version
45
+ # the instance was reified from. Default is `:version`.
46
+ # - :save_changes - Whether or not to save changes to the object_changes
47
+ # column if it exists. Default is true
48
+ #
33
49
  def has_paper_trail(options = {})
50
+ options[:on] ||= [:create, :update, :destroy]
51
+
52
+ # Wrap the :on option in an array if necessary. This allows a single
53
+ # symbol to be passed in.
54
+ options[:on] = Array(options[:on])
55
+
56
+ setup_model_for_paper_trail(options)
57
+
58
+ setup_callbacks_from_options options[:on]
59
+ end
60
+
61
+ def setup_model_for_paper_trail(options = {})
34
62
  # Lazily include the instance methods so we don't clutter up
35
63
  # any more ActiveRecord models than we have to.
36
64
  send :include, InstanceMethods
65
+ send :extend, AttributesSerialization
37
66
 
38
67
  class_attribute :version_association_name
39
68
  self.version_association_name = options[:version] || :version
@@ -45,6 +74,7 @@ module PaperTrail
45
74
  self.version_class_name = options[:class_name] || 'PaperTrail::Version'
46
75
 
47
76
  class_attribute :paper_trail_options
77
+
48
78
  self.paper_trail_options = options.dup
49
79
 
50
80
  [:ignore, :skip, :only].each do |k|
@@ -53,13 +83,15 @@ module PaperTrail
53
83
  end
54
84
 
55
85
  paper_trail_options[:meta] ||= {}
86
+ paper_trail_options[:save_changes] = true if paper_trail_options[:save_changes].nil?
56
87
 
57
88
  class_attribute :versions_association_name
58
89
  self.versions_association_name = options[:versions] || :versions
59
90
 
60
91
  attr_accessor :paper_trail_event
61
92
 
62
- if ::ActiveRecord::VERSION::MAJOR >= 4 # `has_many` syntax for specifying order uses a lambda in Rails 4
93
+ # `has_many` syntax for specifying order uses a lambda in Rails 4
94
+ if ::ActiveRecord::VERSION::MAJOR >= 4
63
95
  has_many self.versions_association_name,
64
96
  lambda { order(model.timestamp_sort_order) },
65
97
  :class_name => self.version_class_name, :as => :item
@@ -70,96 +102,79 @@ module PaperTrail
70
102
  :order => self.paper_trail_version_class.timestamp_sort_order
71
103
  end
72
104
 
73
- options_on = Array(options[:on]) # so that a single symbol can be passed in without wrapping it in an `Array`
74
- after_create :record_create, :if => :save_version? if options_on.empty? || options_on.include?(:create)
75
- if options_on.empty? || options_on.include?(:update)
76
- before_save :reset_timestamp_attrs_for_update_if_needed!, :on => :update
77
- before_update :record_update, :if => :save_version?
78
- after_update :clear_version_instance!
79
- end
80
- after_destroy :record_destroy, :if => :save_version? if options_on.empty? || options_on.include?(:destroy)
81
- end
82
-
83
- # Switches PaperTrail off for this class.
84
- def paper_trail_off!
85
- PaperTrail.enabled_for_model(self, false)
105
+ # Reset the transaction id when the transaction is closed.
106
+ after_commit :reset_transaction_id
107
+ after_rollback :reset_transaction_id
108
+ after_rollback :clear_rolled_back_versions
86
109
  end
87
110
 
88
- def paper_trail_off
89
- warn "DEPRECATED: use `paper_trail_off!` instead of `paper_trail_off`. Support for `paper_trail_off` will be removed in PaperTrail 3.1"
90
- self.paper_trail_off!
111
+ def setup_callbacks_from_options(options_on = [])
112
+ options_on.each do |option|
113
+ send "paper_trail_on_#{option}"
114
+ end
91
115
  end
92
116
 
93
- # Switches PaperTrail on for this class.
94
- def paper_trail_on!
95
- PaperTrail.enabled_for_model(self, true)
96
- end
117
+ # Record version before or after "destroy" event
118
+ def paper_trail_on_destroy(recording_order = 'after')
119
+ unless %w[after before].include?(recording_order.to_s)
120
+ fail ArgumentError, 'recording order can only be "after" or "before"'
121
+ end
97
122
 
98
- def paper_trail_on
99
- warn "DEPRECATED: use `paper_trail_on!` instead of `paper_trail_on`. Support for `paper_trail_on` will be removed in PaperTrail 3.1"
100
- self.paper_trail_on!
101
- end
123
+ if recording_order.to_s == 'after' and
124
+ Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5")
125
+ if ::ActiveRecord::Base.belongs_to_required_by_default
126
+ ::ActiveSupport::Deprecation.warn(
127
+ "paper_trail_on_destroy(:after) is incompatible with ActiveRecord " +
128
+ "belongs_to_required_by_default and has no effect. Please use :before " +
129
+ "or disable belongs_to_required_by_default."
130
+ )
131
+ end
132
+ end
102
133
 
103
- def paper_trail_enabled_for_model?
104
- PaperTrail.enabled_for_model?(self)
105
- end
134
+ send "#{recording_order}_destroy", :record_destroy, :if => :save_version?
106
135
 
107
- def paper_trail_version_class
108
- @paper_trail_version_class ||= version_class_name.constantize
136
+ return if paper_trail_options[:on].include?(:destroy)
137
+ paper_trail_options[:on] << :destroy
109
138
  end
110
139
 
111
- # Used for Version#object attribute
112
- def serialize_attributes_for_paper_trail(attributes)
113
- # don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases
114
- return attributes if self.paper_trail_version_class.object_col_is_json?
140
+ # Record version after "update" event
141
+ def paper_trail_on_update
142
+ before_save :reset_timestamp_attrs_for_update_if_needed!,
143
+ :on => :update
144
+ after_update :record_update,
145
+ :if => :save_version?
146
+ after_update :clear_version_instance!
115
147
 
116
- serialized_attributes.each do |key, coder|
117
- if attributes.key?(key)
118
- coder = PaperTrail::Serializers::YAML unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
119
- attributes[key] = coder.dump(attributes[key])
120
- end
121
- end
148
+ return if paper_trail_options[:on].include?(:update)
149
+ paper_trail_options[:on] << :update
122
150
  end
123
151
 
124
- def unserialize_attributes_for_paper_trail(attributes)
125
- # don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases
126
- return attributes if self.paper_trail_version_class.object_col_is_json?
152
+ # Record version after "create" event
153
+ def paper_trail_on_create
154
+ after_create :record_create,
155
+ :if => :save_version?
127
156
 
128
- serialized_attributes.each do |key, coder|
129
- if attributes.key?(key)
130
- coder = PaperTrail::Serializers::YAML unless coder.respond_to?(:dump)
131
- attributes[key] = coder.load(attributes[key])
132
- end
133
- end
157
+ return if paper_trail_options[:on].include?(:create)
158
+ paper_trail_options[:on] << :create
134
159
  end
135
160
 
136
- # Used for Version#object_changes attribute
137
- def serialize_attribute_changes(changes)
138
- # don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases
139
- return changes if self.paper_trail_version_class.object_changes_col_is_json?
161
+ # Switches PaperTrail off for this class.
162
+ def paper_trail_off!
163
+ PaperTrail.enabled_for_model(self, false)
164
+ end
140
165
 
141
- serialized_attributes.each do |key, coder|
142
- if changes.key?(key)
143
- coder = PaperTrail::Serializers::YAML unless coder.respond_to?(:dump) # Fall back to YAML if `coder` has no `dump` method
144
- old_value, new_value = changes[key]
145
- changes[key] = [coder.dump(old_value),
146
- coder.dump(new_value)]
147
- end
148
- end
166
+ # Switches PaperTrail on for this class.
167
+ def paper_trail_on!
168
+ PaperTrail.enabled_for_model(self, true)
149
169
  end
150
170
 
151
- def unserialize_attribute_changes(changes)
152
- # don't serialize before values before inserting into columns of type `JSON` on `PostgreSQL` databases
153
- return changes if self.paper_trail_version_class.object_changes_col_is_json?
171
+ def paper_trail_enabled_for_model?
172
+ return false unless self.include?(PaperTrail::Model::InstanceMethods)
173
+ PaperTrail.enabled_for_model?(self)
174
+ end
154
175
 
155
- serialized_attributes.each do |key, coder|
156
- if changes.key?(key)
157
- coder = PaperTrail::Serializers::YAML unless coder.respond_to?(:dump)
158
- old_value, new_value = changes[key]
159
- changes[key] = [coder.load(old_value),
160
- coder.load(new_value)]
161
- end
162
- end
176
+ def paper_trail_version_class
177
+ @paper_trail_version_class ||= version_class_name.constantize
163
178
  end
164
179
  end
165
180
 
@@ -173,10 +188,21 @@ module PaperTrail
173
188
  end
174
189
 
175
190
  # Returns who put the object into its current state.
176
- def originator
191
+ def paper_trail_originator
177
192
  (source_version || send(self.class.versions_association_name).last).try(:whodunnit)
178
193
  end
179
194
 
195
+ def originator
196
+ ::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
197
+ self.paper_trail_originator
198
+ end
199
+
200
+ # Invoked after rollbacks to ensure versions records are not created
201
+ # for changes that never actually took place
202
+ def clear_rolled_back_versions
203
+ send(self.class.versions_association_name).reload
204
+ end
205
+
180
206
  # Returns the object (not a Version) as it was at the given timestamp.
181
207
  def version_at(timestamp, reify_options={})
182
208
  # Because a version stores how its object looked *before* the change,
@@ -221,7 +247,19 @@ module PaperTrail
221
247
  self.class.paper_trail_on! if paper_trail_was_enabled
222
248
  end
223
249
 
224
- # Temporarily overwrites the value of whodunnit and then executes the provided block.
250
+ # Utility method for reifying. Anything executed inside the block will
251
+ # appear like a new record.
252
+ def appear_as_new_record
253
+ instance_eval {
254
+ alias :old_new_record? :new_record?
255
+ alias :new_record? :present?
256
+ }
257
+ yield
258
+ instance_eval { alias :new_record? :old_new_record? }
259
+ end
260
+
261
+ # Temporarily overwrites the value of whodunnit and then executes the
262
+ # provided block.
225
263
  def whodunnit(value)
226
264
  raise ArgumentError, 'expected to receive a block' unless block_given?
227
265
  current_whodunnit = PaperTrail.whodunnit
@@ -231,11 +269,14 @@ module PaperTrail
231
269
  PaperTrail.whodunnit = current_whodunnit
232
270
  end
233
271
 
234
- # Mimicks behavior of `touch` method from `ActiveRecord::Persistence`, but generates a version
272
+ # Mimics the `touch` method from `ActiveRecord::Persistence`, but also
273
+ # creates a version. A version is created regardless of options such as
274
+ # `:on`, `:if`, or `:unless`.
235
275
  #
236
- # TODO: lookinto leveraging the `after_touch` callback from `ActiveRecord` to allow the
237
- # regular `touch` method go generate a version as normal. May make sense to switch the `record_update`
238
- # method to leverage an `after_update` callback anyways (likely for v3.1.0)
276
+ # TODO: look into leveraging the `after_touch` callback from
277
+ # `ActiveRecord` to allow the regular `touch` method to generate a version
278
+ # as normal. May make sense to switch the `record_update` method to
279
+ # leverage an `after_update` callback anyways (likely for v4.0.0)
239
280
  def touch_with_version(name = nil)
240
281
  raise ActiveRecordError, "can not touch on a new record object" unless persisted?
241
282
 
@@ -244,11 +285,20 @@ module PaperTrail
244
285
  current_time = current_time_from_proper_timezone
245
286
 
246
287
  attributes.each { |column| write_attribute(column, current_time) }
247
- save!
288
+
289
+ record_update(true) unless will_record_after_update?
290
+ save!(:validate => false)
248
291
  end
249
292
 
250
293
  private
251
294
 
295
+ # Returns true if `save` will cause `record_update`
296
+ # to be called via the `after_update` callback.
297
+ def will_record_after_update?
298
+ on = paper_trail_options[:on]
299
+ on.nil? || on.include?(:update)
300
+ end
301
+
252
302
  def source_version
253
303
  send self.class.version_association_name
254
304
  end
@@ -259,63 +309,162 @@ module PaperTrail
259
309
  :event => paper_trail_event || 'create',
260
310
  :whodunnit => PaperTrail.whodunnit
261
311
  }
262
-
263
- if changed_notably? and self.class.paper_trail_version_class.column_names.include?('object_changes')
264
- data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail :
265
- PaperTrail.serializer.dump(changes_for_paper_trail)
312
+ if respond_to?(:updated_at)
313
+ data[PaperTrail.timestamp_field] = updated_at
314
+ end
315
+ if pt_record_object_changes? && changed_notably?
316
+ data[:object_changes] = pt_recordable_object_changes
317
+ end
318
+ if self.class.paper_trail_version_class.column_names.include?('transaction_id')
319
+ data[:transaction_id] = PaperTrail.transaction_id
266
320
  end
267
- send(self.class.versions_association_name).create! merge_metadata(data)
321
+ version = send(self.class.versions_association_name).create! merge_metadata(data)
322
+ set_transaction_id(version)
323
+ save_associations(version)
268
324
  end
269
325
  end
270
326
 
271
- def record_update
272
- if paper_trail_switched_on? && changed_notably?
273
- object_attrs = object_attrs_for_paper_trail(item_before_change)
327
+ def record_update(force = nil)
328
+ if paper_trail_switched_on? && (force || changed_notably?)
274
329
  data = {
275
330
  :event => paper_trail_event || 'update',
276
- :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
331
+ :object => pt_recordable_object,
277
332
  :whodunnit => PaperTrail.whodunnit
278
333
  }
279
-
280
- if self.class.paper_trail_version_class.column_names.include?('object_changes')
281
- data[:object_changes] = self.class.paper_trail_version_class.object_changes_col_is_json? ? changes_for_paper_trail :
282
- PaperTrail.serializer.dump(changes_for_paper_trail)
334
+ if respond_to?(:updated_at)
335
+ data[PaperTrail.timestamp_field] = updated_at
336
+ end
337
+ if pt_record_object_changes?
338
+ data[:object_changes] = pt_recordable_object_changes
339
+ end
340
+ if self.class.paper_trail_version_class.column_names.include?('transaction_id')
341
+ data[:transaction_id] = PaperTrail.transaction_id
283
342
  end
284
- send(self.class.versions_association_name).build merge_metadata(data)
343
+ version = send(self.class.versions_association_name).create merge_metadata(data)
344
+ set_transaction_id(version)
345
+ save_associations(version)
346
+ end
347
+ end
348
+
349
+ # Returns a boolean indicating whether to store serialized version diffs
350
+ # in the `object_changes` column of the version record.
351
+ # @api private
352
+ def pt_record_object_changes?
353
+ paper_trail_options[:save_changes] &&
354
+ self.class.paper_trail_version_class.column_names.include?('object_changes')
355
+ end
356
+
357
+ # Returns an object which can be assigned to the `object` attribute of a
358
+ # nascent version record. If the `object` column is a postgres `json`
359
+ # column, then a hash can be used in the assignment, otherwise the column
360
+ # is a `text` column, and we must perform the serialization here, using
361
+ # `PaperTrail.serializer`.
362
+ # @api private
363
+ def pt_recordable_object
364
+ object_attrs = object_attrs_for_paper_trail(attributes_before_change)
365
+ if self.class.paper_trail_version_class.object_col_is_json?
366
+ object_attrs
367
+ else
368
+ PaperTrail.serializer.dump(object_attrs)
369
+ end
370
+ end
371
+
372
+ # Returns an object which can be assigned to the `object_changes`
373
+ # attribute of a nascent version record. If the `object_changes` column is
374
+ # a postgres `json` column, then a hash can be used in the assignment,
375
+ # otherwise the column is a `text` column, and we must perform the
376
+ # serialization here, using `PaperTrail.serializer`.
377
+ # @api private
378
+ def pt_recordable_object_changes
379
+ if self.class.paper_trail_version_class.object_changes_col_is_json?
380
+ changes_for_paper_trail
381
+ else
382
+ PaperTrail.serializer.dump(changes_for_paper_trail)
285
383
  end
286
384
  end
287
385
 
288
386
  def changes_for_paper_trail
289
- self.changes.delete_if do |key, value|
290
- !notably_changed.include?(key)
291
- end.tap { |changes| self.class.serialize_attribute_changes(changes) }
387
+ _changes = changes.delete_if { |k,v| !notably_changed.include?(k) }
388
+ self.class.serialize_attribute_changes_for_paper_trail!(_changes)
389
+ _changes.to_hash
292
390
  end
293
391
 
294
- # Invoked via`after_update` callback for when a previous version is reified and then saved
392
+ # Invoked via`after_update` callback for when a previous version is
393
+ # reified and then saved.
295
394
  def clear_version_instance!
296
395
  send("#{self.class.version_association_name}=", nil)
297
396
  end
298
397
 
398
+ # Invoked via callback when a user attempts to persist a reified
399
+ # `Version`.
299
400
  def reset_timestamp_attrs_for_update_if_needed!
300
- return if self.live? # invoked via callback when a user attempts to persist a reified `Version`
301
- timestamp_attributes_for_update_in_model.each { |column| send("reset_#{column}!") }
401
+ return if self.live?
402
+ timestamp_attributes_for_update_in_model.each do |column|
403
+ # ActiveRecord 4.2 deprecated `reset_column!` in favor of
404
+ # `restore_column!`.
405
+ if respond_to?("restore_#{column}!")
406
+ send("restore_#{column}!")
407
+ else
408
+ send("reset_#{column}!")
409
+ end
410
+ end
302
411
  end
303
412
 
304
413
  def record_destroy
305
414
  if paper_trail_switched_on? and not new_record?
306
- object_attrs = object_attrs_for_paper_trail(item_before_change)
307
415
  data = {
308
416
  :item_id => self.id,
309
417
  :item_type => self.class.base_class.name,
310
418
  :event => paper_trail_event || 'destroy',
311
- :object => self.class.paper_trail_version_class.object_col_is_json? ? object_attrs : PaperTrail.serializer.dump(object_attrs),
419
+ :object => pt_recordable_object,
312
420
  :whodunnit => PaperTrail.whodunnit
313
421
  }
314
- send("#{self.class.version_association_name}=", self.class.paper_trail_version_class.create(merge_metadata(data)))
422
+ if self.class.paper_trail_version_class.column_names.include?('transaction_id')
423
+ data[:transaction_id] = PaperTrail.transaction_id
424
+ end
425
+ version = self.class.paper_trail_version_class.create(merge_metadata(data))
426
+ send("#{self.class.version_association_name}=", version)
315
427
  send(self.class.versions_association_name).send :load_target
428
+ set_transaction_id(version)
429
+ save_associations(version)
316
430
  end
317
431
  end
318
432
 
433
+ # Saves associations if the join table for `VersionAssociation` exists.
434
+ def save_associations(version)
435
+ return unless PaperTrail.config.track_associations?
436
+ self.class.reflect_on_all_associations(:belongs_to).each do |assoc|
437
+ assoc_version_args = {
438
+ :version_id => version.id,
439
+ :foreign_key_name => assoc.foreign_key
440
+ }
441
+
442
+ if assoc.options[:polymorphic]
443
+ associated_record = send(assoc.name) if send(assoc.foreign_type)
444
+ if associated_record && associated_record.class.paper_trail_enabled_for_model?
445
+ assoc_version_args.merge!(:foreign_key_id => associated_record.id)
446
+ end
447
+ elsif assoc.klass.paper_trail_enabled_for_model?
448
+ assoc_version_args.merge!(:foreign_key_id => send(assoc.foreign_key))
449
+ end
450
+
451
+ PaperTrail::VersionAssociation.create(assoc_version_args) if assoc_version_args.has_key?(:foreign_key_id)
452
+ end
453
+ end
454
+
455
+ def set_transaction_id(version)
456
+ return unless self.class.paper_trail_version_class.column_names.include?('transaction_id')
457
+ if PaperTrail.transaction? && PaperTrail.transaction_id.nil?
458
+ PaperTrail.transaction_id = version.id
459
+ version.transaction_id = version.id
460
+ version.save
461
+ end
462
+ end
463
+
464
+ def reset_transaction_id
465
+ PaperTrail.transaction_id = nil
466
+ end
467
+
319
468
  def merge_metadata(data)
320
469
  # First we merge the model-level metadata in `meta`.
321
470
  paper_trail_options[:meta].each do |k,v|
@@ -323,8 +472,9 @@ module PaperTrail
323
472
  if v.respond_to?(:call)
324
473
  v.call(self)
325
474
  elsif v.is_a?(Symbol) && respond_to?(v)
326
- # if it is an attribute that is changing, be sure to grab the current version
327
- if has_attribute?(v) && send("#{v}_changed?".to_sym)
475
+ # If it is an attribute that is changing in an existing object,
476
+ # be sure to grab the current version.
477
+ if has_attribute?(v) && send("#{v}_changed?".to_sym) && data[:event] != 'create'
328
478
  send("#{v}_was".to_sym)
329
479
  else
330
480
  send(v)
@@ -333,19 +483,14 @@ module PaperTrail
333
483
  v
334
484
  end
335
485
  end
486
+
336
487
  # Second we merge any extra data from the controller (if available).
337
488
  data.merge(PaperTrail.controller_info || {})
338
489
  end
339
490
 
340
- def item_before_change
341
- previous = self.dup
342
- # `dup` clears timestamps so we add them back.
343
- all_timestamp_attributes.each do |column|
344
- previous[column] = send(column) if self.class.column_names.include?(column.to_s) and not send(column).nil?
345
- end
346
- enums = previous.respond_to?(:defined_enums) ? previous.defined_enums : {}
347
- previous.tap do |prev|
348
- prev.id = id # `dup` clears the `id` so we add that back
491
+ def attributes_before_change
492
+ attributes.tap do |prev|
493
+ enums = self.respond_to?(:defined_enums) ? self.defined_enums : {}
349
494
  changed_attributes.select { |k,v| self.class.column_names.include?(k) }.each do |attr, before|
350
495
  before = enums[attr][before] if enums[attr]
351
496
  prev[attr] = before
@@ -353,28 +498,38 @@ module PaperTrail
353
498
  end
354
499
  end
355
500
 
356
- # returns hash of object attributes (with appropriate attributes serialized), ommitting attributes to be skipped
357
- def object_attrs_for_paper_trail(object)
358
- _attrs = object.attributes.except(*self.paper_trail_options[:skip]).tap do |attributes|
359
- self.class.serialize_attributes_for_paper_trail(attributes)
360
- end
501
+ # Returns hash of attributes (with appropriate attributes serialized),
502
+ # ommitting attributes to be skipped.
503
+ def object_attrs_for_paper_trail(attributes_hash)
504
+ attrs = attributes_hash.except(*self.paper_trail_options[:skip])
505
+ self.class.serialize_attributes_for_paper_trail!(attrs)
506
+ attrs
361
507
  end
362
508
 
363
- # This method is invoked in order to determine whether it is appropriate to generate a new version instance.
364
- # In ActiveRecord 4, the way ActiveModel::Dirty handles the `changes` hash has changed so that
365
- # `timestamp_attributes_for_update` get inserted PRIOR to the persistence happening, so we must accomodate
366
- # this by checking to ensure attributes other than those ignored and update timestamps are really being modified.
509
+ # Determines whether it is appropriate to generate a new version
510
+ # instance. A timestamp-only update (e.g. only `updated_at` changed) is
511
+ # considered notable unless an ignored attribute was also changed.
367
512
  def changed_notably?
368
- if self.paper_trail_options[:ignore].any? && (changed & self.paper_trail_options[:ignore]).any?
369
- (notably_changed - timestamp_attributes_for_update_in_model.map(&:to_s)).any?
513
+ if ignored_attr_has_changed?
514
+ timestamps = timestamp_attributes_for_update_in_model.map(&:to_s)
515
+ (notably_changed - timestamps).any?
370
516
  else
371
517
  notably_changed.any?
372
518
  end
373
519
  end
374
520
 
521
+ # An attributed is "ignored" if it is listed in the `:ignore` option
522
+ # and/or the `:skip` option. Returns true if an ignored attribute has
523
+ # changed.
524
+ def ignored_attr_has_changed?
525
+ ignored = self.paper_trail_options[:ignore] + self.paper_trail_options[:skip]
526
+ ignored.any? && (changed & ignored).any?
527
+ end
528
+
375
529
  def notably_changed
376
530
  only = self.paper_trail_options[:only].dup
377
- # remove Hash arguments and then evaluate whether the attributes (the keys of the hash) should also get pushed into the collection
531
+ # Remove Hash arguments and then evaluate whether the attributes (the
532
+ # keys of the hash) should also get pushed into the collection.
378
533
  only.delete_if do |obj|
379
534
  obj.is_a?(Hash) && obj.each { |attr, condition| only << attr if condition.respond_to?(:call) && condition.call(self) }
380
535
  end
@@ -383,7 +538,8 @@ module PaperTrail
383
538
 
384
539
  def changed_and_not_ignored
385
540
  ignore = self.paper_trail_options[:ignore].dup
386
- # remove Hash arguments and then evaluate whether the attributes (the keys of the hash) should also get pushed into the collection
541
+ # Remove Hash arguments and then evaluate whether the attributes (the
542
+ # keys of the hash) should also get pushed into the collection.
387
543
  ignore.delete_if do |obj|
388
544
  obj.is_a?(Hash) && obj.each { |attr, condition| ignore << attr if condition.respond_to?(:call) && condition.call(self) }
389
545
  end