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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/lib/generators/snail_trail/install/USAGE +3 -0
- data/lib/generators/snail_trail/install/install_generator.rb +108 -0
- data/lib/generators/snail_trail/install/templates/add_object_changes_to_versions.rb.erb +12 -0
- data/lib/generators/snail_trail/install/templates/add_transaction_id_column_to_versions.rb.erb +12 -0
- data/lib/generators/snail_trail/install/templates/create_versions.rb.erb +41 -0
- data/lib/generators/snail_trail/migration_generator.rb +38 -0
- data/lib/generators/snail_trail/update_item_subtype/USAGE +4 -0
- data/lib/generators/snail_trail/update_item_subtype/templates/update_versions_for_item_subtype.rb.erb +85 -0
- data/lib/generators/snail_trail/update_item_subtype/update_item_subtype_generator.rb +19 -0
- data/lib/snail_trail/attribute_serializers/README.md +10 -0
- data/lib/snail_trail/attribute_serializers/attribute_serializer_factory.rb +41 -0
- data/lib/snail_trail/attribute_serializers/cast_attribute_serializer.rb +51 -0
- data/lib/snail_trail/attribute_serializers/object_attribute.rb +51 -0
- data/lib/snail_trail/attribute_serializers/object_changes_attribute.rb +54 -0
- data/lib/snail_trail/cleaner.rb +60 -0
- data/lib/snail_trail/compatibility.rb +51 -0
- data/lib/snail_trail/config.rb +40 -0
- data/lib/snail_trail/errors.rb +33 -0
- data/lib/snail_trail/events/base.rb +343 -0
- data/lib/snail_trail/events/create.rb +32 -0
- data/lib/snail_trail/events/destroy.rb +42 -0
- data/lib/snail_trail/events/update.rb +76 -0
- data/lib/snail_trail/frameworks/active_record/models/snail_trail/version.rb +16 -0
- data/lib/snail_trail/frameworks/active_record.rb +12 -0
- data/lib/snail_trail/frameworks/cucumber.rb +33 -0
- data/lib/snail_trail/frameworks/rails/controller.rb +103 -0
- data/lib/snail_trail/frameworks/rails/railtie.rb +34 -0
- data/lib/snail_trail/frameworks/rails.rb +3 -0
- data/lib/snail_trail/frameworks/rspec/helpers.rb +29 -0
- data/lib/snail_trail/frameworks/rspec.rb +42 -0
- data/lib/snail_trail/has_snail_trail.rb +92 -0
- data/lib/snail_trail/model_config.rb +265 -0
- data/lib/snail_trail/queries/versions/where_attribute_changes.rb +50 -0
- data/lib/snail_trail/queries/versions/where_object.rb +65 -0
- data/lib/snail_trail/queries/versions/where_object_changes.rb +70 -0
- data/lib/snail_trail/queries/versions/where_object_changes_from.rb +57 -0
- data/lib/snail_trail/queries/versions/where_object_changes_to.rb +57 -0
- data/lib/snail_trail/record_history.rb +51 -0
- data/lib/snail_trail/record_trail.rb +375 -0
- data/lib/snail_trail/reifier.rb +147 -0
- data/lib/snail_trail/request.rb +180 -0
- data/lib/snail_trail/serializers/json.rb +36 -0
- data/lib/snail_trail/serializers/yaml.rb +68 -0
- data/lib/snail_trail/type_serializers/postgres_array_serializer.rb +35 -0
- data/lib/snail_trail/version_concern.rb +407 -0
- data/lib/snail_trail/version_number.rb +23 -0
- data/lib/snail_trail.rb +141 -1
- metadata +369 -13
- data/lib/snail_trail/version.rb +0 -5
@@ -0,0 +1,40 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "singleton"
|
4
|
+
require "snail_trail/serializers/yaml"
|
5
|
+
|
6
|
+
module SnailTrail
|
7
|
+
# Global configuration affecting all threads. Some thread-specific
|
8
|
+
# configuration can be found in `snail_trail.rb`, others in `controller.rb`.
|
9
|
+
class Config
|
10
|
+
include Singleton
|
11
|
+
|
12
|
+
attr_accessor(
|
13
|
+
:object_changes_adapter,
|
14
|
+
:serializer,
|
15
|
+
:version_limit,
|
16
|
+
:has_snail_trail_defaults,
|
17
|
+
:version_error_behavior
|
18
|
+
)
|
19
|
+
|
20
|
+
def initialize
|
21
|
+
# Variables which affect all threads, whose access is synchronized.
|
22
|
+
@mutex = Mutex.new
|
23
|
+
@enabled = true
|
24
|
+
|
25
|
+
# Variables which affect all threads, whose access is *not* synchronized.
|
26
|
+
@serializer = SnailTrail::Serializers::YAML
|
27
|
+
@has_snail_trail_defaults = {}
|
28
|
+
@version_error_behavior = :legacy
|
29
|
+
end
|
30
|
+
|
31
|
+
# Indicates whether SnailTrail is on or off. Default: true.
|
32
|
+
def enabled
|
33
|
+
@mutex.synchronize { !!@enabled }
|
34
|
+
end
|
35
|
+
|
36
|
+
def enabled=(enable)
|
37
|
+
@mutex.synchronize { @enabled = enable }
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnailTrail
|
4
|
+
# Generic SnailTrail exception.
|
5
|
+
# @api public
|
6
|
+
class Error < StandardError
|
7
|
+
end
|
8
|
+
|
9
|
+
# An unexpected option, perhaps a typo, was passed to a public API method.
|
10
|
+
# @api public
|
11
|
+
class InvalidOption < Error
|
12
|
+
end
|
13
|
+
|
14
|
+
# The application's database schema is not supported.
|
15
|
+
# @api public
|
16
|
+
class UnsupportedSchema < Error
|
17
|
+
end
|
18
|
+
|
19
|
+
# The application's database column type is not supported.
|
20
|
+
# @api public
|
21
|
+
class UnsupportedColumnType < UnsupportedSchema
|
22
|
+
def initialize(method:, expected:, actual:)
|
23
|
+
super(
|
24
|
+
format(
|
25
|
+
"%s expected %s column, got %s",
|
26
|
+
method,
|
27
|
+
expected,
|
28
|
+
actual
|
29
|
+
)
|
30
|
+
)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,343 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module SnailTrail
|
4
|
+
module Events
|
5
|
+
# We refer to times in the lifecycle of a record as "events". There are
|
6
|
+
# three events:
|
7
|
+
#
|
8
|
+
# - create
|
9
|
+
# - `after_create` we call `RecordTrail#record_create`
|
10
|
+
# - update
|
11
|
+
# - `after_update` we call `RecordTrail#record_update`
|
12
|
+
# - `after_touch` we call `RecordTrail#record_update`
|
13
|
+
# - `RecordTrail#save_with_version` calls `RecordTrail#record_update`
|
14
|
+
# - `RecordTrail#update_columns` is also referred to as an update, though
|
15
|
+
# it uses `RecordTrail#record_update_columns` rather than
|
16
|
+
# `RecordTrail#record_update`
|
17
|
+
# - destroy
|
18
|
+
# - `before_destroy` or `after_destroy` we call `RecordTrail#record_destroy`
|
19
|
+
#
|
20
|
+
# The value inserted into the `event` column of the versions table can also
|
21
|
+
# be overridden by the user, with `snail_trail_event`.
|
22
|
+
#
|
23
|
+
# @api private
|
24
|
+
class Base
|
25
|
+
E_FORBIDDEN_METADATA_KEY = <<-EOS.squish
|
26
|
+
Forbidden metadata key: %s. As of ST 14, the following metadata keys are
|
27
|
+
forbidden: %s
|
28
|
+
EOS
|
29
|
+
FORBIDDEN_METADATA_KEYS = %i[
|
30
|
+
created_at
|
31
|
+
id
|
32
|
+
item_id
|
33
|
+
item_subtype
|
34
|
+
item_type
|
35
|
+
updated_at
|
36
|
+
].freeze
|
37
|
+
|
38
|
+
# @api private
|
39
|
+
def initialize(record, in_after_callback)
|
40
|
+
@record = record
|
41
|
+
@in_after_callback = in_after_callback
|
42
|
+
end
|
43
|
+
|
44
|
+
# Determines whether it is appropriate to generate a new version
|
45
|
+
# instance. A timestamp-only update (e.g. only `updated_at` changed) is
|
46
|
+
# considered notable unless an ignored attribute was also changed.
|
47
|
+
#
|
48
|
+
# @api private
|
49
|
+
def changed_notably?
|
50
|
+
if ignored_attr_has_changed?
|
51
|
+
timestamps = @record.send(:timestamp_attributes_for_update_in_model).map(&:to_s)
|
52
|
+
(notably_changed - timestamps).any?
|
53
|
+
else
|
54
|
+
notably_changed.any?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
# @api private
|
61
|
+
def assert_metadatum_key_is_permitted(key)
|
62
|
+
return unless FORBIDDEN_METADATA_KEYS.include?(key.to_sym)
|
63
|
+
raise SnailTrail::InvalidOption,
|
64
|
+
format(E_FORBIDDEN_METADATA_KEY, key, FORBIDDEN_METADATA_KEYS)
|
65
|
+
end
|
66
|
+
|
67
|
+
# Rails 5.1 changed the API of `ActiveRecord::Dirty`. See
|
68
|
+
# https://github.com/BrandsInsurance/snail_trail/pull/899
|
69
|
+
#
|
70
|
+
# @api private
|
71
|
+
def attribute_changed_in_latest_version?(attr_name)
|
72
|
+
if @in_after_callback
|
73
|
+
@record.saved_change_to_attribute?(attr_name.to_s)
|
74
|
+
else
|
75
|
+
@record.attribute_changed?(attr_name.to_s)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# @api private
|
80
|
+
def nonskipped_attributes_before_change(is_touch)
|
81
|
+
record_attributes = @record.attributes.except(*@record.snail_trail_options[:skip])
|
82
|
+
record_attributes.each_key do |k|
|
83
|
+
if @record.class.column_names.include?(k)
|
84
|
+
record_attributes[k] = attribute_in_previous_version(k, is_touch)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# Rails 5.1 changed the API of `ActiveRecord::Dirty`. See
|
90
|
+
# https://github.com/BrandsInsurance/snail_trail/pull/899
|
91
|
+
#
|
92
|
+
# Event can be any of the three (create, update, destroy).
|
93
|
+
#
|
94
|
+
# @api private
|
95
|
+
def attribute_in_previous_version(attr_name, is_touch)
|
96
|
+
if @in_after_callback && !is_touch
|
97
|
+
# For most events, we want the original value of the attribute, before
|
98
|
+
# the last save.
|
99
|
+
@record.attribute_before_last_save(attr_name.to_s)
|
100
|
+
else
|
101
|
+
# We are either performing a `record_destroy` or a
|
102
|
+
# `record_update(is_touch: true)`.
|
103
|
+
@record.attribute_in_database(attr_name.to_s)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# @api private
|
108
|
+
def calculated_ignored_array
|
109
|
+
ignore = @record.snail_trail_options[:ignore].dup
|
110
|
+
# Remove Hash arguments and then evaluate whether the attributes (the
|
111
|
+
# keys of the hash) should also get pushed into the collection.
|
112
|
+
ignore.delete_if do |obj|
|
113
|
+
obj.is_a?(Hash) &&
|
114
|
+
obj.each { |attr, condition|
|
115
|
+
ignore << attr if condition.respond_to?(:call) && condition.call(@record)
|
116
|
+
}
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
# @api private
|
121
|
+
def changed_and_not_ignored
|
122
|
+
skip = @record.snail_trail_options[:skip]
|
123
|
+
(changed_in_latest_version - calculated_ignored_array) - skip
|
124
|
+
end
|
125
|
+
|
126
|
+
# @api private
|
127
|
+
def changed_in_latest_version
|
128
|
+
# Memoized to reduce memory usage
|
129
|
+
@changed_in_latest_version ||= changes_in_latest_version.keys
|
130
|
+
end
|
131
|
+
|
132
|
+
# Memoized to reduce memory usage
|
133
|
+
#
|
134
|
+
# @api private
|
135
|
+
def changes_in_latest_version
|
136
|
+
@changes_in_latest_version ||= load_changes_in_latest_version
|
137
|
+
end
|
138
|
+
|
139
|
+
# @api private
|
140
|
+
def evaluate_only
|
141
|
+
only = @record.snail_trail_options[:only].dup
|
142
|
+
# Remove Hash arguments and then evaluate whether the attributes (the
|
143
|
+
# keys of the hash) should also get pushed into the collection.
|
144
|
+
only.delete_if do |obj|
|
145
|
+
obj.is_a?(Hash) &&
|
146
|
+
obj.each { |attr, condition|
|
147
|
+
only << attr if condition.respond_to?(:call) && condition.call(@record)
|
148
|
+
}
|
149
|
+
end
|
150
|
+
only
|
151
|
+
end
|
152
|
+
|
153
|
+
# An attributed is "ignored" if it is listed in the `:ignore` option
|
154
|
+
# and/or the `:skip` option. Returns true if an ignored attribute has
|
155
|
+
# changed.
|
156
|
+
#
|
157
|
+
# @api private
|
158
|
+
def ignored_attr_has_changed?
|
159
|
+
ignored = calculated_ignored_array + @record.snail_trail_options[:skip]
|
160
|
+
ignored.any? && changed_in_latest_version.intersect?(ignored)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Rails 5.1 changed the API of `ActiveRecord::Dirty`. See
|
164
|
+
# https://github.com/BrandsInsurance/snail_trail/pull/899
|
165
|
+
#
|
166
|
+
# @api private
|
167
|
+
def load_changes_in_latest_version
|
168
|
+
if @in_after_callback
|
169
|
+
@record.saved_changes
|
170
|
+
else
|
171
|
+
@record.changes
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
# ST 10 has a new optional column, `item_subtype`
|
176
|
+
#
|
177
|
+
# @api private
|
178
|
+
def merge_item_subtype_into(data)
|
179
|
+
if @record.class.snail_trail.version_class.columns_hash.key?("item_subtype")
|
180
|
+
data.merge!(item_subtype: @record.class.name)
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
# Updates `data` from the model's `meta` option and from `controller_info`.
|
185
|
+
# Metadata is always recorded; that means all three events (create, update,
|
186
|
+
# destroy) and `update_columns`.
|
187
|
+
#
|
188
|
+
# @api private
|
189
|
+
def merge_metadata_into(data)
|
190
|
+
merge_metadata_from_model_into(data)
|
191
|
+
merge_metadata_from_controller_into(data)
|
192
|
+
end
|
193
|
+
|
194
|
+
# Updates `data` from `controller_info`.
|
195
|
+
#
|
196
|
+
# @api private
|
197
|
+
def merge_metadata_from_controller_into(data)
|
198
|
+
metadata = SnailTrail.request.controller_info || {}
|
199
|
+
metadata.keys.each { |k| assert_metadatum_key_is_permitted(k) }
|
200
|
+
data.merge(metadata)
|
201
|
+
end
|
202
|
+
|
203
|
+
# Updates `data` from the model's `meta` option.
|
204
|
+
#
|
205
|
+
# @api private
|
206
|
+
def merge_metadata_from_model_into(data)
|
207
|
+
@record.snail_trail_options[:meta].each do |k, v|
|
208
|
+
assert_metadatum_key_is_permitted(k)
|
209
|
+
data[k] = model_metadatum(v, data[:event])
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
# Given a `value` from the model's `meta` option, returns an object to be
|
214
|
+
# persisted. The `value` can be a simple scalar value, but it can also
|
215
|
+
# be a symbol that names a model method, or even a Proc.
|
216
|
+
#
|
217
|
+
# @api private
|
218
|
+
def model_metadatum(value, event)
|
219
|
+
if value.respond_to?(:call)
|
220
|
+
value.call(@record)
|
221
|
+
elsif value.is_a?(Symbol) && @record.respond_to?(value, true)
|
222
|
+
metadatum_from_model_method(event, value)
|
223
|
+
else
|
224
|
+
value
|
225
|
+
end
|
226
|
+
end
|
227
|
+
|
228
|
+
# The model method can either be an attribute or a non-attribute method.
|
229
|
+
#
|
230
|
+
# If it is an attribute that is changing in an existing object,
|
231
|
+
# be sure to grab the correct version.
|
232
|
+
#
|
233
|
+
# @api private
|
234
|
+
def metadatum_from_model_method(event, method)
|
235
|
+
if event != "create" &&
|
236
|
+
@record.has_attribute?(method) &&
|
237
|
+
attribute_changed_in_latest_version?(method)
|
238
|
+
attribute_in_previous_version(method, false)
|
239
|
+
else
|
240
|
+
@record.send(method)
|
241
|
+
end
|
242
|
+
end
|
243
|
+
|
244
|
+
# @api private
|
245
|
+
def notable_changes
|
246
|
+
changes_in_latest_version.delete_if { |k, _v|
|
247
|
+
notably_changed.exclude?(k)
|
248
|
+
}
|
249
|
+
end
|
250
|
+
|
251
|
+
# @api private
|
252
|
+
def notably_changed
|
253
|
+
# Memoized to reduce memory usage
|
254
|
+
@notably_changed ||= begin
|
255
|
+
only = evaluate_only
|
256
|
+
cani = changed_and_not_ignored
|
257
|
+
only.empty? ? cani : (cani & only)
|
258
|
+
end
|
259
|
+
end
|
260
|
+
|
261
|
+
# Returns hash of attributes (with appropriate attributes serialized),
|
262
|
+
# omitting attributes to be skipped.
|
263
|
+
#
|
264
|
+
# @api private
|
265
|
+
def object_attrs_for_snail_trail(is_touch)
|
266
|
+
attrs = nonskipped_attributes_before_change(is_touch)
|
267
|
+
AttributeSerializers::ObjectAttribute.new(@record.class).serialize(attrs)
|
268
|
+
attrs
|
269
|
+
end
|
270
|
+
|
271
|
+
# @api private
|
272
|
+
def prepare_object_changes(changes)
|
273
|
+
changes = serialize_object_changes(changes)
|
274
|
+
recordable_object_changes(changes)
|
275
|
+
end
|
276
|
+
|
277
|
+
# Returns an object which can be assigned to the `object_changes`
|
278
|
+
# attribute of a nascent version record. If the `object_changes` column is
|
279
|
+
# a postgres `json` column, then a hash can be used in the assignment,
|
280
|
+
# otherwise the column is a `text` column, and we must perform the
|
281
|
+
# serialization here, using `SnailTrail.serializer`.
|
282
|
+
#
|
283
|
+
# @api private
|
284
|
+
# @param changes HashWithIndifferentAccess
|
285
|
+
def recordable_object_changes(changes)
|
286
|
+
if SnailTrail.config.object_changes_adapter.respond_to?(:diff)
|
287
|
+
# We'd like to avoid the `to_hash` here, because it increases memory
|
288
|
+
# usage, but that would be a breaking change because
|
289
|
+
# `object_changes_adapter` expects a plain `Hash`, not a
|
290
|
+
# `HashWithIndifferentAccess`.
|
291
|
+
changes = SnailTrail.config.object_changes_adapter.diff(changes.to_hash)
|
292
|
+
end
|
293
|
+
|
294
|
+
if @record.class.snail_trail.version_class.object_changes_col_is_json?
|
295
|
+
changes
|
296
|
+
else
|
297
|
+
SnailTrail.serializer.dump(changes)
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
# Returns a boolean indicating whether to store serialized version diffs
|
302
|
+
# in the `object_changes` column of the version record.
|
303
|
+
#
|
304
|
+
# @api private
|
305
|
+
def record_object_changes?
|
306
|
+
@record.class.snail_trail.version_class.column_names.include?("object_changes")
|
307
|
+
end
|
308
|
+
|
309
|
+
# Returns a boolean indicating whether to store the original object during save.
|
310
|
+
#
|
311
|
+
# @api private
|
312
|
+
def record_object?
|
313
|
+
@record.class.snail_trail.version_class.column_names.include?("object")
|
314
|
+
end
|
315
|
+
|
316
|
+
# Returns an object which can be assigned to the `object` attribute of a
|
317
|
+
# nascent version record. If the `object` column is a postgres `json`
|
318
|
+
# column, then a hash can be used in the assignment, otherwise the column
|
319
|
+
# is a `text` column, and we must perform the serialization here, using
|
320
|
+
# `SnailTrail.serializer`.
|
321
|
+
#
|
322
|
+
# @api private
|
323
|
+
def recordable_object(is_touch)
|
324
|
+
if @record.class.snail_trail.version_class.object_col_is_json?
|
325
|
+
object_attrs_for_snail_trail(is_touch)
|
326
|
+
else
|
327
|
+
SnailTrail.serializer.dump(object_attrs_for_snail_trail(is_touch))
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
# @api private
|
332
|
+
def serialize_object_changes(changes)
|
333
|
+
AttributeSerializers::ObjectChangesAttribute.
|
334
|
+
new(@record.class).
|
335
|
+
serialize(changes)
|
336
|
+
|
337
|
+
# We'd like to convert this `HashWithIndifferentAccess` to a plain
|
338
|
+
# `Hash`, but we don't, to save memory.
|
339
|
+
changes
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
343
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "snail_trail/events/base"
|
4
|
+
|
5
|
+
module SnailTrail
|
6
|
+
module Events
|
7
|
+
# See docs in `Base`.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class Create < Base
|
11
|
+
# Return attributes of nascent `Version` record.
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
def data
|
15
|
+
data = {
|
16
|
+
item: @record,
|
17
|
+
event: @record.snail_trail_event || "create",
|
18
|
+
whodunnit: SnailTrail.request.whodunnit
|
19
|
+
}
|
20
|
+
if @record.respond_to?(:updated_at)
|
21
|
+
data[:created_at] = @record.updated_at
|
22
|
+
end
|
23
|
+
if record_object_changes? && changed_notably?
|
24
|
+
changes = notable_changes
|
25
|
+
data[:object_changes] = prepare_object_changes(changes)
|
26
|
+
end
|
27
|
+
merge_item_subtype_into(data)
|
28
|
+
merge_metadata_into(data)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "snail_trail/events/base"
|
4
|
+
|
5
|
+
module SnailTrail
|
6
|
+
module Events
|
7
|
+
# See docs in `Base`.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class Destroy < Base
|
11
|
+
# Return attributes of nascent `Version` record.
|
12
|
+
#
|
13
|
+
# @api private
|
14
|
+
def data
|
15
|
+
data = {
|
16
|
+
item_id: @record.id,
|
17
|
+
item_type: @record.class.base_class.name,
|
18
|
+
event: @record.snail_trail_event || "destroy",
|
19
|
+
whodunnit: SnailTrail.request.whodunnit
|
20
|
+
}
|
21
|
+
if record_object?
|
22
|
+
data[:object] = recordable_object(false)
|
23
|
+
end
|
24
|
+
if record_object_changes?
|
25
|
+
data[:object_changes] = prepare_object_changes(notable_changes)
|
26
|
+
end
|
27
|
+
merge_item_subtype_into(data)
|
28
|
+
merge_metadata_into(data)
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
# Rails' implementation (eg. `@record.saved_changes`) returns nothing on
|
34
|
+
# destroy, so we have to build the hash we want.
|
35
|
+
#
|
36
|
+
# @override
|
37
|
+
def changes_in_latest_version
|
38
|
+
@record.attributes.transform_values { |value| [value, nil] }
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "snail_trail/events/base"
|
4
|
+
|
5
|
+
module SnailTrail
|
6
|
+
module Events
|
7
|
+
# See docs in `Base`.
|
8
|
+
#
|
9
|
+
# @api private
|
10
|
+
class Update < Base
|
11
|
+
# - is_touch - [boolean] - Used in the two situations that are touch-like:
|
12
|
+
# - `after_touch` we call `RecordTrail#record_update`
|
13
|
+
# - force_changes - [Hash] - Only used by `RecordTrail#update_columns`,
|
14
|
+
# because there dirty-tracking is off, so it has to track its own changes.
|
15
|
+
#
|
16
|
+
# @api private
|
17
|
+
def initialize(record, in_after_callback, is_touch, force_changes)
|
18
|
+
super(record, in_after_callback)
|
19
|
+
@is_touch = is_touch
|
20
|
+
@force_changes = force_changes
|
21
|
+
end
|
22
|
+
|
23
|
+
# Return attributes of nascent `Version` record.
|
24
|
+
#
|
25
|
+
# @api private
|
26
|
+
def data
|
27
|
+
data = {
|
28
|
+
item: @record,
|
29
|
+
event: @record.snail_trail_event || "update",
|
30
|
+
whodunnit: SnailTrail.request.whodunnit
|
31
|
+
}
|
32
|
+
if record_object?
|
33
|
+
data[:object] = recordable_object(@is_touch)
|
34
|
+
end
|
35
|
+
merge_object_changes_into(data)
|
36
|
+
merge_item_subtype_into(data)
|
37
|
+
merge_metadata_into(data)
|
38
|
+
end
|
39
|
+
|
40
|
+
# If it is a touch event, and changed are empty, it is assumed to be
|
41
|
+
# implicit `touch` mutation, and will a version is created.
|
42
|
+
#
|
43
|
+
# See https://github.com/rails/rails/commit/dcb825902d79d0f6baba956f7c6ec5767611353e
|
44
|
+
#
|
45
|
+
# @api private
|
46
|
+
def changed_notably?
|
47
|
+
if @is_touch && changes_in_latest_version.empty?
|
48
|
+
true
|
49
|
+
else
|
50
|
+
super
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
private
|
55
|
+
|
56
|
+
# @api private
|
57
|
+
def merge_object_changes_into(data)
|
58
|
+
if record_object_changes?
|
59
|
+
changes = @force_changes.nil? ? notable_changes : @force_changes
|
60
|
+
data[:object_changes] = prepare_object_changes(changes)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
# `touch` cannot record `object_changes` because rails' `touch` does not
|
65
|
+
# perform dirty-tracking. Specifically, methods from `Dirty`, like
|
66
|
+
# `saved_changes`, return the same values before and after `touch`.
|
67
|
+
#
|
68
|
+
# See https://github.com/rails/rails/issues/33429
|
69
|
+
#
|
70
|
+
# @api private
|
71
|
+
def record_object_changes?
|
72
|
+
!@is_touch && super
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "snail_trail/version_concern"
|
4
|
+
|
5
|
+
module SnailTrail
|
6
|
+
# This is the default ActiveRecord model provided by SnailTrail. Most simple
|
7
|
+
# applications will use this model as-is, but it is possible to sub-class,
|
8
|
+
# extend, or even do without this model entirely. See documentation section
|
9
|
+
# 6.a. Custom Version Classes.
|
10
|
+
#
|
11
|
+
# The snail_trail-association_tracking gem provides a related model,
|
12
|
+
# `VersionAssociation`.
|
13
|
+
class Version < ::ActiveRecord::Base
|
14
|
+
include SnailTrail::VersionConcern
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Either ActiveRecord has already been loaded by the Lazy Load Hook in our
|
4
|
+
# Railtie, or else we load it now.
|
5
|
+
require "active_record"
|
6
|
+
SnailTrail::Compatibility.check_activerecord(ActiveRecord.gem_version)
|
7
|
+
|
8
|
+
# Now we can load the parts of ST that depend on AR.
|
9
|
+
require "snail_trail/has_snail_trail"
|
10
|
+
require "snail_trail/reifier"
|
11
|
+
require "snail_trail/frameworks/active_record/models/snail_trail/version"
|
12
|
+
ActiveRecord::Base.include SnailTrail::Model
|
@@ -0,0 +1,33 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# before hook for Cucumber
|
4
|
+
Before do
|
5
|
+
SnailTrail.enabled = false
|
6
|
+
SnailTrail.request.enabled = true
|
7
|
+
SnailTrail.request.whodunnit = nil
|
8
|
+
SnailTrail.request.controller_info = {} if defined?(Rails)
|
9
|
+
end
|
10
|
+
|
11
|
+
module SnailTrail
|
12
|
+
module Cucumber
|
13
|
+
# Helper method for enabling ST in Cucumber features.
|
14
|
+
module Extensions
|
15
|
+
# :call-seq:
|
16
|
+
# with_versioning
|
17
|
+
#
|
18
|
+
# enable versioning for specific blocks
|
19
|
+
|
20
|
+
def with_versioning
|
21
|
+
was_enabled = ::SnailTrail.enabled?
|
22
|
+
::SnailTrail.enabled = true
|
23
|
+
begin
|
24
|
+
yield
|
25
|
+
ensure
|
26
|
+
::SnailTrail.enabled = was_enabled
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
World SnailTrail::Cucumber::Extensions
|