snail_trail 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/lib/generators/snail_trail/install/USAGE +3 -0
  4. data/lib/generators/snail_trail/install/install_generator.rb +108 -0
  5. data/lib/generators/snail_trail/install/templates/add_object_changes_to_versions.rb.erb +12 -0
  6. data/lib/generators/snail_trail/install/templates/add_transaction_id_column_to_versions.rb.erb +12 -0
  7. data/lib/generators/snail_trail/install/templates/create_versions.rb.erb +41 -0
  8. data/lib/generators/snail_trail/migration_generator.rb +38 -0
  9. data/lib/generators/snail_trail/update_item_subtype/USAGE +4 -0
  10. data/lib/generators/snail_trail/update_item_subtype/templates/update_versions_for_item_subtype.rb.erb +85 -0
  11. data/lib/generators/snail_trail/update_item_subtype/update_item_subtype_generator.rb +19 -0
  12. data/lib/snail_trail/attribute_serializers/README.md +10 -0
  13. data/lib/snail_trail/attribute_serializers/attribute_serializer_factory.rb +41 -0
  14. data/lib/snail_trail/attribute_serializers/cast_attribute_serializer.rb +51 -0
  15. data/lib/snail_trail/attribute_serializers/object_attribute.rb +51 -0
  16. data/lib/snail_trail/attribute_serializers/object_changes_attribute.rb +54 -0
  17. data/lib/snail_trail/cleaner.rb +60 -0
  18. data/lib/snail_trail/compatibility.rb +51 -0
  19. data/lib/snail_trail/config.rb +40 -0
  20. data/lib/snail_trail/errors.rb +33 -0
  21. data/lib/snail_trail/events/base.rb +343 -0
  22. data/lib/snail_trail/events/create.rb +32 -0
  23. data/lib/snail_trail/events/destroy.rb +42 -0
  24. data/lib/snail_trail/events/update.rb +76 -0
  25. data/lib/snail_trail/frameworks/active_record/models/snail_trail/version.rb +16 -0
  26. data/lib/snail_trail/frameworks/active_record.rb +12 -0
  27. data/lib/snail_trail/frameworks/cucumber.rb +33 -0
  28. data/lib/snail_trail/frameworks/rails/controller.rb +103 -0
  29. data/lib/snail_trail/frameworks/rails/railtie.rb +34 -0
  30. data/lib/snail_trail/frameworks/rails.rb +3 -0
  31. data/lib/snail_trail/frameworks/rspec/helpers.rb +29 -0
  32. data/lib/snail_trail/frameworks/rspec.rb +42 -0
  33. data/lib/snail_trail/has_snail_trail.rb +92 -0
  34. data/lib/snail_trail/model_config.rb +265 -0
  35. data/lib/snail_trail/queries/versions/where_attribute_changes.rb +50 -0
  36. data/lib/snail_trail/queries/versions/where_object.rb +65 -0
  37. data/lib/snail_trail/queries/versions/where_object_changes.rb +70 -0
  38. data/lib/snail_trail/queries/versions/where_object_changes_from.rb +57 -0
  39. data/lib/snail_trail/queries/versions/where_object_changes_to.rb +57 -0
  40. data/lib/snail_trail/record_history.rb +51 -0
  41. data/lib/snail_trail/record_trail.rb +375 -0
  42. data/lib/snail_trail/reifier.rb +147 -0
  43. data/lib/snail_trail/request.rb +180 -0
  44. data/lib/snail_trail/serializers/json.rb +36 -0
  45. data/lib/snail_trail/serializers/yaml.rb +68 -0
  46. data/lib/snail_trail/type_serializers/postgres_array_serializer.rb +35 -0
  47. data/lib/snail_trail/version_concern.rb +407 -0
  48. data/lib/snail_trail/version_number.rb +23 -0
  49. data/lib/snail_trail.rb +141 -1
  50. metadata +369 -13
  51. data/lib/snail_trail/version.rb +0 -5
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_object_changes` in
7
+ # `snail_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereObjectChanges
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attributes - A `Hash` of attributes and values. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attributes)
15
+ @version_model_class = version_model_class
16
+
17
+ # Currently, this `deep_dup` is necessary because the `jsonb` branch
18
+ # modifies `@attributes`, and that would be a nasty surprise for
19
+ # consumers of this class.
20
+ # TODO: Stop modifying `@attributes`, then remove `deep_dup`.
21
+ @attributes = attributes.deep_dup
22
+ end
23
+
24
+ # @api private
25
+ def execute
26
+ if SnailTrail.config.object_changes_adapter.respond_to?(:where_object_changes)
27
+ return SnailTrail.config.object_changes_adapter.where_object_changes(
28
+ @version_model_class, @attributes
29
+ )
30
+ end
31
+ column_type = @version_model_class.columns_hash["object_changes"].type
32
+ case column_type
33
+ when :jsonb
34
+ jsonb
35
+ when :json
36
+ json
37
+ else
38
+ raise UnsupportedColumnType.new(
39
+ method: "where_object_changes",
40
+ expected: "json or jsonb",
41
+ actual: column_type
42
+ )
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ # @api private
49
+ def json
50
+ predicates = []
51
+ values = []
52
+ @attributes.each do |field, value|
53
+ predicates.push(
54
+ "((object_changes->>? ILIKE ?) OR (object_changes->>? ILIKE ?))"
55
+ )
56
+ values.push(field, "[#{value.to_json},%", field, "[%,#{value.to_json}]%")
57
+ end
58
+ sql = predicates.join(" and ")
59
+ @version_model_class.where(sql, *values)
60
+ end
61
+
62
+ # @api private
63
+ def jsonb
64
+ @attributes.each { |field, value| @attributes[field] = [value] }
65
+ @version_model_class.where("object_changes @> ?", @attributes.to_json)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_object_changes_from` in
7
+ # `snail_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereObjectChangesFrom
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attributes - A `Hash` of attributes and values. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attributes)
15
+ @version_model_class = version_model_class
16
+ @attributes = attributes
17
+ end
18
+
19
+ # @api private
20
+ def execute
21
+ if SnailTrail.config.object_changes_adapter.respond_to?(:where_object_changes_from)
22
+ return SnailTrail.config.object_changes_adapter.where_object_changes_from(
23
+ @version_model_class, @attributes
24
+ )
25
+ end
26
+ column_type = @version_model_class.columns_hash["object_changes"].type
27
+ case column_type
28
+ when :jsonb, :json
29
+ json
30
+ else
31
+ raise UnsupportedColumnType.new(
32
+ method: "where_object_changes_from",
33
+ expected: "json or jsonb",
34
+ actual: column_type
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @api private
42
+ def json
43
+ predicates = []
44
+ values = []
45
+ @attributes.each do |field, value|
46
+ predicates.push(
47
+ "(object_changes->>? ILIKE ?)"
48
+ )
49
+ values.push(field, "[#{value.to_json},%")
50
+ end
51
+ sql = predicates.join(" and ")
52
+ @version_model_class.where(sql, *values)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ module Queries
5
+ module Versions
6
+ # For public API documentation, see `where_object_changes_to` in
7
+ # `snail_trail/version_concern.rb`.
8
+ # @api private
9
+ class WhereObjectChangesTo
10
+ # - version_model_class - The class that VersionConcern was mixed into.
11
+ # - attributes - A `Hash` of attributes and values. See the public API
12
+ # documentation for details.
13
+ # @api private
14
+ def initialize(version_model_class, attributes)
15
+ @version_model_class = version_model_class
16
+ @attributes = attributes
17
+ end
18
+
19
+ # @api private
20
+ def execute
21
+ if SnailTrail.config.object_changes_adapter.respond_to?(:where_object_changes_to)
22
+ return SnailTrail.config.object_changes_adapter.where_object_changes_to(
23
+ @version_model_class, @attributes
24
+ )
25
+ end
26
+ column_type = @version_model_class.columns_hash["object_changes"].type
27
+ case column_type
28
+ when :jsonb, :json
29
+ json
30
+ else
31
+ raise UnsupportedColumnType.new(
32
+ method: "where_object_changes_to",
33
+ expected: "json or jsonb",
34
+ actual: column_type
35
+ )
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ # @api private
42
+ def json
43
+ predicates = []
44
+ values = []
45
+ @attributes.each do |field, value|
46
+ predicates.push(
47
+ "(object_changes->>? ILIKE ?)"
48
+ )
49
+ values.push(field, "[%#{value.to_json}]")
50
+ end
51
+ sql = predicates.join(" and ")
52
+ @version_model_class.where(sql, *values)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ # Represents the history of a single record.
5
+ # @api private
6
+ class RecordHistory
7
+ # @param versions - ActiveRecord::Relation - All versions of the record.
8
+ # @param version_class - Class - Usually SnailTrail::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.to_a.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([table[:created_at], 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
+ end
51
+ end
@@ -0,0 +1,375 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snail_trail/events/create"
4
+ require "snail_trail/events/destroy"
5
+ require "snail_trail/events/update"
6
+
7
+ module SnailTrail
8
+ # Represents the "snail trail" for a single record.
9
+ class RecordTrail
10
+ def initialize(record)
11
+ @record = record
12
+ end
13
+
14
+ # Invoked after rollbacks to ensure versions records are not created for
15
+ # changes that never actually took place. Optimization: Use lazy `reset`
16
+ # instead of eager `reload` because, in many use cases, the association will
17
+ # not be used.
18
+ def clear_rolled_back_versions
19
+ versions.reset
20
+ end
21
+
22
+ # Invoked via`after_update` callback for when a previous version is
23
+ # reified and then saved.
24
+ def clear_version_instance
25
+ @record.send(:"#{@record.class.version_association_name}=", nil)
26
+ end
27
+
28
+ # Returns true if this instance is the current, live one;
29
+ # returns false if this instance came from a previous version.
30
+ def live?
31
+ source_version.nil?
32
+ end
33
+
34
+ # Returns the object (not a Version) as it became next.
35
+ # NOTE: if self (the item) was not reified from a version, i.e. it is the
36
+ # "live" item, we return nil. Perhaps we should return self instead?
37
+ def next_version
38
+ subsequent_version = source_version.next
39
+ subsequent_version ? subsequent_version.reify : @record.class.find(@record.id)
40
+ rescue StandardError # TODO: Rescue something more specific
41
+ nil
42
+ end
43
+
44
+ # Returns who put `@record` into its current state.
45
+ #
46
+ # @api public
47
+ def originator
48
+ (source_version || versions.last).try(:whodunnit)
49
+ end
50
+
51
+ # Returns the object (not a Version) as it was most recently.
52
+ #
53
+ # @api public
54
+ def previous_version
55
+ (source_version ? source_version.previous : versions.last).try(:reify)
56
+ end
57
+
58
+ def record_create
59
+ return unless enabled?
60
+
61
+ build_version_on_create(in_after_callback: true).tap do |version|
62
+ version.save!
63
+
64
+ update_transaction_id(version)
65
+
66
+ # Because the version object was created using version_class.new instead
67
+ # of versions_assoc.build?, the association cache is unaware. So, we
68
+ # invalidate the `versions` association cache with `reset`.
69
+ versions.reset
70
+ rescue StandardError => e
71
+ handle_version_errors e, version, :create
72
+ end
73
+ end
74
+
75
+ # `recording_order` is "after" or "before". See ModelConfig#on_destroy.
76
+ #
77
+ # @api private
78
+ # @return - The created version object, so that plugins can use it, e.g.
79
+ # snail_trail-association_tracking
80
+ def record_destroy(recording_order)
81
+ return unless enabled? && !@record.new_record?
82
+ in_after_callback = recording_order == "after"
83
+ event = Events::Destroy.new(@record, in_after_callback)
84
+
85
+ # Merge data from `Event` with data from ST-AT. We no longer use
86
+ # `data_for_destroy` but ST-AT still does.
87
+ data = event.data.merge(data_for_destroy)
88
+
89
+ version = @record.class.snail_trail.version_class.new(data)
90
+ begin
91
+ version.save!
92
+ assign_and_reset_version_association(version)
93
+
94
+ if version && version.respond_to?(:errors) && version.errors.empty?
95
+ update_transaction_id(version)
96
+ end
97
+
98
+ version
99
+ rescue StandardError => e
100
+ handle_version_errors e, version, :destroy
101
+ end
102
+ end
103
+
104
+ # @api private
105
+ # @param force [boolean] Insert a `Version` even if `@record` has not
106
+ # `changed_notably?`.
107
+ # @param in_after_callback [boolean] True when called from an `after_update`
108
+ # or `after_touch` callback.
109
+ # @param is_touch [boolean] True when called from an `after_touch` callback.
110
+ # @return - The created version object, so that plugins can use it, e.g.
111
+ # snail_trail-association_tracking
112
+ def record_update(force:, in_after_callback:, is_touch:)
113
+ return unless enabled?
114
+
115
+ version = build_version_on_update(
116
+ force: force,
117
+ in_after_callback: in_after_callback,
118
+ is_touch: is_touch
119
+ )
120
+ return unless version
121
+
122
+ begin
123
+ version.save!
124
+ # Because the version object was created using version_class.new instead
125
+ # of versions_assoc.build?, the association cache is unaware. So, we
126
+ # invalidate the `versions` association cache with `reset`.
127
+ versions.reset
128
+
129
+ if version && version.respond_to?(:errors) && version.errors.empty?
130
+ update_transaction_id(version)
131
+ end
132
+
133
+ version
134
+ rescue StandardError => e
135
+ handle_version_errors e, version, :update
136
+ end
137
+ end
138
+
139
+ # Invoked via callback when a user attempts to persist a reified
140
+ # `Version`.
141
+ def reset_timestamp_attrs_for_update_if_needed
142
+ return if live?
143
+ @record.send(:timestamp_attributes_for_update_in_model).each do |column|
144
+ @record.send(:"restore_#{column}!")
145
+ end
146
+ end
147
+
148
+ # AR callback.
149
+ # @api private
150
+ def save_version?
151
+ if_condition = @record.snail_trail_options[:if]
152
+ unless_condition = @record.snail_trail_options[:unless]
153
+ (if_condition.blank? || if_condition.call(@record)) && !unless_condition.try(:call, @record)
154
+ end
155
+
156
+ def source_version
157
+ version
158
+ end
159
+
160
+ # Save, and create a version record regardless of options such as `:on`,
161
+ # `:if`, or `:unless`.
162
+ #
163
+ # `in_after_callback`: Indicates if this method is being called within an
164
+ # `after` callback. Defaults to `false`.
165
+ # `options`: Optional arguments passed to `save`.
166
+ #
167
+ # This is an "update" event. That is, we record the same data we would in
168
+ # the case of a normal AR `update`.
169
+ def save_with_version(in_after_callback: false, **options)
170
+ ::SnailTrail.request(enabled: false) do
171
+ @record.save(**options)
172
+ end
173
+ record_update(force: true, in_after_callback: in_after_callback, is_touch: false)
174
+ end
175
+
176
+ # Like the `update_column` method from `ActiveRecord::Persistence`, but also
177
+ # creates a version to record those changes.
178
+ # @api public
179
+ def update_column(name, value)
180
+ update_columns(name => value)
181
+ end
182
+
183
+ # Like the `update_columns` method from `ActiveRecord::Persistence`, but also
184
+ # creates a version to record those changes.
185
+ # @api public
186
+ def update_columns(attributes)
187
+ # `@record.update_columns` skips dirty-tracking, so we can't just use
188
+ # `@record.changes` or @record.saved_changes` from `ActiveModel::Dirty`.
189
+ # We need to build our own hash with the changes that will be made
190
+ # directly to the database.
191
+ changes = {}
192
+ attributes.each do |k, v|
193
+ changes[k] = [@record[k], v]
194
+ end
195
+ @record.update_columns(attributes)
196
+ record_update_columns(changes)
197
+ end
198
+
199
+ # Returns the object (not a Version) as it was at the given timestamp.
200
+ def version_at(timestamp, reify_options = {})
201
+ # Because a version stores how its object looked *before* the change,
202
+ # we need to look for the first version created *after* the timestamp.
203
+ v = versions.subsequent(timestamp, true).first
204
+ return v.reify(reify_options) if v
205
+ @record unless @record.destroyed?
206
+ end
207
+
208
+ # Returns the objects (not Versions) as they were between the given times.
209
+ def versions_between(start_time, end_time)
210
+ versions = send(@record.class.versions_association_name).between(start_time, end_time)
211
+ versions.collect { |version| version_at(version.created_at) }
212
+ end
213
+
214
+ private
215
+
216
+ def add_transaction_id_to(data)
217
+ return unless @record.class.snail_trail.version_class.column_names.include?("transaction_id")
218
+ data[:transaction_id] = ::SnailTrail.request.transaction_id
219
+ end
220
+
221
+
222
+ def update_transaction_id(version)
223
+ return unless @record.class.snail_trail.version_class.column_names.include?("transaction_id")
224
+ if ::SnailTrail.transaction? && ::SnailTrail.request.transaction_id.nil?
225
+ ::SnailTrail.request.transaction_id = version.id
226
+ version.transaction_id = version.id
227
+ version.save
228
+ end
229
+ end
230
+
231
+ # @api private
232
+ def assign_and_reset_version_association(version)
233
+ @record.send(:"#{@record.class.version_association_name}=", version)
234
+ @record.send(@record.class.versions_association_name).reset
235
+ end
236
+
237
+ # @api private
238
+ def build_version_on_create(in_after_callback:)
239
+ event = Events::Create.new(@record, in_after_callback)
240
+
241
+ # Merge data from `Event` with data from ST-AT. We no longer use
242
+ # `data_for_create` but ST-AT still does.
243
+ data = event.data.merge!(data_for_create)
244
+
245
+ # Pure `version_class.new` reduces memory usage compared to `versions_assoc.build`
246
+ @record.class.snail_trail.version_class.new(data)
247
+ end
248
+
249
+ # @api private
250
+ def build_version_on_update(force:, in_after_callback:, is_touch:)
251
+ event = Events::Update.new(@record, in_after_callback, is_touch, nil)
252
+ return unless force || event.changed_notably?
253
+ data = event.data
254
+
255
+ # Copy the (recently set) `updated_at` from the record to the `created_at`
256
+ # of the `Version`. Without this feature, these two timestamps would
257
+ # differ by a few milliseconds. To some people, it seems a little
258
+ # unnatural to tamper with creation timestamps in this way. But, this
259
+ # feature has existed for a long time, almost a decade now, and some users
260
+ # may rely on it now.
261
+ if @record.respond_to?(:updated_at) &&
262
+ @record.snail_trail_options[:synchronize_version_creation_timestamp] != false
263
+ data[:created_at] = @record.updated_at
264
+ end
265
+
266
+ # Merge data from `Event` with data from ST-AT. We no longer use
267
+ # `data_for_update` but ST-AT still does. To save memory, we use `merge!`
268
+ # instead of `merge`.
269
+ data.merge!(data_for_update)
270
+
271
+ # Using `version_class.new` reduces memory usage compared to
272
+ # `versions_assoc.build`. It's a trade-off though. We have to clear
273
+ # the association cache (see `versions.reset`) and that could cause an
274
+ # additional query in certain applications.
275
+ @record.class.snail_trail.version_class.new(data)
276
+ end
277
+
278
+ # @api public
279
+ def data_for_create
280
+ data = {}
281
+ add_transaction_id_to(data)
282
+ data
283
+ end
284
+
285
+ # @api public
286
+ def data_for_destroy
287
+ data = {}
288
+ add_transaction_id_to(data)
289
+ data
290
+ end
291
+
292
+ # @api public
293
+ def data_for_update
294
+ data = {}
295
+ add_transaction_id_to(data)
296
+ data
297
+ end
298
+
299
+ # @api public
300
+ def data_for_update_columns
301
+ data = {}
302
+ add_transaction_id_to(data)
303
+ data
304
+ end
305
+
306
+ # Is ST enabled for this particular record?
307
+ # @api private
308
+ def enabled?
309
+ SnailTrail.enabled? &&
310
+ SnailTrail.request.enabled? &&
311
+ SnailTrail.request.enabled_for_model?(@record.class)
312
+ end
313
+
314
+ def log_version_errors(version, action)
315
+ version.logger&.warn(
316
+ "Unable to create version for #{action} of #{@record.class.name}" \
317
+ "##{@record.id}: " + version.errors.full_messages.join(", ")
318
+ )
319
+ end
320
+
321
+ # Centralized handler for version errors
322
+ # @api private
323
+ def handle_version_errors(e, version, action)
324
+ case SnailTrail.config.version_error_behavior
325
+ when :legacy
326
+ # legacy behavior was to raise on create and log on update/delete
327
+ if action == :create
328
+ raise e
329
+ else
330
+ log_version_errors(version, action)
331
+ end
332
+ when :log
333
+ log_version_errors(version, action)
334
+ when :exception
335
+ raise e
336
+ when :silent
337
+ # noop
338
+ end
339
+ end
340
+
341
+ # @api private
342
+ # @return - The created version object, so that plugins can use it, e.g.
343
+ # snail_trail-association_tracking
344
+ def record_update_columns(changes)
345
+ return unless enabled?
346
+ data = Events::Update.new(@record, false, false, changes).data
347
+
348
+ # Merge data from `Event` with data from ST-AT. We no longer use
349
+ # `data_for_update_columns` but ST-AT still does.
350
+ data.merge!(data_for_update_columns)
351
+
352
+ versions_assoc = @record.send(@record.class.versions_association_name)
353
+ version = versions_assoc.new(data)
354
+ begin
355
+ version.save!
356
+
357
+ if version && version.respond_to?(:errors) && version.errors.empty?
358
+ update_transaction_id(version)
359
+ end
360
+
361
+ version
362
+ rescue StandardError => e
363
+ handle_version_errors e, version, :update
364
+ end
365
+ end
366
+
367
+ def version
368
+ @record.public_send(@record.class.version_association_name)
369
+ end
370
+
371
+ def versions
372
+ @record.public_send(@record.class.versions_association_name)
373
+ end
374
+ end
375
+ end