/
version_concern.rb
302 lines (263 loc) · 9.89 KB
/
version_concern.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
require 'active_support/concern'
module PaperTrail
module VersionConcern
extend ::ActiveSupport::Concern
included do
belongs_to :item, :polymorphic => true
# Since the test suite has test coverage for this, we want to declare
# the association when the test suite is running. This makes it pass when
# DB is not initialized prior to test runs such as when we run on Travis
# CI (there won't be a db in `test/dummy/db/`).
if PaperTrail.config.track_associations?
has_many :version_associations, :dependent => :destroy
end
validates_presence_of :event
if PaperTrail.active_record_protected_attributes?
attr_accessible(
:item_type,
:item_id,
:event,
:whodunnit,
:object,
:object_changes,
:transaction_id,
:created_at
)
end
after_create :enforce_version_limit!
scope :within_transaction, lambda { |id| where :transaction_id => id }
end
module ClassMethods
def with_item_keys(item_type, item_id)
where :item_type => item_type, :item_id => item_id
end
def creates
where :event => 'create'
end
def updates
where :event => 'update'
end
def destroys
where :event => 'destroy'
end
def not_creates
where 'event <> ?', 'create'
end
# Returns versions after `obj`.
#
# @param obj - a `Version` or a timestamp
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
# Default: false.
# @return `ActiveRecord::Relation`
# @api public
def subsequent(obj, timestamp_arg = false)
if timestamp_arg != true && self.primary_key_is_int?
return where(arel_table[primary_key].gt(obj.id)).order(arel_table[primary_key].asc)
end
obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
where(arel_table[PaperTrail.timestamp_field].gt(obj)).order(self.timestamp_sort_order)
end
# Returns versions before `obj`.
#
# @param obj - a `Version` or a timestamp
# @param timestamp_arg - boolean - When true, `obj` is a timestamp.
# Default: false.
# @return `ActiveRecord::Relation`
# @api public
def preceding(obj, timestamp_arg = false)
if timestamp_arg != true && self.primary_key_is_int?
return where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
end
obj = obj.send(PaperTrail.timestamp_field) if obj.is_a?(self)
where(arel_table[PaperTrail.timestamp_field].lt(obj)).
order(self.timestamp_sort_order('desc'))
end
def between(start_time, end_time)
where(
arel_table[PaperTrail.timestamp_field].gt(start_time).
and(arel_table[PaperTrail.timestamp_field].lt(end_time))
).order(self.timestamp_sort_order)
end
# Defaults to using the primary key as the secondary sort order if
# possible.
def timestamp_sort_order(direction = 'asc')
[arel_table[PaperTrail.timestamp_field].send(direction.downcase)].tap do |array|
array << arel_table[primary_key].send(direction.downcase) if self.primary_key_is_int?
end
end
# Performs an attribute search on the serialized object by invoking the
# identically-named method in the serializer being used.
def where_object(args = {})
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
if columns_hash['object'].type == :jsonb
where("object @> ?", args.to_json)
elsif columns_hash['object'].type == :json
predicates = []
values = []
args.each do |field, value|
predicates.push "object->>? = ?"
values.concat([field, value.to_s])
end
sql = predicates.join(" and ")
where(sql, *values)
else
arel_field = arel_table[:object]
where_conditions = args.map { |field, value|
PaperTrail.serializer.where_object_condition(arel_field, field, value)
}.reduce { |a, e| a.and(e) }
where(where_conditions)
end
end
def where_object_changes(args = {})
raise ArgumentError, 'expected to receive a Hash' unless args.is_a?(Hash)
if columns_hash['object_changes'].type == :jsonb
args.each { |field, value| args[field] = [value] }
where("object_changes @> ?", args.to_json)
elsif columns_hash['object'].type == :json
predicates = []
values = []
args.each do |field, value|
predicates.push(
"((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
)
values.concat([field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%"])
end
sql = predicates.join(" and ")
where(sql, *values)
else
arel_field = arel_table[:object_changes]
where_conditions = args.map { |field, value|
PaperTrail.serializer.where_object_changes_condition(arel_field, field, value)
}.reduce { |a, e| a.and(e) }
where(where_conditions)
end
end
def primary_key_is_int?
@primary_key_is_int ||= columns_hash[primary_key].type == :integer
rescue
true
end
# Returns whether the `object` column is using the `json` type supported
# by PostgreSQL.
def object_col_is_json?
[:json, :jsonb].include?(columns_hash['object'].type)
end
# Returns whether the `object_changes` column is using the `json` type
# supported by PostgreSQL.
def object_changes_col_is_json?
[:json, :jsonb].include?(columns_hash['object_changes'].try(:type))
end
end
# Restore the item from this version.
#
# Optionally this can also restore all :has_one and :has_many (including
# has_many :through) associations as they were "at the time", if they are
# also being versioned by PaperTrail.
#
# Options:
#
# - :has_one
# - `true` - Also reify has_one associations.
# - `false - Default.
# - :has_many
# - `true` - Also reify has_many and has_many :through associations.
# - `false` - Default.
# - :mark_for_destruction
# - `true` - Mark the has_one/has_many associations that did not exist in
# the reified version for destruction, instead of removing them.
# - `false` - Default. Useful for persisting the reified version.
# - :dup
# - `false` - Default.
# - `true` - Always create a new object instance. Useful for
# comparing two versions of the same object.
# - :unversioned_attributes
# - `:nil` - Default. Attributes undefined in version record are set to
# nil in reified record.
# - `:preserve` - Attributes undefined in version record are not modified.
#
def reify(options = {})
return nil if object.nil?
without_identity_map do
::PaperTrail::Reifier.reify(self, options)
end
end
# Returns what changed in this version of the item.
# `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
# not have an `object_changes` text column.
def changeset
return nil unless self.class.column_names.include? 'object_changes'
@changeset ||= load_changeset
end
# Returns who put the item into the state stored in this version.
def paper_trail_originator
@paper_trail_originator ||= previous.whodunnit rescue nil
end
def originator
::ActiveSupport::Deprecation.warn "Use paper_trail_originator instead of originator."
self.paper_trail_originator
end
# Returns who changed the item from the state it had in this version. This
# is an alias for `whodunnit`.
def terminator
@terminator ||= whodunnit
end
alias_method :version_author, :terminator
def sibling_versions(reload = false)
if reload || @sibling_versions.nil?
@sibling_versions = self.class.with_item_keys(item_type, item_id)
end
@sibling_versions
end
def next
@next ||= sibling_versions.subsequent(self).first
end
def previous
@previous ||= sibling_versions.preceding(self).first
end
# Returns an integer representing the chronological position of the
# version among its siblings (see `sibling_versions`). The "create" event,
# for example, has an index of 0.
# @api public
def index
@index ||= RecordHistory.new(sibling_versions, self.class).index(self)
end
private
# @api private
def load_changeset
changes = HashWithIndifferentAccess.new(object_changes_deserialized)
item_type.constantize.unserialize_attribute_changes_for_paper_trail!(changes)
changes
rescue # TODO: Rescue something specific
{}
end
# @api private
def object_changes_deserialized
if self.class.object_changes_col_is_json?
object_changes
else
PaperTrail.serializer.load(object_changes)
end
end
# In Rails 3.1+, calling reify on a previous version confuses the
# IdentityMap, if enabled. This prevents insertion into the map.
# @api private
def without_identity_map(&block)
if defined?(::ActiveRecord::IdentityMap) && ::ActiveRecord::IdentityMap.respond_to?(:without)
::ActiveRecord::IdentityMap.without(&block)
else
block.call
end
end
# Checks that a value has been set for the `version_limit` config
# option, and if so enforces it.
# @api private
def enforce_version_limit!
limit = PaperTrail.config.version_limit
return unless limit.is_a? Numeric
previous_versions = sibling_versions.not_creates
return unless previous_versions.size > limit
excess_versions = previous_versions - previous_versions.last(limit)
excess_versions.map(&:destroy)
end
end
end