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.
- checksums.yaml +4 -4
- data/.gitignore +5 -0
- data/.rspec +1 -2
- data/.travis.yml +14 -5
- data/CHANGELOG.md +215 -8
- data/CONTRIBUTING.md +84 -0
- data/README.md +922 -502
- data/Rakefile +2 -2
- data/doc/bug_report_template.rb +65 -0
- data/gemfiles/ar3.gemfile +61 -0
- data/lib/generators/paper_trail/install_generator.rb +22 -3
- data/lib/generators/paper_trail/templates/add_object_changes_to_versions.rb +6 -1
- data/lib/generators/paper_trail/templates/add_transaction_id_column_to_versions.rb +11 -0
- data/lib/generators/paper_trail/templates/create_version_associations.rb +17 -0
- data/lib/generators/paper_trail/templates/create_versions.rb +22 -1
- data/lib/paper_trail.rb +52 -22
- data/lib/paper_trail/attributes_serialization.rb +89 -0
- data/lib/paper_trail/cleaner.rb +32 -15
- data/lib/paper_trail/config.rb +35 -2
- data/lib/paper_trail/frameworks/active_record.rb +4 -5
- data/lib/paper_trail/frameworks/active_record/models/paper_trail/version_association.rb +7 -0
- data/lib/paper_trail/frameworks/rails.rb +1 -0
- data/lib/paper_trail/frameworks/rails/controller.rb +27 -11
- data/lib/paper_trail/frameworks/rspec.rb +5 -0
- data/lib/paper_trail/frameworks/sinatra.rb +3 -1
- data/lib/paper_trail/has_paper_trail.rb +304 -148
- data/lib/paper_trail/record_history.rb +59 -0
- data/lib/paper_trail/reifier.rb +270 -0
- data/lib/paper_trail/serializers/json.rb +13 -2
- data/lib/paper_trail/serializers/yaml.rb +16 -2
- data/lib/paper_trail/version_association_concern.rb +15 -0
- data/lib/paper_trail/version_concern.rb +160 -122
- data/lib/paper_trail/version_number.rb +3 -3
- data/paper_trail.gemspec +22 -9
- data/spec/generators/install_generator_spec.rb +4 -4
- data/spec/models/animal_spec.rb +36 -0
- data/spec/models/boolit_spec.rb +48 -0
- data/spec/models/callback_modifier_spec.rb +96 -0
- data/spec/models/fluxor_spec.rb +19 -0
- data/spec/models/gadget_spec.rb +14 -12
- data/spec/models/joined_version_spec.rb +9 -9
- data/spec/models/json_version_spec.rb +103 -0
- data/spec/models/kitchen/banana_spec.rb +14 -0
- data/spec/models/not_on_update_spec.rb +19 -0
- data/spec/models/post_with_status_spec.rb +3 -3
- data/spec/models/skipper_spec.rb +46 -0
- data/spec/models/thing_spec.rb +11 -0
- data/spec/models/version_spec.rb +195 -44
- data/spec/models/widget_spec.rb +136 -76
- data/spec/modules/paper_trail_spec.rb +27 -0
- data/spec/modules/version_concern_spec.rb +8 -8
- data/spec/modules/version_number_spec.rb +16 -16
- data/spec/paper_trail/config_spec.rb +52 -0
- data/spec/paper_trail_spec.rb +17 -17
- data/spec/rails_helper.rb +34 -0
- data/spec/requests/articles_spec.rb +10 -14
- data/spec/spec_helper.rb +81 -34
- data/spec/support/alt_db_init.rb +1 -1
- data/test/dummy/app/controllers/application_controller.rb +1 -1
- data/test/dummy/app/controllers/articles_controller.rb +4 -1
- data/test/dummy/app/models/animal.rb +2 -0
- data/test/dummy/app/models/book.rb +4 -0
- data/test/dummy/app/models/boolit.rb +4 -0
- data/test/dummy/app/models/callback_modifier.rb +45 -0
- data/test/dummy/app/models/chapter.rb +9 -0
- data/test/dummy/app/models/citation.rb +5 -0
- data/test/dummy/app/models/customer.rb +4 -0
- data/test/dummy/app/models/editor.rb +4 -0
- data/test/dummy/app/models/editorship.rb +5 -0
- data/test/dummy/app/models/fruit.rb +5 -0
- data/test/dummy/app/models/kitchen/banana.rb +5 -0
- data/test/dummy/app/models/line_item.rb +4 -0
- data/test/dummy/app/models/not_on_update.rb +4 -0
- data/test/dummy/app/models/order.rb +5 -0
- data/test/dummy/app/models/paragraph.rb +5 -0
- data/test/dummy/app/models/person.rb +13 -3
- data/test/dummy/app/models/post.rb +0 -1
- data/test/dummy/app/models/quotation.rb +5 -0
- data/test/dummy/app/models/section.rb +6 -0
- data/test/dummy/app/models/skipper.rb +6 -0
- data/test/dummy/app/models/song.rb +20 -0
- data/test/dummy/app/models/thing.rb +3 -0
- data/test/dummy/app/models/whatchamajigger.rb +4 -0
- data/test/dummy/app/models/widget.rb +5 -0
- data/test/dummy/app/versions/json_version.rb +3 -0
- data/test/dummy/app/versions/kitchen/banana_version.rb +5 -0
- data/test/dummy/config/application.rb +6 -0
- data/test/dummy/config/database.postgres.yml +1 -1
- data/test/dummy/config/environments/test.rb +5 -1
- data/test/dummy/config/initializers/paper_trail.rb +6 -1
- data/test/dummy/db/migrate/20110208155312_set_up_test_tables.rb +143 -3
- data/test/dummy/db/schema.rb +169 -25
- data/test/functional/controller_test.rb +4 -2
- data/test/functional/modular_sinatra_test.rb +1 -1
- data/test/functional/sinatra_test.rb +1 -1
- data/test/paper_trail_test.rb +7 -0
- data/test/test_helper.rb +38 -2
- data/test/time_travel_helper.rb +15 -0
- data/test/unit/associations_test.rb +726 -0
- data/test/unit/inheritance_column_test.rb +6 -6
- data/test/unit/model_test.rb +109 -125
- data/test/unit/protected_attrs_test.rb +4 -3
- data/test/unit/serializer_test.rb +6 -6
- data/test/unit/serializers/json_test.rb +17 -4
- data/test/unit/serializers/yaml_test.rb +5 -1
- data/test/unit/version_test.rb +87 -69
- metadata +172 -75
- data/gemfiles/3.0.gemfile +0 -42
- data/test/dummy/public/404.html +0 -26
- data/test/dummy/public/422.html +0 -26
- data/test/dummy/public/500.html +0 -26
- data/test/dummy/public/favicon.ico +0 -0
- data/test/dummy/public/javascripts/application.js +0 -2
- data/test/dummy/public/javascripts/controls.js +0 -965
- data/test/dummy/public/javascripts/dragdrop.js +0 -974
- data/test/dummy/public/javascripts/effects.js +0 -1123
- data/test/dummy/public/javascripts/prototype.js +0 -6001
- data/test/dummy/public/javascripts/rails.js +0 -175
- data/test/dummy/public/stylesheets/.gitkeep +0 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module PaperTrail
|
|
2
|
+
|
|
3
|
+
# Represents the history of a single record.
|
|
4
|
+
# @api private
|
|
5
|
+
class RecordHistory
|
|
6
|
+
|
|
7
|
+
# @param versions - ActiveRecord::Relation - All versions of the record.
|
|
8
|
+
# @param version_class - Class - Usually PaperTrail::Version,
|
|
9
|
+
# but it could also be a custom version class.
|
|
10
|
+
# @api private
|
|
11
|
+
def initialize(versions, version_class)
|
|
12
|
+
@versions = versions
|
|
13
|
+
@version_class = version_class
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Returns ordinal position of `version` in `sequence`.
|
|
17
|
+
# @api private
|
|
18
|
+
def index(version)
|
|
19
|
+
sequence.index(version)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# Returns `@versions` in chronological order.
|
|
25
|
+
# @api private
|
|
26
|
+
def sequence
|
|
27
|
+
if @version_class.primary_key_is_int?
|
|
28
|
+
@versions.select(primary_key).order(primary_key.asc)
|
|
29
|
+
else
|
|
30
|
+
@versions.
|
|
31
|
+
select([timestamp, primary_key]).
|
|
32
|
+
order(@version_class.timestamp_sort_order)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return - Arel::Attribute - Attribute representing the primary key
|
|
37
|
+
# of the version table. The column's data type is usually a serial
|
|
38
|
+
# integer (the rails convention) but not always.
|
|
39
|
+
# @api private
|
|
40
|
+
def primary_key
|
|
41
|
+
table[@version_class.primary_key]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return - Arel::Table - The version table, usually named `versions`, but
|
|
45
|
+
# not always.
|
|
46
|
+
# @api private
|
|
47
|
+
def table
|
|
48
|
+
@version_class.arel_table
|
|
49
|
+
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
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
module PaperTrail
|
|
2
|
+
|
|
3
|
+
# Given a version record and some options, builds a new model object.
|
|
4
|
+
# @api private
|
|
5
|
+
module Reifier
|
|
6
|
+
class << self
|
|
7
|
+
|
|
8
|
+
# See `VersionConcern#reify` for documentation.
|
|
9
|
+
# @api private
|
|
10
|
+
def reify(version, options)
|
|
11
|
+
options = options.dup
|
|
12
|
+
|
|
13
|
+
options.reverse_merge!(
|
|
14
|
+
:version_at => version.created_at,
|
|
15
|
+
:mark_for_destruction => false,
|
|
16
|
+
:has_one => false,
|
|
17
|
+
:has_many => false,
|
|
18
|
+
:unversioned_attributes => :nil
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
attrs = version.class.object_col_is_json? ?
|
|
22
|
+
version.object :
|
|
23
|
+
PaperTrail.serializer.load(version.object)
|
|
24
|
+
|
|
25
|
+
# Normally a polymorphic belongs_to relationship allows us to get the
|
|
26
|
+
# object we belong to by calling, in this case, `item`. However this
|
|
27
|
+
# returns nil if `item` has been destroyed, and we need to be able to
|
|
28
|
+
# retrieve destroyed objects.
|
|
29
|
+
#
|
|
30
|
+
# In this situation we constantize the `item_type` to get hold of the
|
|
31
|
+
# class...except when the stored object's attributes include a `type`
|
|
32
|
+
# key. If this is the case, the object we belong to is using single
|
|
33
|
+
# table inheritance and the `item_type` will be the base class, not the
|
|
34
|
+
# actual subclass. If `type` is present but empty, the class is the base
|
|
35
|
+
# class.
|
|
36
|
+
|
|
37
|
+
if options[:dup] != true && version.item
|
|
38
|
+
model = version.item
|
|
39
|
+
# Look for attributes that exist in the model and not in this
|
|
40
|
+
# version. These attributes should be set to nil.
|
|
41
|
+
if options[:unversioned_attributes] == :nil
|
|
42
|
+
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
inheritance_column_name = version.item_type.constantize.inheritance_column
|
|
46
|
+
class_name = attrs[inheritance_column_name].blank? ?
|
|
47
|
+
version.item_type :
|
|
48
|
+
attrs[inheritance_column_name]
|
|
49
|
+
klass = class_name.constantize
|
|
50
|
+
# The `dup` option always returns a new object, otherwise we should
|
|
51
|
+
# attempt to look for the item outside of default scope(s).
|
|
52
|
+
if options[:dup] || (_item = klass.unscoped.find_by_id(version.item_id)).nil?
|
|
53
|
+
model = klass.new
|
|
54
|
+
elsif options[:unversioned_attributes] == :nil
|
|
55
|
+
model = _item
|
|
56
|
+
# Look for attributes that exist in the model and not in this
|
|
57
|
+
# version. These attributes should be set to nil.
|
|
58
|
+
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
model.class.unserialize_attributes_for_paper_trail! attrs
|
|
63
|
+
|
|
64
|
+
# Set all the attributes in this version on the model.
|
|
65
|
+
attrs.each do |k, v|
|
|
66
|
+
if model.has_attribute?(k)
|
|
67
|
+
model[k.to_sym] = v
|
|
68
|
+
elsif model.respond_to?("#{k}=")
|
|
69
|
+
model.send("#{k}=", v)
|
|
70
|
+
else
|
|
71
|
+
version.logger.warn(
|
|
72
|
+
"Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
model.send "#{model.class.version_association_name}=", version
|
|
78
|
+
|
|
79
|
+
unless options[:has_one] == false
|
|
80
|
+
reify_has_ones version.transaction_id, model, options
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless options[:has_many] == false
|
|
84
|
+
reify_has_manys version.transaction_id, model, options
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
model
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# Replaces each record in `array` with its reified version, if present
|
|
93
|
+
# in `versions`.
|
|
94
|
+
#
|
|
95
|
+
# @api private
|
|
96
|
+
# @param array - The collection to be modified.
|
|
97
|
+
# @param options
|
|
98
|
+
# @param versions - A `Hash` mapping IDs to `Version`s
|
|
99
|
+
# @return nil - Always returns `nil`
|
|
100
|
+
#
|
|
101
|
+
# Once modified by this method, `array` will be assigned to the
|
|
102
|
+
# AR association currently being reified.
|
|
103
|
+
#
|
|
104
|
+
def prepare_array_for_has_many(array, options, versions)
|
|
105
|
+
# Iterate each child to replace it with the previous value if there is
|
|
106
|
+
# a version after the timestamp.
|
|
107
|
+
array.map! do |record|
|
|
108
|
+
if (version = versions.delete(record.id)).nil?
|
|
109
|
+
record
|
|
110
|
+
elsif version.event == 'create'
|
|
111
|
+
options[:mark_for_destruction] ? record.tap { |r| r.mark_for_destruction } : nil
|
|
112
|
+
else
|
|
113
|
+
version.reify(options.merge(:has_many => false, :has_one => false))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Reify the rest of the versions and add them to the collection, these
|
|
118
|
+
# versions are for those that have been removed from the live
|
|
119
|
+
# associations.
|
|
120
|
+
array.concat(
|
|
121
|
+
versions.values.map { |v|
|
|
122
|
+
v.reify(options.merge(:has_many => false, :has_one => false))
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
array.compact!
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Restore the `model`'s has_one associations as they were when this
|
|
132
|
+
# version was superseded by the next (because that's what the user was
|
|
133
|
+
# looking at when they made the change).
|
|
134
|
+
def reify_has_ones(transaction_id, model, options = {})
|
|
135
|
+
version_table_name = model.class.paper_trail_version_class.table_name
|
|
136
|
+
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
|
137
|
+
if assoc.klass.paper_trail_enabled_for_model?
|
|
138
|
+
version = model.class.paper_trail_version_class.joins(:version_associations).
|
|
139
|
+
where("version_associations.foreign_key_name = ?", assoc.foreign_key).
|
|
140
|
+
where("version_associations.foreign_key_id = ?", model.id).
|
|
141
|
+
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
|
142
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
|
143
|
+
order("#{version_table_name}.id ASC").
|
|
144
|
+
first
|
|
145
|
+
if version
|
|
146
|
+
if version.event == 'create'
|
|
147
|
+
if options[:mark_for_destruction]
|
|
148
|
+
model.send(assoc.name).mark_for_destruction if model.send(assoc.name, true)
|
|
149
|
+
else
|
|
150
|
+
model.appear_as_new_record do
|
|
151
|
+
model.send "#{assoc.name}=", nil
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
else
|
|
155
|
+
child = version.reify(options.merge(:has_many => false, :has_one => false))
|
|
156
|
+
model.appear_as_new_record do
|
|
157
|
+
model.send "#{assoc.name}=", child
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Restore the `model`'s has_many associations as they were at version_at
|
|
166
|
+
# timestamp We lookup the first child versions after version_at timestamp or
|
|
167
|
+
# in same transaction.
|
|
168
|
+
def reify_has_manys(transaction_id, model, options = {})
|
|
169
|
+
assoc_has_many_through, assoc_has_many_directly =
|
|
170
|
+
model.class.reflect_on_all_associations(:has_many).
|
|
171
|
+
partition { |assoc| assoc.options[:through] }
|
|
172
|
+
reify_has_many_directly(transaction_id, assoc_has_many_directly, model, options)
|
|
173
|
+
reify_has_many_through(transaction_id, assoc_has_many_through, model, options)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Restore the `model`'s has_many associations not associated through
|
|
177
|
+
# another association.
|
|
178
|
+
def reify_has_many_directly(transaction_id, associations, model, options = {})
|
|
179
|
+
version_table_name = model.class.paper_trail_version_class.table_name
|
|
180
|
+
associations.each do |assoc|
|
|
181
|
+
next unless assoc.klass.paper_trail_enabled_for_model?
|
|
182
|
+
version_id_subquery = PaperTrail::VersionAssociation.
|
|
183
|
+
joins(model.class.version_association_name).
|
|
184
|
+
select("MIN(version_id)").
|
|
185
|
+
where("foreign_key_name = ?", assoc.foreign_key).
|
|
186
|
+
where("foreign_key_id = ?", model.id).
|
|
187
|
+
where("#{version_table_name}.item_type = ?", assoc.class_name).
|
|
188
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
|
189
|
+
group("item_id").
|
|
190
|
+
to_sql
|
|
191
|
+
versions = versions_by_id(model.class, version_id_subquery)
|
|
192
|
+
collection = Array.new model.send(assoc.name, true) # pass true to avoid cache
|
|
193
|
+
prepare_array_for_has_many(collection, options, versions)
|
|
194
|
+
model.send(assoc.name).proxy_association.target = collection
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Restore the `model`'s has_many associations through another association.
|
|
199
|
+
# This must be called after the direct has_manys have been reified
|
|
200
|
+
# (reify_has_many_directly).
|
|
201
|
+
def reify_has_many_through(transaction_id, associations, model, options = {})
|
|
202
|
+
associations.each do |assoc|
|
|
203
|
+
next unless assoc.klass.paper_trail_enabled_for_model?
|
|
204
|
+
|
|
205
|
+
# Load the collection of through-models. For example, if `model` is a
|
|
206
|
+
# Chapter, having many Paragraphs through Sections, then
|
|
207
|
+
# `through_collection` will contain Sections.
|
|
208
|
+
through_collection = model.send(assoc.options[:through])
|
|
209
|
+
|
|
210
|
+
# Examine the `source_reflection`, i.e. the "source" of `assoc` the
|
|
211
|
+
# `ThroughReflection`. The source can be a `BelongsToReflection`
|
|
212
|
+
# or a `HasManyReflection`.
|
|
213
|
+
#
|
|
214
|
+
# If the association is a has_many association again, then call
|
|
215
|
+
# reify_has_manys for each record in `through_collection`.
|
|
216
|
+
if !assoc.source_reflection.belongs_to? && through_collection.present?
|
|
217
|
+
through_collection.each do |through_model|
|
|
218
|
+
reify_has_manys(transaction_id, through_model, options)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# At this point, the "through" part of the association chain has
|
|
222
|
+
# been reified, but not the final, "target" part. To continue our
|
|
223
|
+
# example, `model.sections` (including `model.sections.paragraphs`)
|
|
224
|
+
# has been loaded. However, the final "target" part of the
|
|
225
|
+
# association, that is, `model.paragraphs`, has not been loaded. So,
|
|
226
|
+
# we do that now.
|
|
227
|
+
collection = through_collection.map { |through_model|
|
|
228
|
+
through_model.send(assoc.name.to_sym).to_a
|
|
229
|
+
}.flatten
|
|
230
|
+
else
|
|
231
|
+
collection_keys = through_collection.map { |through_model|
|
|
232
|
+
through_model.send(assoc.association_foreign_key)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
version_id_subquery = assoc.klass.paper_trail_version_class.
|
|
236
|
+
select("MIN(id)").
|
|
237
|
+
where("item_type = ?", assoc.class_name).
|
|
238
|
+
where("item_id IN (?)", collection_keys).
|
|
239
|
+
where("created_at >= ? OR transaction_id = ?", options[:version_at], transaction_id).
|
|
240
|
+
group("item_id").
|
|
241
|
+
to_sql
|
|
242
|
+
versions = versions_by_id(assoc.klass, version_id_subquery)
|
|
243
|
+
collection = Array.new assoc.klass.where(assoc.klass.primary_key => collection_keys)
|
|
244
|
+
prepare_array_for_has_many(collection, options, versions)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# To continue our example above, assign to `model.paragraphs` the
|
|
248
|
+
# `collection` (an array of `Paragraph`s).
|
|
249
|
+
model.send(assoc.name).proxy_association.target = collection
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Given a SQL fragment that identifies the IDs of version records,
|
|
254
|
+
# returns a `Hash` mapping those IDs to `Version`s.
|
|
255
|
+
#
|
|
256
|
+
# @api private
|
|
257
|
+
# @param klass - An ActiveRecord class.
|
|
258
|
+
# @param version_id_subquery - String. A SQL subquery that selects
|
|
259
|
+
# the IDs of version records.
|
|
260
|
+
# @return A `Hash` mapping IDs to `Version`s
|
|
261
|
+
#
|
|
262
|
+
def versions_by_id(klass, version_id_subquery)
|
|
263
|
+
klass.
|
|
264
|
+
paper_trail_version_class.
|
|
265
|
+
where("id IN (#{version_id_subquery})").
|
|
266
|
+
inject({}) { |acc, v| acc.merge!(v.item_id => v) }
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -13,8 +13,8 @@ module PaperTrail
|
|
|
13
13
|
ActiveSupport::JSON.encode object
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Returns a SQL condition to be used to match the given field and value
|
|
17
|
-
# the serialized object
|
|
16
|
+
# Returns a SQL condition to be used to match the given field and value
|
|
17
|
+
# in the serialized object
|
|
18
18
|
def where_object_condition(arel_field, field, value)
|
|
19
19
|
# Convert to JSON to handle strings and nulls correctly.
|
|
20
20
|
json_value = value.to_json
|
|
@@ -31,6 +31,17 @@ module PaperTrail
|
|
|
31
31
|
arel_field.matches("%\"#{field}\":#{json_value}%")
|
|
32
32
|
end
|
|
33
33
|
end
|
|
34
|
+
|
|
35
|
+
# Returns a SQL condition to be used to match the given field and value
|
|
36
|
+
# in the serialized object_changes
|
|
37
|
+
def where_object_changes_condition(arel_field, field, value)
|
|
38
|
+
# Convert to JSON to handle strings and nulls correctly.
|
|
39
|
+
json_value = value.to_json
|
|
40
|
+
|
|
41
|
+
# Need to check first (before) and secondary (after) fields
|
|
42
|
+
arel_field.matches("%\"#{field}\":[#{json_value},%").
|
|
43
|
+
or(arel_field.matches("%\"#{field}\":[%,#{json_value}]%"))
|
|
44
|
+
end
|
|
34
45
|
end
|
|
35
46
|
end
|
|
36
47
|
end
|
|
@@ -13,11 +13,25 @@ module PaperTrail
|
|
|
13
13
|
::YAML.dump object
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
# Returns a SQL condition to be used to match the given field and value
|
|
17
|
-
# the serialized object
|
|
16
|
+
# Returns a SQL condition to be used to match the given field and value
|
|
17
|
+
# in the serialized object
|
|
18
18
|
def where_object_condition(arel_field, field, value)
|
|
19
19
|
arel_field.matches("%\n#{field}: #{value}\n%")
|
|
20
20
|
end
|
|
21
|
+
|
|
22
|
+
# Returns a SQL condition to be used to match the given field and value
|
|
23
|
+
# in the serialized object_changes
|
|
24
|
+
def where_object_changes_condition(arel_field, field, value)
|
|
25
|
+
# Need to check first (before) and secondary (after) fields
|
|
26
|
+
if (defined?(::YAML::ENGINE) && ::YAML::ENGINE.yamler == 'psych') ||
|
|
27
|
+
(defined?(::Psych) && ::YAML == ::Psych)
|
|
28
|
+
arel_field.matches("%\n#{field}:\n- #{value}\n%").
|
|
29
|
+
or(arel_field.matches("%\n#{field}:\n-%\n- #{value}\n%"))
|
|
30
|
+
else # Syck adds extra spaces into array dumps
|
|
31
|
+
arel_field.matches("%\n#{field}: \n%- #{value}\n%").
|
|
32
|
+
or(arel_field.matches("%\n#{field}: \n-%\n- #{value}\n%"))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
21
35
|
end
|
|
22
36
|
end
|
|
23
37
|
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require 'active_support/concern'
|
|
2
|
+
|
|
3
|
+
module PaperTrail
|
|
4
|
+
module VersionAssociationConcern
|
|
5
|
+
extend ::ActiveSupport::Concern
|
|
6
|
+
|
|
7
|
+
included do
|
|
8
|
+
belongs_to :version
|
|
9
|
+
|
|
10
|
+
if PaperTrail.active_record_protected_attributes?
|
|
11
|
+
attr_accessible :version_id, :foreign_key_name, :foreign_key_id
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -6,10 +6,33 @@ module PaperTrail
|
|
|
6
6
|
|
|
7
7
|
included do
|
|
8
8
|
belongs_to :item, :polymorphic => true
|
|
9
|
+
|
|
10
|
+
# Since the test suite has test coverage for this, we want to declare
|
|
11
|
+
# the association when the test suite is running. This makes it pass when
|
|
12
|
+
# DB is not initialized prior to test runs such as when we run on Travis
|
|
13
|
+
# CI (there won't be a db in `test/dummy/db/`).
|
|
14
|
+
if PaperTrail.config.track_associations?
|
|
15
|
+
has_many :version_associations, :dependent => :destroy
|
|
16
|
+
end
|
|
17
|
+
|
|
9
18
|
validates_presence_of :event
|
|
10
|
-
|
|
19
|
+
|
|
20
|
+
if PaperTrail.active_record_protected_attributes?
|
|
21
|
+
attr_accessible(
|
|
22
|
+
:item_type,
|
|
23
|
+
:item_id,
|
|
24
|
+
:event,
|
|
25
|
+
:whodunnit,
|
|
26
|
+
:object,
|
|
27
|
+
:object_changes,
|
|
28
|
+
:transaction_id,
|
|
29
|
+
:created_at
|
|
30
|
+
)
|
|
31
|
+
end
|
|
11
32
|
|
|
12
33
|
after_create :enforce_version_limit!
|
|
34
|
+
|
|
35
|
+
scope :within_transaction, lambda { |id| where :transaction_id => id }
|
|
13
36
|
end
|
|
14
37
|
|
|
15
38
|
module ClassMethods
|
|
@@ -33,8 +56,13 @@ module PaperTrail
|
|
|
33
56
|
where 'event <> ?', 'create'
|
|
34
57
|
end
|
|
35
58
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
59
|
+
# Returns versions after `obj`.
|
|
60
|
+
#
|
|
61
|
+
# @param obj - a `Version` or a timestamp
|
|
62
|
+
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
|
63
|
+
# Default: false.
|
|
64
|
+
# @return `ActiveRecord::Relation`
|
|
65
|
+
# @api public
|
|
38
66
|
def subsequent(obj, timestamp_arg = false)
|
|
39
67
|
if timestamp_arg != true && self.primary_key_is_int?
|
|
40
68
|
return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
|
|
@@ -44,6 +72,13 @@ module PaperTrail
|
|
|
44
72
|
where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(self.timestamp_sort_order)
|
|
45
73
|
end
|
|
46
74
|
|
|
75
|
+
# Returns versions before `obj`.
|
|
76
|
+
#
|
|
77
|
+
# @param obj - a `Version` or a timestamp
|
|
78
|
+
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
|
|
79
|
+
# Default: false.
|
|
80
|
+
# @return `ActiveRecord::Relation`
|
|
81
|
+
# @api public
|
|
47
82
|
def preceding(obj, timestamp_arg = false)
|
|
48
83
|
if timestamp_arg != true && self.primary_key_is_int?
|
|
49
84
|
return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
|
|
@@ -53,7 +88,6 @@ module PaperTrail
|
|
|
53
88
|
where(arel_table[PaperTrail.timestamp_field].lt(obj)).order(self.timestamp_sort_order('desc'))
|
|
54
89
|
end
|
|
55
90
|
|
|
56
|
-
|
|
57
91
|
def between(start_time, end_time)
|
|
58
92
|
where(
|
|
59
93
|
arel_table[PaperTrail.timestamp_field].gt(start_time).
|
|
@@ -61,7 +95,8 @@ module PaperTrail
|
|
|
61
95
|
).order(self.timestamp_sort_order)
|
|
62
96
|
end
|
|
63
97
|
|
|
64
|
-
#
|
|
98
|
+
# Defaults to using the primary key as the secondary sort order if
|
|
99
|
+
# possible.
|
|
65
100
|
def timestamp_sort_order(direction = 'asc')
|
|
66
101
|
[arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
|
|
67
102
|
array << arel_table[primary_key].send(direction.downcase) if self.primary_key_is_int?
|
|
@@ -72,15 +107,51 @@ module PaperTrail
|
|
|
72
107
|
# identically-named method in the serializer being used.
|
|
73
108
|
def where_object(args = {})
|
|
74
109
|
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
|
|
75
|
-
arel_field = arel_table[:object]
|
|
76
110
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
111
|
+
if columns_hash['object'].type == :jsonb
|
|
112
|
+
where("object @> ?", args.to_json)
|
|
113
|
+
elsif columns_hash['object'].type == :json
|
|
114
|
+
predicates = []
|
|
115
|
+
values = []
|
|
116
|
+
args.each do |field, value|
|
|
117
|
+
predicates.push "object->>? = ?"
|
|
118
|
+
values.concat([field, value.to_s])
|
|
119
|
+
end
|
|
120
|
+
sql = predicates.join(" and ")
|
|
121
|
+
where(sql, *values)
|
|
122
|
+
else
|
|
123
|
+
arel_field = arel_table[:object]
|
|
124
|
+
where_conditions = args.map { |field, value|
|
|
125
|
+
PaperTrail.serializer.where_object_condition(arel_field, field, value)
|
|
126
|
+
}.reduce { |a, e| a.and(e) }
|
|
127
|
+
where(where_conditions)
|
|
81
128
|
end
|
|
129
|
+
end
|
|
82
130
|
|
|
83
|
-
|
|
131
|
+
def where_object_changes(args = {})
|
|
132
|
+
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
|
|
133
|
+
|
|
134
|
+
if columns_hash['object_changes'].type == :jsonb
|
|
135
|
+
args.each { |field, value| args[field] = [value] }
|
|
136
|
+
where("object_changes @> ?", args.to_json)
|
|
137
|
+
elsif columns_hash['object'].type == :json
|
|
138
|
+
predicates = []
|
|
139
|
+
values = []
|
|
140
|
+
args.each do |field, value|
|
|
141
|
+
predicates.push(
|
|
142
|
+
"((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
|
|
143
|
+
)
|
|
144
|
+
values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
|
|
145
|
+
end
|
|
146
|
+
sql = predicates.join(" and ")
|
|
147
|
+
where(sql, *values)
|
|
148
|
+
else
|
|
149
|
+
arel_field = arel_table[:object_changes]
|
|
150
|
+
where_conditions = args.map { |field, value|
|
|
151
|
+
PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
|
|
152
|
+
}.reduce { |a, e| a.and(e) }
|
|
153
|
+
where(where_conditions)
|
|
154
|
+
end
|
|
84
155
|
end
|
|
85
156
|
|
|
86
157
|
def primary_key_is_int?
|
|
@@ -89,110 +160,83 @@ module PaperTrail
|
|
|
89
160
|
true
|
|
90
161
|
end
|
|
91
162
|
|
|
92
|
-
# Returns whether the `object` column is using the `json` type supported
|
|
163
|
+
# Returns whether the `object` column is using the `json` type supported
|
|
164
|
+
# by PostgreSQL.
|
|
93
165
|
def object_col_is_json?
|
|
94
|
-
|
|
166
|
+
[:json, :jsonb].include?(columns_hash['object'].type)
|
|
95
167
|
end
|
|
96
168
|
|
|
97
|
-
# Returns whether the `object_changes` column is using the `json` type
|
|
169
|
+
# Returns whether the `object_changes` column is using the `json` type
|
|
170
|
+
# supported by PostgreSQL.
|
|
98
171
|
def object_changes_col_is_json?
|
|
99
|
-
|
|
172
|
+
[:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
|
|
100
173
|
end
|
|
101
174
|
end
|
|
102
175
|
|
|
103
176
|
# Restore the item from this version.
|
|
104
177
|
#
|
|
105
|
-
#
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
# opt out.
|
|
178
|
+
# Optionally this can also restore all :has_one and :has_many (including
|
|
179
|
+
# has_many :through) associations as they were "at the time", if they are
|
|
180
|
+
# also being versioned by PaperTrail.
|
|
109
181
|
#
|
|
110
182
|
# Options:
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
#
|
|
183
|
+
#
|
|
184
|
+
# - :has_one
|
|
185
|
+
# - `true` - Also reify has_one associations.
|
|
186
|
+
# - `false - Default.
|
|
187
|
+
# - :has_many
|
|
188
|
+
# - `true` - Also reify has_many and has_many :through associations.
|
|
189
|
+
# - `false` - Default.
|
|
190
|
+
# - :mark_for_destruction
|
|
191
|
+
# - `true` - Mark the has_one/has_many associations that did not exist in
|
|
192
|
+
# the reified version for destruction, instead of removing them.
|
|
193
|
+
# - `false` - Default. Useful for persisting the reified version.
|
|
194
|
+
# - :dup
|
|
195
|
+
# - `false` - Default.
|
|
196
|
+
# - `true` - Always create a new object instance. Useful for
|
|
197
|
+
# comparing two versions of the same object.
|
|
198
|
+
# - :unversioned_attributes
|
|
199
|
+
# - `:nil` - Default. Attributes undefined in version record are set to
|
|
200
|
+
# nil in reified record.
|
|
201
|
+
# - `:preserve` - Attributes undefined in version record are not modified.
|
|
202
|
+
#
|
|
114
203
|
def reify(options = {})
|
|
115
204
|
return nil if object.nil?
|
|
116
|
-
|
|
117
205
|
without_identity_map do
|
|
118
|
-
|
|
119
|
-
options.reverse_merge! :has_one => false
|
|
120
|
-
|
|
121
|
-
attrs = self.class.object_col_is_json? ? object : PaperTrail.serializer.load(object)
|
|
122
|
-
|
|
123
|
-
# Normally a polymorphic belongs_to relationship allows us
|
|
124
|
-
# to get the object we belong to by calling, in this case,
|
|
125
|
-
# `item`. However this returns nil if `item` has been
|
|
126
|
-
# destroyed, and we need to be able to retrieve destroyed
|
|
127
|
-
# objects.
|
|
128
|
-
#
|
|
129
|
-
# In this situation we constantize the `item_type` to get hold of
|
|
130
|
-
# the class...except when the stored object's attributes
|
|
131
|
-
# include a `type` key. If this is the case, the object
|
|
132
|
-
# we belong to is using single table inheritance and the
|
|
133
|
-
# `item_type` will be the base class, not the actual subclass.
|
|
134
|
-
# If `type` is present but empty, the class is the base class.
|
|
135
|
-
|
|
136
|
-
if item
|
|
137
|
-
model = item
|
|
138
|
-
# Look for attributes that exist in the model and not in this version. These attributes should be set to nil.
|
|
139
|
-
(model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
|
|
140
|
-
else
|
|
141
|
-
inheritance_column_name = item_type.constantize.inheritance_column
|
|
142
|
-
class_name = attrs[inheritance_column_name].blank? ? item_type : attrs[inheritance_column_name]
|
|
143
|
-
klass = class_name.constantize
|
|
144
|
-
model = klass.new
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
model.class.unserialize_attributes_for_paper_trail attrs
|
|
148
|
-
|
|
149
|
-
# Set all the attributes in this version on the model
|
|
150
|
-
attrs.each do |k, v|
|
|
151
|
-
if model.respond_to?("#{k}=")
|
|
152
|
-
model[k.to_sym] = v
|
|
153
|
-
else
|
|
154
|
-
logger.warn "Attribute #{k} does not exist on #{item_type} (Version id: #{id})."
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
model.send "#{model.class.version_association_name}=", self
|
|
159
|
-
|
|
160
|
-
unless options[:has_one] == false
|
|
161
|
-
reify_has_ones model, options[:has_one]
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
model
|
|
206
|
+
::PaperTrail::Reifier.reify(self, options)
|
|
165
207
|
end
|
|
166
208
|
end
|
|
167
209
|
|
|
168
|
-
# Returns what changed in this version of the item.
|
|
169
|
-
#
|
|
210
|
+
# Returns what changed in this version of the item.
|
|
211
|
+
# `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
|
|
212
|
+
# not have an `object_changes` text column.
|
|
170
213
|
def changeset
|
|
171
214
|
return nil unless self.class.column_names.include? 'object_changes'
|
|
172
|
-
|
|
173
|
-
_changes = self.class.object_changes_col_is_json? ? object_changes : PaperTrail.serializer.load(object_changes)
|
|
174
|
-
@changeset ||= HashWithIndifferentAccess.new(_changes).tap do |changes|
|
|
175
|
-
item_type.constantize.unserialize_attribute_changes(changes)
|
|
176
|
-
end
|
|
177
|
-
rescue
|
|
178
|
-
{}
|
|
215
|
+
@changeset ||= load_changeset
|
|
179
216
|
end
|
|
180
217
|
|
|
181
218
|
# Returns who put the item into the state stored in this version.
|
|
219
|
+
def paper_trail_originator
|
|
220
|
+
@paper_trail_originator ||= previous.whodunnit rescue nil
|
|
221
|
+
end
|
|
222
|
+
|
|
182
223
|
def originator
|
|
183
|
-
|
|
224
|
+
::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
|
|
225
|
+
self.paper_trail_originator
|
|
184
226
|
end
|
|
185
227
|
|
|
186
|
-
# Returns who changed the item from the state it had in this version.
|
|
187
|
-
#
|
|
228
|
+
# Returns who changed the item from the state it had in this version. This
|
|
229
|
+
# is an alias for `whodunnit`.
|
|
188
230
|
def terminator
|
|
189
231
|
@terminator ||= whodunnit
|
|
190
232
|
end
|
|
191
233
|
alias_method :version_author, :terminator
|
|
192
234
|
|
|
193
235
|
def sibling_versions(reload = false)
|
|
194
|
-
|
|
195
|
-
|
|
236
|
+
if reload || @sibling_versions.nil?
|
|
237
|
+
@sibling_versions = self.class.with_item_keys(item_type, item_id)
|
|
238
|
+
end
|
|
239
|
+
@sibling_versions
|
|
196
240
|
end
|
|
197
241
|
|
|
198
242
|
def next
|
|
@@ -203,21 +247,37 @@ module PaperTrail
|
|
|
203
247
|
@previous ||= sibling_versions.preceding(self).first
|
|
204
248
|
end
|
|
205
249
|
|
|
250
|
+
# Returns an integer representing the chronological position of the
|
|
251
|
+
# version among its siblings (see `sibling_versions`). The "create" event,
|
|
252
|
+
# for example, has an index of 0.
|
|
253
|
+
# @api public
|
|
206
254
|
def index
|
|
207
|
-
|
|
208
|
-
@index ||=
|
|
209
|
-
if self.class.primary_key_is_int?
|
|
210
|
-
sibling_versions.select(table[self.class.primary_key]).order(table[self.class.primary_key].asc).index(self)
|
|
211
|
-
else
|
|
212
|
-
sibling_versions.select([table[PaperTrail.timestamp_field], table[self.class.primary_key]]).
|
|
213
|
-
order(self.class.timestamp_sort_order).index(self)
|
|
214
|
-
end
|
|
255
|
+
@index ||= RecordHistory.new(sibling_versions, self.class).index(self)
|
|
215
256
|
end
|
|
216
257
|
|
|
217
258
|
private
|
|
218
259
|
|
|
260
|
+
# @api private
|
|
261
|
+
def load_changeset
|
|
262
|
+
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
|
|
263
|
+
item_type.constantize.unserialize_attribute_changes_for_paper_trail!(changes)
|
|
264
|
+
changes
|
|
265
|
+
rescue # TODO: Rescue something specific
|
|
266
|
+
{}
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# @api private
|
|
270
|
+
def object_changes_deserialized
|
|
271
|
+
if self.class.object_changes_col_is_json?
|
|
272
|
+
object_changes
|
|
273
|
+
else
|
|
274
|
+
PaperTrail.serializer.load(object_changes)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
219
278
|
# In Rails 3.1+, calling reify on a previous version confuses the
|
|
220
279
|
# IdentityMap, if enabled. This prevents insertion into the map.
|
|
280
|
+
# @api private
|
|
221
281
|
def without_identity_map(&block)
|
|
222
282
|
if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
|
|
223
283
|
::ActiveRecord::IdentityMap.without(&block)
|
|
@@ -226,38 +286,16 @@ module PaperTrail
|
|
|
226
286
|
end
|
|
227
287
|
end
|
|
228
288
|
|
|
229
|
-
#
|
|
230
|
-
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
# The `lookback` sets how many seconds before the model's change we go.
|
|
234
|
-
def reify_has_ones(model, lookback)
|
|
235
|
-
model.class.reflect_on_all_associations(:has_one).each do |assoc|
|
|
236
|
-
child = model.send assoc.name
|
|
237
|
-
if child.respond_to? :version_at
|
|
238
|
-
# N.B. we use version of the child as it was `lookback` seconds before the parent was updated.
|
|
239
|
-
# Ideally we want the version of the child as it was just before the parent was updated...
|
|
240
|
-
# but until PaperTrail knows which updates are "together" (e.g. parent and child being
|
|
241
|
-
# updated on the same form), it's impossible to tell when the overall update started;
|
|
242
|
-
# and therefore impossible to know when "just before" was.
|
|
243
|
-
if (child_as_it_was = child.version_at(send(PaperTrail.timestamp_field) - lookback.seconds))
|
|
244
|
-
child_as_it_was.attributes.each do |k,v|
|
|
245
|
-
model.send(assoc.name).send :write_attribute, k.to_sym, v rescue nil
|
|
246
|
-
end
|
|
247
|
-
else
|
|
248
|
-
model.send "#{assoc.name}=", nil
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
# checks to see if a value has been set for the `version_limit` config option, and if so enforces it
|
|
289
|
+
# Checks that a value has been set for the `version_limit` config
|
|
290
|
+
# option, and if so enforces it.
|
|
291
|
+
# @api private
|
|
255
292
|
def enforce_version_limit!
|
|
256
|
-
|
|
293
|
+
limit = PaperTrail.config.version_limit
|
|
294
|
+
return unless limit.is_a? Numeric
|
|
257
295
|
previous_versions = sibling_versions.not_creates
|
|
258
|
-
return unless previous_versions.size >
|
|
259
|
-
|
|
260
|
-
|
|
296
|
+
return unless previous_versions.size > limit
|
|
297
|
+
excess_versions = previous_versions - previous_versions.last(limit)
|
|
298
|
+
excess_versions.map(&:destroy)
|
|
261
299
|
end
|
|
262
300
|
end
|
|
263
301
|
end
|