paper_trail 4.2.0 → 7.1.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (171) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/paper_trail/install_generator.rb +91 -17
  3. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb.erb +12 -0
  4. data/lib/generators/paper_trail/templates/{add_transaction_id_column_to_versions.rb → add_transaction_id_column_to_versions.rb.erb} +3 -1
  5. data/lib/generators/paper_trail/templates/create_version_associations.rb.erb +22 -0
  6. data/lib/generators/paper_trail/templates/{create_versions.rb → create_versions.rb.erb} +9 -7
  7. data/lib/paper_trail.rb +180 -148
  8. data/lib/paper_trail/attribute_serializers/README.md +10 -0
  9. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +80 -0
  10. data/lib/paper_trail/attribute_serializers/legacy_active_record_shim.rb +48 -0
  11. data/lib/paper_trail/attribute_serializers/object_attribute.rb +39 -0
  12. data/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +42 -0
  13. data/lib/paper_trail/cleaner.rb +16 -10
  14. data/lib/paper_trail/config.rb +28 -27
  15. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version.rb +5 -1
  16. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version_association.rb +6 -2
  17. data/lib/paper_trail/frameworks/cucumber.rb +1 -0
  18. data/lib/paper_trail/frameworks/rails.rb +2 -7
  19. data/lib/paper_trail/frameworks/rails/controller.rb +20 -18
  20. data/lib/paper_trail/frameworks/rails/engine.rb +6 -1
  21. data/lib/paper_trail/frameworks/rspec.rb +17 -6
  22. data/lib/paper_trail/frameworks/rspec/helpers.rb +3 -1
  23. data/lib/paper_trail/has_paper_trail.rb +25 -503
  24. data/lib/paper_trail/model_config.rb +207 -0
  25. data/lib/paper_trail/queries/versions/where_object.rb +60 -0
  26. data/lib/paper_trail/queries/versions/where_object_changes.rb +68 -0
  27. data/lib/paper_trail/record_history.rb +2 -12
  28. data/lib/paper_trail/record_trail.rb +573 -0
  29. data/lib/paper_trail/reifier.rb +164 -215
  30. data/lib/paper_trail/reifiers/belongs_to.rb +48 -0
  31. data/lib/paper_trail/reifiers/has_and_belongs_to_many.rb +50 -0
  32. data/lib/paper_trail/reifiers/has_many.rb +110 -0
  33. data/lib/paper_trail/reifiers/has_many_through.rb +90 -0
  34. data/lib/paper_trail/reifiers/has_one.rb +76 -0
  35. data/lib/paper_trail/serializers/json.rb +16 -7
  36. data/lib/paper_trail/serializers/yaml.rb +9 -13
  37. data/lib/paper_trail/version_association_concern.rb +3 -5
  38. data/lib/paper_trail/version_concern.rb +138 -111
  39. data/lib/paper_trail/version_number.rb +10 -9
  40. metadata +95 -327
  41. data/.gitignore +0 -22
  42. data/.rspec +0 -2
  43. data/.travis.yml +0 -41
  44. data/CHANGELOG.md +0 -362
  45. data/CONTRIBUTING.md +0 -84
  46. data/Gemfile +0 -2
  47. data/MIT-LICENSE +0 -20
  48. data/README.md +0 -1535
  49. data/Rakefile +0 -30
  50. data/doc/bug_report_template.rb +0 -65
  51. data/gemfiles/ar3.gemfile +0 -61
  52. data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +0 -10
  53. data/lib/generators/paper_trail/templates/create_version_associations.rb +0 -17
  54. data/lib/paper_trail/attributes_serialization.rb +0 -89
  55. data/lib/paper_trail/frameworks/sinatra.rb +0 -38
  56. data/paper_trail.gemspec +0 -59
  57. data/spec/generators/install_generator_spec.rb +0 -67
  58. data/spec/models/animal_spec.rb +0 -36
  59. data/spec/models/boolit_spec.rb +0 -48
  60. data/spec/models/callback_modifier_spec.rb +0 -96
  61. data/spec/models/fluxor_spec.rb +0 -19
  62. data/spec/models/gadget_spec.rb +0 -70
  63. data/spec/models/joined_version_spec.rb +0 -47
  64. data/spec/models/json_version_spec.rb +0 -103
  65. data/spec/models/kitchen/banana_spec.rb +0 -14
  66. data/spec/models/not_on_update_spec.rb +0 -19
  67. data/spec/models/post_with_status_spec.rb +0 -17
  68. data/spec/models/skipper_spec.rb +0 -46
  69. data/spec/models/thing_spec.rb +0 -11
  70. data/spec/models/version_spec.rb +0 -239
  71. data/spec/models/widget_spec.rb +0 -298
  72. data/spec/modules/paper_trail_spec.rb +0 -27
  73. data/spec/modules/version_concern_spec.rb +0 -32
  74. data/spec/modules/version_number_spec.rb +0 -44
  75. data/spec/paper_trail/config_spec.rb +0 -52
  76. data/spec/paper_trail_spec.rb +0 -66
  77. data/spec/rails_helper.rb +0 -34
  78. data/spec/requests/articles_spec.rb +0 -30
  79. data/spec/spec_helper.rb +0 -95
  80. data/spec/support/alt_db_init.rb +0 -59
  81. data/test/custom_json_serializer.rb +0 -13
  82. data/test/dummy/Rakefile +0 -7
  83. data/test/dummy/app/controllers/application_controller.rb +0 -20
  84. data/test/dummy/app/controllers/articles_controller.rb +0 -17
  85. data/test/dummy/app/controllers/test_controller.rb +0 -5
  86. data/test/dummy/app/controllers/widgets_controller.rb +0 -31
  87. data/test/dummy/app/helpers/application_helper.rb +0 -2
  88. data/test/dummy/app/models/animal.rb +0 -6
  89. data/test/dummy/app/models/article.rb +0 -16
  90. data/test/dummy/app/models/authorship.rb +0 -5
  91. data/test/dummy/app/models/book.rb +0 -9
  92. data/test/dummy/app/models/boolit.rb +0 -4
  93. data/test/dummy/app/models/callback_modifier.rb +0 -45
  94. data/test/dummy/app/models/cat.rb +0 -2
  95. data/test/dummy/app/models/chapter.rb +0 -9
  96. data/test/dummy/app/models/citation.rb +0 -5
  97. data/test/dummy/app/models/customer.rb +0 -4
  98. data/test/dummy/app/models/document.rb +0 -4
  99. data/test/dummy/app/models/dog.rb +0 -2
  100. data/test/dummy/app/models/editor.rb +0 -4
  101. data/test/dummy/app/models/editorship.rb +0 -5
  102. data/test/dummy/app/models/elephant.rb +0 -3
  103. data/test/dummy/app/models/fluxor.rb +0 -3
  104. data/test/dummy/app/models/foo_widget.rb +0 -2
  105. data/test/dummy/app/models/fruit.rb +0 -5
  106. data/test/dummy/app/models/gadget.rb +0 -3
  107. data/test/dummy/app/models/kitchen/banana.rb +0 -5
  108. data/test/dummy/app/models/legacy_widget.rb +0 -4
  109. data/test/dummy/app/models/line_item.rb +0 -4
  110. data/test/dummy/app/models/not_on_update.rb +0 -4
  111. data/test/dummy/app/models/order.rb +0 -5
  112. data/test/dummy/app/models/paragraph.rb +0 -5
  113. data/test/dummy/app/models/person.rb +0 -38
  114. data/test/dummy/app/models/post.rb +0 -3
  115. data/test/dummy/app/models/post_with_status.rb +0 -8
  116. data/test/dummy/app/models/protected_widget.rb +0 -3
  117. data/test/dummy/app/models/quotation.rb +0 -5
  118. data/test/dummy/app/models/section.rb +0 -6
  119. data/test/dummy/app/models/skipper.rb +0 -6
  120. data/test/dummy/app/models/song.rb +0 -32
  121. data/test/dummy/app/models/thing.rb +0 -3
  122. data/test/dummy/app/models/translation.rb +0 -4
  123. data/test/dummy/app/models/whatchamajigger.rb +0 -4
  124. data/test/dummy/app/models/widget.rb +0 -15
  125. data/test/dummy/app/models/wotsit.rb +0 -8
  126. data/test/dummy/app/versions/joined_version.rb +0 -5
  127. data/test/dummy/app/versions/json_version.rb +0 -3
  128. data/test/dummy/app/versions/kitchen/banana_version.rb +0 -5
  129. data/test/dummy/app/versions/post_version.rb +0 -3
  130. data/test/dummy/app/views/layouts/application.html.erb +0 -14
  131. data/test/dummy/config.ru +0 -4
  132. data/test/dummy/config/application.rb +0 -69
  133. data/test/dummy/config/boot.rb +0 -10
  134. data/test/dummy/config/database.mysql.yml +0 -19
  135. data/test/dummy/config/database.postgres.yml +0 -15
  136. data/test/dummy/config/database.sqlite.yml +0 -15
  137. data/test/dummy/config/environment.rb +0 -5
  138. data/test/dummy/config/environments/development.rb +0 -40
  139. data/test/dummy/config/environments/production.rb +0 -73
  140. data/test/dummy/config/environments/test.rb +0 -41
  141. data/test/dummy/config/initializers/backtrace_silencers.rb +0 -7
  142. data/test/dummy/config/initializers/inflections.rb +0 -10
  143. data/test/dummy/config/initializers/mime_types.rb +0 -5
  144. data/test/dummy/config/initializers/paper_trail.rb +0 -10
  145. data/test/dummy/config/initializers/secret_token.rb +0 -7
  146. data/test/dummy/config/initializers/session_store.rb +0 -8
  147. data/test/dummy/config/locales/en.yml +0 -5
  148. data/test/dummy/config/routes.rb +0 -4
  149. data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +0 -287
  150. data/test/dummy/db/schema.rb +0 -246
  151. data/test/dummy/script/rails +0 -6
  152. data/test/functional/controller_test.rb +0 -91
  153. data/test/functional/enabled_for_controller_test.rb +0 -29
  154. data/test/functional/modular_sinatra_test.rb +0 -48
  155. data/test/functional/sinatra_test.rb +0 -49
  156. data/test/functional/thread_safety_test.rb +0 -48
  157. data/test/paper_trail_test.rb +0 -38
  158. data/test/test_helper.rb +0 -105
  159. data/test/time_travel_helper.rb +0 -15
  160. data/test/unit/associations_test.rb +0 -726
  161. data/test/unit/cleaner_test.rb +0 -182
  162. data/test/unit/inheritance_column_test.rb +0 -43
  163. data/test/unit/model_test.rb +0 -1373
  164. data/test/unit/protected_attrs_test.rb +0 -47
  165. data/test/unit/serializer_test.rb +0 -117
  166. data/test/unit/serializers/json_test.rb +0 -88
  167. data/test/unit/serializers/mixin_json_test.rb +0 -36
  168. data/test/unit/serializers/mixin_yaml_test.rb +0 -49
  169. data/test/unit/serializers/yaml_test.rb +0 -52
  170. data/test/unit/timestamp_test.rb +0 -43
  171. data/test/unit/version_test.rb +0 -119
@@ -0,0 +1,207 @@
1
+ require "active_support/core_ext"
2
+
3
+ module PaperTrail
4
+ # Configures an ActiveRecord model, mostly at application boot time, but also
5
+ # sometimes mid-request, with methods like enable/disable.
6
+ class ModelConfig
7
+ E_CANNOT_RECORD_AFTER_DESTROY = <<-STR.strip_heredoc.freeze
8
+ paper_trail.on_destroy(:after) is incompatible with ActiveRecord's
9
+ belongs_to_required_by_default and has no effect. Please use :before
10
+ or disable belongs_to_required_by_default.
11
+ STR
12
+
13
+ def initialize(model_class)
14
+ @model_class = model_class
15
+ end
16
+
17
+ # Switches PaperTrail off for this class.
18
+ def disable
19
+ ::PaperTrail.enabled_for_model(@model_class, false)
20
+ end
21
+
22
+ # Switches PaperTrail on for this class.
23
+ def enable
24
+ ::PaperTrail.enabled_for_model(@model_class, true)
25
+ end
26
+
27
+ def enabled?
28
+ return false unless @model_class.include?(::PaperTrail::Model::InstanceMethods)
29
+ ::PaperTrail.enabled_for_model?(@model_class)
30
+ end
31
+
32
+ # Adds a callback that records a version after a "create" event.
33
+ def on_create
34
+ @model_class.after_create { |r|
35
+ r.paper_trail.record_create if r.paper_trail.save_version?
36
+ }
37
+ return if @model_class.paper_trail_options[:on].include?(:create)
38
+ @model_class.paper_trail_options[:on] << :create
39
+ end
40
+
41
+ # Adds a callback that records a version before or after a "destroy" event.
42
+ def on_destroy(recording_order = "before")
43
+ unless %w[after before].include?(recording_order.to_s)
44
+ raise ArgumentError, 'recording order can only be "after" or "before"'
45
+ end
46
+
47
+ if recording_order.to_s == "after" && cannot_record_after_destroy?
48
+ ::ActiveSupport::Deprecation.warn(E_CANNOT_RECORD_AFTER_DESTROY)
49
+ end
50
+
51
+ @model_class.send(
52
+ "#{recording_order}_destroy",
53
+ ->(r) { r.paper_trail.record_destroy if r.paper_trail.save_version? }
54
+ )
55
+
56
+ return if @model_class.paper_trail_options[:on].include?(:destroy)
57
+ @model_class.paper_trail_options[:on] << :destroy
58
+ end
59
+
60
+ # Adds a callback that records a version after an "update" event.
61
+ def on_update
62
+ @model_class.before_save(on: :update) { |r|
63
+ r.paper_trail.reset_timestamp_attrs_for_update_if_needed
64
+ }
65
+ @model_class.after_update { |r|
66
+ r.paper_trail.record_update(nil) if r.paper_trail.save_version?
67
+ }
68
+ @model_class.after_update { |r|
69
+ r.paper_trail.clear_version_instance
70
+ }
71
+ return if @model_class.paper_trail_options[:on].include?(:update)
72
+ @model_class.paper_trail_options[:on] << :update
73
+ end
74
+
75
+ # Set up `@model_class` for PaperTrail. Installs callbacks, associations,
76
+ # "class attributes", instance methods, and more.
77
+ # @api private
78
+ def setup(options = {})
79
+ options[:on] ||= %i[create update destroy]
80
+ options[:on] = Array(options[:on]) # Support single symbol
81
+ @model_class.send :include, ::PaperTrail::Model::InstanceMethods
82
+ if ::ActiveRecord::VERSION::STRING < "4.2"
83
+ ::ActiveSupport::Deprecation.warn(
84
+ "Your version of ActiveRecord (< 4.2) has reached EOL. PaperTrail " \
85
+ "will soon drop support. Please upgrade ActiveRecord ASAP."
86
+ )
87
+ @model_class.send :extend, AttributeSerializers::LegacyActiveRecordShim
88
+ end
89
+ setup_options(options)
90
+ setup_associations(options)
91
+ setup_transaction_callbacks
92
+ setup_callbacks_from_options options[:on]
93
+ setup_callbacks_for_habtm options[:join_tables]
94
+ end
95
+
96
+ def version_class
97
+ @_version_class ||= @model_class.version_class_name.constantize
98
+ end
99
+
100
+ private
101
+
102
+ def active_record_gem_version
103
+ Gem::Version.new(ActiveRecord::VERSION::STRING)
104
+ end
105
+
106
+ def cannot_record_after_destroy?
107
+ Gem::Version.new(ActiveRecord::VERSION::STRING).release >= Gem::Version.new("5") &&
108
+ ::ActiveRecord::Base.belongs_to_required_by_default
109
+ end
110
+
111
+ def habtm_assocs_not_skipped
112
+ @model_class.reflect_on_all_associations(:has_and_belongs_to_many).
113
+ reject { |a| @model_class.paper_trail_options[:skip].include?(a.name.to_s) }
114
+ end
115
+
116
+ def setup_associations(options)
117
+ @model_class.class_attribute :version_association_name
118
+ @model_class.version_association_name = options[:version] || :version
119
+
120
+ # The version this instance was reified from.
121
+ @model_class.send :attr_accessor, @model_class.version_association_name
122
+
123
+ @model_class.class_attribute :version_class_name
124
+ @model_class.version_class_name = options[:class_name] || "PaperTrail::Version"
125
+
126
+ @model_class.class_attribute :versions_association_name
127
+ @model_class.versions_association_name = options[:versions] || :versions
128
+
129
+ @model_class.send :attr_accessor, :paper_trail_event
130
+
131
+ @model_class.has_many(
132
+ @model_class.versions_association_name,
133
+ -> { order(model.timestamp_sort_order) },
134
+ class_name: @model_class.version_class_name,
135
+ as: :item
136
+ )
137
+ end
138
+
139
+ # Adds callbacks to record changes to habtm associations such that on save
140
+ # the previous version of the association (if changed) can be reconstructed.
141
+ def setup_callbacks_for_habtm(join_tables)
142
+ @model_class.send :attr_accessor, :paper_trail_habtm
143
+ @model_class.class_attribute :paper_trail_save_join_tables
144
+ @model_class.paper_trail_save_join_tables = Array.wrap(join_tables)
145
+ habtm_assocs_not_skipped.each(&method(:setup_habtm_change_callbacks))
146
+ end
147
+
148
+ def setup_callbacks_from_options(options_on = [])
149
+ options_on.each do |event|
150
+ public_send("on_#{event}")
151
+ end
152
+ end
153
+
154
+ def setup_habtm_change_callbacks(assoc)
155
+ assoc_name = assoc.name
156
+ %w[add remove].each do |verb|
157
+ @model_class.send(:"before_#{verb}_for_#{assoc_name}").send(
158
+ :<<,
159
+ lambda do |*args|
160
+ update_habtm_state(assoc_name, :"before_#{verb}", args[-2], args.last)
161
+ end
162
+ )
163
+ end
164
+ end
165
+
166
+ def setup_options(options)
167
+ @model_class.class_attribute :paper_trail_options
168
+ @model_class.paper_trail_options = options.dup
169
+
170
+ %i[ignore skip only].each do |k|
171
+ @model_class.paper_trail_options[k] = [@model_class.paper_trail_options[k]].
172
+ flatten.
173
+ compact.
174
+ map { |attr| attr.is_a?(Hash) ? attr.stringify_keys : attr.to_s }
175
+ end
176
+
177
+ @model_class.paper_trail_options[:meta] ||= {}
178
+ if @model_class.paper_trail_options[:save_changes].nil?
179
+ @model_class.paper_trail_options[:save_changes] = true
180
+ end
181
+ end
182
+
183
+ # Reset the transaction id when the transaction is closed.
184
+ def setup_transaction_callbacks
185
+ @model_class.after_commit { PaperTrail.clear_transaction_id }
186
+ @model_class.after_rollback { PaperTrail.clear_transaction_id }
187
+ @model_class.after_rollback { paper_trail.clear_rolled_back_versions }
188
+ end
189
+
190
+ def update_habtm_state(name, callback, model, assoc)
191
+ model.paper_trail_habtm ||= {}
192
+ model.paper_trail_habtm[name] ||= { removed: [], added: [] }
193
+ state = model.paper_trail_habtm[name]
194
+ assoc_id = assoc.id
195
+ case callback
196
+ when :before_add
197
+ state[:added] |= [assoc_id]
198
+ state[:removed] -= [assoc_id]
199
+ when :before_remove
200
+ state[:removed] |= [assoc_id]
201
+ state[:added] -= [assoc_id]
202
+ else
203
+ raise "Invalid callback: #{callback}"
204
+ end
205
+ end
206
+ end
207
+ end
@@ -0,0 +1,60 @@
1
+ module PaperTrail
2
+ module Queries
3
+ module Versions
4
+ # For public API documentation, see `where_object` in
5
+ # `paper_trail/version_concern.rb`.
6
+ # @api private
7
+ class WhereObject
8
+ # - version_model_class - The class that VersionConcern was mixed into.
9
+ # - attributes - A `Hash` of attributes and values. See the public API
10
+ # documentation for details.
11
+ # @api private
12
+ def initialize(version_model_class, attributes)
13
+ @version_model_class = version_model_class
14
+ @attributes = attributes
15
+ end
16
+
17
+ # @api private
18
+ def execute
19
+ case @version_model_class.columns_hash["object"].type
20
+ when :jsonb
21
+ jsonb
22
+ when :json
23
+ json
24
+ else
25
+ text
26
+ end
27
+ end
28
+
29
+ private
30
+
31
+ # @api private
32
+ def json
33
+ predicates = []
34
+ values = []
35
+ @attributes.each do |field, value|
36
+ predicates.push "object->>? = ?"
37
+ values.concat([field, value.to_s])
38
+ end
39
+ sql = predicates.join(" and ")
40
+ @version_model_class.where(sql, *values)
41
+ end
42
+
43
+ # @api private
44
+ def jsonb
45
+ @version_model_class.where("object @> ?", @attributes.to_json)
46
+ end
47
+
48
+ # @api private
49
+ def text
50
+ arel_field = @version_model_class.arel_table[:object]
51
+ where_conditions = @attributes.map { |field, value|
52
+ ::PaperTrail.serializer.where_object_condition(arel_field, field, value)
53
+ }
54
+ where_conditions = where_conditions.reduce { |a, e| a.and(e) }
55
+ @version_model_class.where(where_conditions)
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,68 @@
1
+ module PaperTrail
2
+ module Queries
3
+ module Versions
4
+ # For public API documentation, see `where_object` in
5
+ # `paper_trail/version_concern.rb`.
6
+ # @api private
7
+ class WhereObjectChanges
8
+ # - version_model_class - The class that VersionConcern was mixed into.
9
+ # - attributes - A `Hash` of attributes and values. See the public API
10
+ # documentation for details.
11
+ # @api private
12
+ def initialize(version_model_class, attributes)
13
+ @version_model_class = version_model_class
14
+
15
+ # Currently, this `deep_dup` is necessary because the `jsonb` branch
16
+ # modifies `@attributes`, and that would be a nasty suprise for
17
+ # consumers of this class.
18
+ # TODO: Stop modifying `@attributes`, then remove `deep_dup`.
19
+ @attributes = attributes.deep_dup
20
+ end
21
+
22
+ # @api private
23
+ def execute
24
+ case @version_model_class.columns_hash["object_changes"].type
25
+ when :jsonb
26
+ jsonb
27
+ when :json
28
+ json
29
+ else
30
+ text
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # @api private
37
+ def json
38
+ predicates = []
39
+ values = []
40
+ @attributes.each do |field, value|
41
+ predicates.push(
42
+ "((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
43
+ )
44
+ values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
45
+ end
46
+ sql = predicates.join(" and ")
47
+ @version_model_class.where(sql, *values)
48
+ end
49
+
50
+ # @api private
51
+ def jsonb
52
+ @attributes.each { |field, value| @attributes[field] = [value] }
53
+ @version_model_class.where("object_changes @> ?", @attributes.to_json)
54
+ end
55
+
56
+ # @api private
57
+ def text
58
+ arel_field = @version_model_class.arel_table[:object_changes]
59
+ where_conditions = @attributes.map { |field, value|
60
+ ::PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
61
+ }
62
+ where_conditions = where_conditions.reduce { |a, e| a.and(e) }
63
+ @version_model_class.where(where_conditions)
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -1,9 +1,7 @@
1
1
  module PaperTrail
2
-
3
2
  # Represents the history of a single record.
4
3
  # @api private
5
4
  class RecordHistory
6
-
7
5
  # @param versions - ActiveRecord::Relation - All versions of the record.
8
6
  # @param version_class - Class - Usually PaperTrail::Version,
9
7
  # but it could also be a custom version class.
@@ -16,7 +14,7 @@ module PaperTrail
16
14
  # Returns ordinal position of `version` in `sequence`.
17
15
  # @api private
18
16
  def index(version)
19
- sequence.index(version)
17
+ sequence.to_a.index(version)
20
18
  end
21
19
 
22
20
  private
@@ -28,7 +26,7 @@ module PaperTrail
28
26
  @versions.select(primary_key).order(primary_key.asc)
29
27
  else
30
28
  @versions.
31
- select([timestamp, primary_key]).
29
+ select([table[:created_at], primary_key]).
32
30
  order(@version_class.timestamp_sort_order)
33
31
  end
34
32
  end
@@ -47,13 +45,5 @@ module PaperTrail
47
45
  def table
48
46
  @version_class.arel_table
49
47
  end
50
-
51
- # @return - Arel::Attribute - Attribute representing the timestamp column
52
- # of the version table, usually named `created_at` (the rails convention)
53
- # but not always.
54
- # @api private
55
- def timestamp
56
- table[PaperTrail.timestamp_field]
57
- end
58
48
  end
59
49
  end
@@ -0,0 +1,573 @@
1
+ module PaperTrail
2
+ # Represents the "paper trail" for a single record.
3
+ class RecordTrail
4
+ # The respond_to? check here is specific to ActiveRecord 4.0 and can be
5
+ # removed when support for ActiveRecord < 4.2 is dropped.
6
+ RAILS_GTE_5_1 = ::ActiveRecord.respond_to?(:gem_version) &&
7
+ ::ActiveRecord.gem_version >= ::Gem::Version.new("5.1.0.beta1")
8
+
9
+ def initialize(record)
10
+ @record = record
11
+ @in_after_callback = false
12
+ end
13
+
14
+ # Utility method for reifying. Anything executed inside the block will
15
+ # appear like a new record.
16
+ #
17
+ # > .. as best as I can tell, the purpose of
18
+ # > appear_as_new_record was to attempt to prevent the callbacks in
19
+ # > AutosaveAssociation (which is the module responsible for persisting
20
+ # > foreign key changes earlier than most people want most of the time
21
+ # > because backwards compatibility or the maintainer hates himself or
22
+ # > something) from running. By also stubbing out persisted? we can
23
+ # > actually prevent those. A more stable option might be to use suppress
24
+ # > instead, similar to the other branch in reify_has_one.
25
+ # > -Sean Griffin (https://github.com/airblade/paper_trail/pull/899)
26
+ #
27
+ def appear_as_new_record
28
+ @record.instance_eval {
29
+ alias :old_new_record? :new_record?
30
+ alias :new_record? :present?
31
+ alias :old_persisted? :persisted?
32
+ alias :persisted? :nil?
33
+ }
34
+ yield
35
+ @record.instance_eval {
36
+ alias :new_record? :old_new_record?
37
+ alias :persisted? :old_persisted?
38
+ }
39
+ end
40
+
41
+ def attributes_before_change
42
+ Hash[@record.attributes.map do |k, v|
43
+ if @record.class.column_names.include?(k)
44
+ [k, attribute_in_previous_version(k)]
45
+ else
46
+ [k, v]
47
+ end
48
+ end]
49
+ end
50
+
51
+ def changed_and_not_ignored
52
+ ignore = @record.paper_trail_options[:ignore].dup
53
+ # Remove Hash arguments and then evaluate whether the attributes (the
54
+ # keys of the hash) should also get pushed into the collection.
55
+ ignore.delete_if do |obj|
56
+ obj.is_a?(Hash) &&
57
+ obj.each { |attr, condition|
58
+ ignore << attr if condition.respond_to?(:call) && condition.call(@record)
59
+ }
60
+ end
61
+ skip = @record.paper_trail_options[:skip]
62
+ changed_in_latest_version - ignore - skip
63
+ end
64
+
65
+ # Invoked after rollbacks to ensure versions records are not created for
66
+ # changes that never actually took place. Optimization: Use lazy `reset`
67
+ # instead of eager `reload` because, in many use cases, the association will
68
+ # not be used.
69
+ def clear_rolled_back_versions
70
+ versions.reset
71
+ end
72
+
73
+ # Invoked via`after_update` callback for when a previous version is
74
+ # reified and then saved.
75
+ def clear_version_instance
76
+ @record.send("#{@record.class.version_association_name}=", nil)
77
+ end
78
+
79
+ # Determines whether it is appropriate to generate a new version
80
+ # instance. A timestamp-only update (e.g. only `updated_at` changed) is
81
+ # considered notable unless an ignored attribute was also changed.
82
+ def changed_notably?
83
+ if ignored_attr_has_changed?
84
+ timestamps = @record.send(:timestamp_attributes_for_update_in_model).map(&:to_s)
85
+ (notably_changed - timestamps).any?
86
+ else
87
+ notably_changed.any?
88
+ end
89
+ end
90
+
91
+ # @api private
92
+ def changes
93
+ notable_changes = changes_in_latest_version.delete_if { |k, _v|
94
+ !notably_changed.include?(k)
95
+ }
96
+ AttributeSerializers::ObjectChangesAttribute.
97
+ new(@record.class).
98
+ serialize(notable_changes)
99
+ notable_changes.to_hash
100
+ end
101
+
102
+ def enabled?
103
+ PaperTrail.enabled? && PaperTrail.enabled_for_controller? && enabled_for_model?
104
+ end
105
+
106
+ def enabled_for_model?
107
+ @record.class.paper_trail.enabled?
108
+ end
109
+
110
+ # An attributed is "ignored" if it is listed in the `:ignore` option
111
+ # and/or the `:skip` option. Returns true if an ignored attribute has
112
+ # changed.
113
+ def ignored_attr_has_changed?
114
+ ignored = @record.paper_trail_options[:ignore] + @record.paper_trail_options[:skip]
115
+ ignored.any? && (changed_in_latest_version & ignored).any?
116
+ end
117
+
118
+ # Returns true if this instance is the current, live one;
119
+ # returns false if this instance came from a previous version.
120
+ def live?
121
+ source_version.nil?
122
+ end
123
+
124
+ # Updates `data` from the model's `meta` option and from `controller_info`.
125
+ # @api private
126
+ def merge_metadata_into(data)
127
+ merge_metadata_from_model_into(data)
128
+ merge_metadata_from_controller_into(data)
129
+ end
130
+
131
+ # Updates `data` from `controller_info`.
132
+ # @api private
133
+ def merge_metadata_from_controller_into(data)
134
+ data.merge(PaperTrail.controller_info || {})
135
+ end
136
+
137
+ # Updates `data` from the model's `meta` option.
138
+ # @api private
139
+ def merge_metadata_from_model_into(data)
140
+ @record.paper_trail_options[:meta].each do |k, v|
141
+ data[k] = model_metadatum(v, data[:event])
142
+ end
143
+ end
144
+
145
+ # Given a `value` from the model's `meta` option, returns an object to be
146
+ # persisted. The `value` can be a simple scalar value, but it can also
147
+ # be a symbol that names a model method, or even a Proc.
148
+ # @api private
149
+ def model_metadatum(value, event)
150
+ if value.respond_to?(:call)
151
+ value.call(@record)
152
+ elsif value.is_a?(Symbol) && @record.respond_to?(value, true)
153
+ # If it is an attribute that is changing in an existing object,
154
+ # be sure to grab the current version.
155
+ if event != "create" &&
156
+ @record.has_attribute?(value) &&
157
+ attribute_changed_in_latest_version?(value)
158
+ attribute_in_previous_version(value)
159
+ else
160
+ @record.send(value)
161
+ end
162
+ else
163
+ value
164
+ end
165
+ end
166
+
167
+ # Returns the object (not a Version) as it became next.
168
+ # NOTE: if self (the item) was not reified from a version, i.e. it is the
169
+ # "live" item, we return nil. Perhaps we should return self instead?
170
+ def next_version
171
+ subsequent_version = source_version.next
172
+ subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
173
+ rescue StandardError # TODO: Rescue something more specific
174
+ nil
175
+ end
176
+
177
+ def notably_changed
178
+ only = @record.paper_trail_options[:only].dup
179
+ # Remove Hash arguments and then evaluate whether the attributes (the
180
+ # keys of the hash) should also get pushed into the collection.
181
+ only.delete_if do |obj|
182
+ obj.is_a?(Hash) &&
183
+ obj.each { |attr, condition|
184
+ only << attr if condition.respond_to?(:call) && condition.call(@record)
185
+ }
186
+ end
187
+ only.empty? ? changed_and_not_ignored : (changed_and_not_ignored & only)
188
+ end
189
+
190
+ # Returns hash of attributes (with appropriate attributes serialized),
191
+ # omitting attributes to be skipped.
192
+ def object_attrs_for_paper_trail
193
+ attrs = attributes_before_change.except(*@record.paper_trail_options[:skip])
194
+ AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs)
195
+ attrs
196
+ end
197
+
198
+ # Returns who put `@record` into its current state.
199
+ def originator
200
+ (source_version || versions.last).try(:whodunnit)
201
+ end
202
+
203
+ # Returns the object (not a Version) as it was most recently.
204
+ def previous_version
205
+ (source_version ? source_version.previous : versions.last).try(:reify)
206
+ end
207
+
208
+ def record_create
209
+ @in_after_callback = true
210
+ return unless enabled?
211
+ versions_assoc = @record.send(@record.class.versions_association_name)
212
+ version = versions_assoc.create! data_for_create
213
+ update_transaction_id(version)
214
+ save_associations(version)
215
+ ensure
216
+ @in_after_callback = false
217
+ end
218
+
219
+ # Returns data for record create
220
+ # @api private
221
+ def data_for_create
222
+ data = {
223
+ event: @record.paper_trail_event || "create",
224
+ whodunnit: PaperTrail.whodunnit
225
+ }
226
+ if @record.respond_to?(:updated_at)
227
+ data[:created_at] = @record.updated_at
228
+ end
229
+ if record_object_changes? && changed_notably?
230
+ data[:object_changes] = recordable_object_changes
231
+ end
232
+ add_transaction_id_to(data)
233
+ merge_metadata_into(data)
234
+ end
235
+
236
+ def record_destroy
237
+ if enabled? && !@record.new_record?
238
+ version = @record.class.paper_trail.version_class.create(data_for_destroy)
239
+ if version.errors.any?
240
+ log_version_errors(version, :destroy)
241
+ else
242
+ @record.send("#{@record.class.version_association_name}=", version)
243
+ @record.send(@record.class.versions_association_name).reset
244
+ update_transaction_id(version)
245
+ save_associations(version)
246
+ end
247
+ end
248
+ end
249
+
250
+ # Returns data for record destroy
251
+ # @api private
252
+ def data_for_destroy
253
+ data = {
254
+ item_id: @record.id,
255
+ item_type: @record.class.base_class.name,
256
+ event: @record.paper_trail_event || "destroy",
257
+ object: recordable_object,
258
+ whodunnit: PaperTrail.whodunnit
259
+ }
260
+ add_transaction_id_to(data)
261
+ merge_metadata_into(data)
262
+ end
263
+
264
+ # Returns a boolean indicating whether to store serialized version diffs
265
+ # in the `object_changes` column of the version record.
266
+ # @api private
267
+ def record_object_changes?
268
+ @record.paper_trail_options[:save_changes] &&
269
+ @record.class.paper_trail.version_class.column_names.include?("object_changes")
270
+ end
271
+
272
+ def record_update(force)
273
+ @in_after_callback = true
274
+ if enabled? && (force || changed_notably?)
275
+ versions_assoc = @record.send(@record.class.versions_association_name)
276
+ version = versions_assoc.create(data_for_update)
277
+ if version.errors.any?
278
+ log_version_errors(version, :update)
279
+ else
280
+ update_transaction_id(version)
281
+ save_associations(version)
282
+ end
283
+ end
284
+ ensure
285
+ @in_after_callback = false
286
+ end
287
+
288
+ # Returns data for record update
289
+ # @api private
290
+ def data_for_update
291
+ data = {
292
+ event: @record.paper_trail_event || "update",
293
+ object: recordable_object,
294
+ whodunnit: PaperTrail.whodunnit
295
+ }
296
+ if @record.respond_to?(:updated_at)
297
+ data[:created_at] = @record.updated_at
298
+ end
299
+ if record_object_changes?
300
+ data[:object_changes] = recordable_object_changes
301
+ end
302
+ add_transaction_id_to(data)
303
+ merge_metadata_into(data)
304
+ end
305
+
306
+ # Returns an object which can be assigned to the `object` attribute of a
307
+ # nascent version record. If the `object` column is a postgres `json`
308
+ # column, then a hash can be used in the assignment, otherwise the column
309
+ # is a `text` column, and we must perform the serialization here, using
310
+ # `PaperTrail.serializer`.
311
+ # @api private
312
+ def recordable_object
313
+ if @record.class.paper_trail.version_class.object_col_is_json?
314
+ object_attrs_for_paper_trail
315
+ else
316
+ PaperTrail.serializer.dump(object_attrs_for_paper_trail)
317
+ end
318
+ end
319
+
320
+ # Returns an object which can be assigned to the `object_changes`
321
+ # attribute of a nascent version record. If the `object_changes` column is
322
+ # a postgres `json` column, then a hash can be used in the assignment,
323
+ # otherwise the column is a `text` column, and we must perform the
324
+ # serialization here, using `PaperTrail.serializer`.
325
+ # @api private
326
+ def recordable_object_changes
327
+ if @record.class.paper_trail.version_class.object_changes_col_is_json?
328
+ changes
329
+ else
330
+ PaperTrail.serializer.dump(changes)
331
+ end
332
+ end
333
+
334
+ # Invoked via callback when a user attempts to persist a reified
335
+ # `Version`.
336
+ def reset_timestamp_attrs_for_update_if_needed
337
+ return if live?
338
+ @record.send(:timestamp_attributes_for_update_in_model).each do |column|
339
+ # ActiveRecord 4.2 deprecated `reset_column!` in favor of
340
+ # `restore_column!`.
341
+ if @record.respond_to?("restore_#{column}!")
342
+ @record.send("restore_#{column}!")
343
+ else
344
+ @record.send("reset_#{column}!")
345
+ end
346
+ end
347
+ end
348
+
349
+ # Saves associations if the join table for `VersionAssociation` exists.
350
+ def save_associations(version)
351
+ return unless PaperTrail.config.track_associations?
352
+ save_bt_associations(version)
353
+ save_habtm_associations(version)
354
+ end
355
+
356
+ # Save all `belongs_to` associations.
357
+ # @api private
358
+ def save_bt_associations(version)
359
+ @record.class.reflect_on_all_associations(:belongs_to).each do |assoc|
360
+ save_bt_association(assoc, version)
361
+ end
362
+ end
363
+
364
+ # When a record is created, updated, or destroyed, we determine what the
365
+ # HABTM associations looked like before any changes were made, by using
366
+ # the `paper_trail_habtm` data structure. Then, we create
367
+ # `VersionAssociation` records for each of the associated records.
368
+ # @api private
369
+ def save_habtm_associations(version)
370
+ @record.class.reflect_on_all_associations(:has_and_belongs_to_many).each do |a|
371
+ next unless save_habtm_association?(a)
372
+ habtm_assoc_ids(a).each do |id|
373
+ PaperTrail::VersionAssociation.create(
374
+ version_id: version.transaction_id,
375
+ foreign_key_name: a.name,
376
+ foreign_key_id: id
377
+ )
378
+ end
379
+ end
380
+ end
381
+
382
+ # AR callback.
383
+ # @api private
384
+ def save_version?
385
+ if_condition = @record.paper_trail_options[:if]
386
+ unless_condition = @record.paper_trail_options[:unless]
387
+ (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
388
+ end
389
+
390
+ def source_version
391
+ version
392
+ end
393
+
394
+ # Mimics the `touch` method from `ActiveRecord::Persistence`, but also
395
+ # creates a version. A version is created regardless of options such as
396
+ # `:on`, `:if`, or `:unless`.
397
+ #
398
+ # TODO: look into leveraging the `after_touch` callback from
399
+ # `ActiveRecord` to allow the regular `touch` method to generate a version
400
+ # as normal. May make sense to switch the `record_update` method to
401
+ # leverage an `after_update` callback anyways (likely for v4.0.0)
402
+ def touch_with_version(name = nil)
403
+ unless @record.persisted?
404
+ raise ::ActiveRecord::ActiveRecordError, "can not touch on a new record object"
405
+ end
406
+ attributes = @record.send :timestamp_attributes_for_update_in_model
407
+ attributes << name if name
408
+ current_time = @record.send :current_time_from_proper_timezone
409
+ attributes.each { |column|
410
+ @record.send(:write_attribute, column, current_time)
411
+ }
412
+ record_update(true) unless will_record_after_update?
413
+ @record.save!(validate: false)
414
+ end
415
+
416
+ # Returns the object (not a Version) as it was at the given timestamp.
417
+ def version_at(timestamp, reify_options = {})
418
+ # Because a version stores how its object looked *before* the change,
419
+ # we need to look for the first version created *after* the timestamp.
420
+ v = versions.subsequent(timestamp, true).first
421
+ return v.reify(reify_options) if v
422
+ @record unless @record.destroyed?
423
+ end
424
+
425
+ # Returns the objects (not Versions) as they were between the given times.
426
+ def versions_between(start_time, end_time)
427
+ versions = send(@record.class.versions_association_name).between(start_time, end_time)
428
+ versions.collect { |version| version_at(version.created_at) }
429
+ end
430
+
431
+ # Executes the given method or block without creating a new version.
432
+ def without_versioning(method = nil)
433
+ paper_trail_was_enabled = enabled_for_model?
434
+ @record.class.paper_trail.disable
435
+ if method
436
+ if respond_to?(method)
437
+ public_send(method)
438
+ else
439
+ @record.send(method)
440
+ end
441
+ else
442
+ yield @record
443
+ end
444
+ ensure
445
+ @record.class.paper_trail.enable if paper_trail_was_enabled
446
+ end
447
+
448
+ # Temporarily overwrites the value of whodunnit and then executes the
449
+ # provided block.
450
+ def whodunnit(value)
451
+ raise ArgumentError, "expected to receive a block" unless block_given?
452
+ current_whodunnit = PaperTrail.whodunnit
453
+ PaperTrail.whodunnit = value
454
+ yield @record
455
+ ensure
456
+ PaperTrail.whodunnit = current_whodunnit
457
+ end
458
+
459
+ private
460
+
461
+ def add_transaction_id_to(data)
462
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
463
+ data[:transaction_id] = PaperTrail.transaction_id
464
+ end
465
+
466
+ # @api private
467
+ def attribute_changed_in_latest_version?(attr_name)
468
+ if @in_after_callback && RAILS_GTE_5_1
469
+ @record.saved_change_to_attribute?(attr_name.to_s)
470
+ else
471
+ @record.attribute_changed?(attr_name.to_s)
472
+ end
473
+ end
474
+
475
+ # @api private
476
+ def attribute_in_previous_version(attr_name)
477
+ if @in_after_callback && RAILS_GTE_5_1
478
+ @record.attribute_before_last_save(attr_name.to_s)
479
+ else
480
+ # TODO: after dropping support for rails 4.0, remove send, because
481
+ # attribute_was is no longer private.
482
+ @record.send(:attribute_was, attr_name.to_s)
483
+ end
484
+ end
485
+
486
+ # @api private
487
+ def changed_in_latest_version
488
+ if @in_after_callback && RAILS_GTE_5_1
489
+ @record.saved_changes.keys
490
+ else
491
+ @record.changed
492
+ end
493
+ end
494
+
495
+ # @api private
496
+ def changes_in_latest_version
497
+ if @in_after_callback && RAILS_GTE_5_1
498
+ @record.saved_changes
499
+ else
500
+ @record.changes
501
+ end
502
+ end
503
+
504
+ # Given a HABTM association, returns an array of ids.
505
+ # @api private
506
+ def habtm_assoc_ids(habtm_assoc)
507
+ current = @record.send(habtm_assoc.name).to_a.map(&:id) # TODO: `pluck` would use less memory
508
+ removed = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :removed) || []
509
+ added = @record.paper_trail_habtm.try(:[], habtm_assoc.name).try(:[], :added) || []
510
+ current + removed - added
511
+ end
512
+
513
+ def log_version_errors(version, action)
514
+ version.logger && version.logger.warn(
515
+ "Unable to create version for #{action} of #{@record.class.name}" +
516
+ "##{@record.id}: " + version.errors.full_messages.join(", ")
517
+ )
518
+ end
519
+
520
+ # Save a single `belongs_to` association.
521
+ # @api private
522
+ def save_bt_association(assoc, version)
523
+ assoc_version_args = {
524
+ version_id: version.id,
525
+ foreign_key_name: assoc.foreign_key
526
+ }
527
+
528
+ if assoc.options[:polymorphic]
529
+ associated_record = @record.send(assoc.name) if @record.send(assoc.foreign_type)
530
+ if associated_record && associated_record.class.paper_trail.enabled?
531
+ assoc_version_args[:foreign_key_id] = associated_record.id
532
+ end
533
+ elsif assoc.klass.paper_trail.enabled?
534
+ assoc_version_args[:foreign_key_id] = @record.send(assoc.foreign_key)
535
+ end
536
+
537
+ if assoc_version_args.key?(:foreign_key_id)
538
+ PaperTrail::VersionAssociation.create(assoc_version_args)
539
+ end
540
+ end
541
+
542
+ # Returns true if the given HABTM association should be saved.
543
+ # @api private
544
+ def save_habtm_association?(assoc)
545
+ @record.class.paper_trail_save_join_tables.include?(assoc.name) ||
546
+ assoc.klass.paper_trail.enabled?
547
+ end
548
+
549
+ # Returns true if `save` will cause `record_update`
550
+ # to be called via the `after_update` callback.
551
+ def will_record_after_update?
552
+ on = @record.paper_trail_options[:on]
553
+ on.nil? || on.include?(:update)
554
+ end
555
+
556
+ def update_transaction_id(version)
557
+ return unless @record.class.paper_trail.version_class.column_names.include?("transaction_id")
558
+ if PaperTrail.transaction? && PaperTrail.transaction_id.nil?
559
+ PaperTrail.transaction_id = version.id
560
+ version.transaction_id = version.id
561
+ version.save
562
+ end
563
+ end
564
+
565
+ def version
566
+ @record.public_send(@record.class.version_association_name)
567
+ end
568
+
569
+ def versions
570
+ @record.public_send(@record.class.versions_association_name)
571
+ end
572
+ end
573
+ end