paper_trail 3.0.6 → 4.2.0

Sign up to get free protection for your applications and to get access to all the features.
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