snail_trail 0.0.1 → 1.0.0.rc.pre.1

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 +371 -15
  51. data/lib/snail_trail/version.rb +0 -5
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snail_trail/attribute_serializers/object_attribute"
4
+
5
+ module SnailTrail
6
+ # Given a version record and some options, builds a new model object.
7
+ # @api private
8
+ module Reifier
9
+ class << self
10
+ # See `VersionConcern#reify` for documentation.
11
+ # @api private
12
+ def reify(version, options)
13
+ options = apply_defaults_to(options, version)
14
+ attrs = version.object_deserialized
15
+ model = init_model(attrs, options, version)
16
+ reify_attributes(model, version, attrs)
17
+ model.send :"#{model.class.version_association_name}=", version
18
+ model
19
+ end
20
+
21
+ private
22
+
23
+ # Given a hash of `options` for `.reify`, return a new hash with default
24
+ # values applied.
25
+ # @api private
26
+ def apply_defaults_to(options, version)
27
+ {
28
+ version_at: version.created_at,
29
+ mark_for_destruction: false,
30
+ has_one: false,
31
+ has_many: false,
32
+ belongs_to: false,
33
+ has_and_belongs_to_many: false,
34
+ unversioned_attributes: :nil
35
+ }.merge(options)
36
+ end
37
+
38
+ # Initialize a model object suitable for reifying `version` into. Does
39
+ # not perform reification, merely instantiates the appropriate model
40
+ # class and, if specified by `options[:unversioned_attributes]`, sets
41
+ # unversioned attributes to `nil`.
42
+ #
43
+ # Normally a polymorphic belongs_to relationship allows us to get the
44
+ # object we belong to by calling, in this case, `item`. However this
45
+ # returns nil if `item` has been destroyed, and we need to be able to
46
+ # retrieve destroyed objects.
47
+ #
48
+ # In this situation we constantize the `item_type` to get hold of the
49
+ # class...except when the stored object's attributes include a `type`
50
+ # key. If this is the case, the object we belong to is using single
51
+ # table inheritance (STI) and the `item_type` will be the base class,
52
+ # not the actual subclass. If `type` is present but empty, the class is
53
+ # the base class.
54
+ def init_model(attrs, options, version)
55
+ klass = version_reification_class(version, attrs)
56
+
57
+ # The `dup` option and destroyed version always returns a new object,
58
+ # otherwise we should attempt to load item or to look for the item
59
+ # outside of default scope(s).
60
+ model = if options[:dup] == true || version.event == "destroy"
61
+ klass.new
62
+ else
63
+ version.item || init_model_by_finding_item_id(klass, version) || klass.new
64
+ end
65
+
66
+ if options[:unversioned_attributes] == :nil && !model.new_record?
67
+ init_unversioned_attrs(attrs, model)
68
+ end
69
+
70
+ model
71
+ end
72
+
73
+ # @api private
74
+ def init_model_by_finding_item_id(klass, version)
75
+ klass.unscoped.where(klass.primary_key => version.item_id).first
76
+ end
77
+
78
+ # Look for attributes that exist in `model` and not in this version.
79
+ # These attributes should be set to nil. Modifies `attrs`.
80
+ # @api private
81
+ def init_unversioned_attrs(attrs, model)
82
+ (model.attribute_names - attrs.keys).each { |k| attrs[k] = nil }
83
+ end
84
+
85
+ # Reify onto `model` an attribute named `k` with value `v` from `version`.
86
+ #
87
+ # `ObjectAttribute#deserialize` will return the mapped enum value and in
88
+ # Rails < 5, the []= uses the integer type caster from the column
89
+ # definition (in general) and thus will turn a (usually) string to 0
90
+ # instead of the correct value.
91
+ #
92
+ # @api private
93
+ def reify_attribute(k, v, model, version)
94
+ if model.has_attribute?(k)
95
+ model[k.to_sym] = v
96
+ elsif model.respond_to?(:"#{k}=")
97
+ model.send(:"#{k}=", v)
98
+ elsif version.logger
99
+ version.logger.warn(
100
+ "Attribute #{k} does not exist on #{version.item_type} (Version id: #{version.id})."
101
+ )
102
+ end
103
+ end
104
+
105
+ # Reify onto `model` all the attributes of `version`.
106
+ # @api private
107
+ def reify_attributes(model, version, attrs)
108
+ AttributeSerializers::ObjectAttribute.new(model.class).deserialize(attrs)
109
+ attrs.each do |k, v|
110
+ reify_attribute(k, v, model, version)
111
+ end
112
+ end
113
+
114
+ # Given a `version`, return the class to reify. This method supports
115
+ # Single Table Inheritance (STI) with custom inheritance columns and
116
+ # custom inheritance column values.
117
+ #
118
+ # For example, imagine a `version` whose `item_type` is "Animal". The
119
+ # `animals` table is an STI table (it has cats and dogs) and it has a
120
+ # custom inheritance column, `species`. If `attrs["species"]` is "Dog",
121
+ # this method returns the constant `Dog`. If `attrs["species"]` is blank,
122
+ # this method returns the constant `Animal`.
123
+ #
124
+ # The values contained in the inheritance columns may be non-camelized
125
+ # strings (e.g. 'dog' instead of 'Dog'). To reify classes in this case
126
+ # we need to call the parents class `sti_class_for` method to retrieve
127
+ # the correct record class.
128
+ #
129
+ # You can see these particular examples in action in
130
+ # `spec/models/animal_spec.rb` and `spec/models/plant_spec.rb`
131
+ def version_reification_class(version, attrs)
132
+ clazz = version.item_type.constantize
133
+ inheritance_column_name = clazz.inheritance_column
134
+ inher_col_value = attrs[inheritance_column_name]
135
+ return clazz if inher_col_value.blank?
136
+
137
+ # Rails 6.1 adds a public method for clients to use to customize STI classes. If that
138
+ # method is not available, fall back to using the private one
139
+ if clazz.public_methods.include?(:sti_class_for)
140
+ return clazz.sti_class_for(inher_col_value)
141
+ end
142
+
143
+ clazz.send(:find_sti_class, inher_col_value)
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "request_store"
4
+
5
+ module SnailTrail
6
+ # Manages variables that affect the current HTTP request, such as `whodunnit`.
7
+ #
8
+ # Please do not use `SnailTrail::Request` directly, use `SnailTrail.request`.
9
+ # Currently, `Request` is a `Module`, but in the future it is quite possible
10
+ # we may make it a `Class`. If we make such a choice, we will not provide any
11
+ # warning and will not treat it as a breaking change. You've been warned :)
12
+ #
13
+ # @api private
14
+ module Request
15
+ class << self
16
+ # @api private
17
+ def clear_transaction_id
18
+ self.transaction_id = nil
19
+ end
20
+
21
+ # @api private
22
+ def transaction_id
23
+ store[:transaction_id]
24
+ end
25
+
26
+ # @api private
27
+ def transaction_id=(id)
28
+ store[:transaction_id] = id
29
+ end
30
+
31
+ # Sets any data from the controller that you want SnailTrail to store.
32
+ # See also `SnailTrail::Rails::Controller#info_for_snail_trail`.
33
+ #
34
+ # SnailTrail.request.controller_info = { ip: request_user_ip }
35
+ # SnailTrail.request.controller_info # => { ip: '127.0.0.1' }
36
+ #
37
+ # @api public
38
+ def controller_info=(value)
39
+ store[:controller_info] = value
40
+ end
41
+
42
+ # Returns the data from the controller that you want SnailTrail to store.
43
+ # See also `SnailTrail::Rails::Controller#info_for_snail_trail`.
44
+ #
45
+ # SnailTrail.request.controller_info = { ip: request_user_ip }
46
+ # SnailTrail.request.controller_info # => { ip: '127.0.0.1' }
47
+ #
48
+ # @api public
49
+ def controller_info
50
+ store[:controller_info]
51
+ end
52
+
53
+ # Switches SnailTrail off for the given model.
54
+ # @api public
55
+ def disable_model(model_class)
56
+ enabled_for_model(model_class, false)
57
+ end
58
+
59
+ # Switches SnailTrail on for the given model.
60
+ # @api public
61
+ def enable_model(model_class)
62
+ enabled_for_model(model_class, true)
63
+ end
64
+
65
+ # Sets whether SnailTrail is enabled or disabled for the current request.
66
+ # @api public
67
+ def enabled=(value)
68
+ store[:enabled] = value
69
+ end
70
+
71
+ # Returns `true` if SnailTrail is enabled for the request, `false` otherwise.
72
+ # See `SnailTrail::Rails::Controller#snail_trail_enabled_for_controller`.
73
+ # @api public
74
+ def enabled?
75
+ !!store[:enabled]
76
+ end
77
+
78
+ # Sets whether SnailTrail is enabled or disabled for this model in the
79
+ # current request.
80
+ # @api public
81
+ def enabled_for_model(model, value)
82
+ store[:"enabled_for_#{model}"] = value
83
+ end
84
+
85
+ # Returns `true` if SnailTrail is enabled for this model in the current
86
+ # request, `false` otherwise.
87
+ # @api public
88
+ def enabled_for_model?(model)
89
+ model.include?(::SnailTrail::Model::InstanceMethods) &&
90
+ !!store.fetch(:"enabled_for_#{model}", true)
91
+ end
92
+
93
+ # Temporarily set `options` and execute a block.
94
+ # @api private
95
+ def with(options)
96
+ return unless block_given?
97
+ validate_public_options(options)
98
+ before = to_h
99
+ merge(options)
100
+ yield
101
+ ensure
102
+ set(before)
103
+ end
104
+
105
+ # Sets who is responsible for any changes that occur during request. You
106
+ # would normally use this in a migration or on the console, when working
107
+ # with models directly.
108
+ #
109
+ # `value` is usually a string, the name of a person, but you can set
110
+ # anything that responds to `to_s`. You can also set a Proc, which will
111
+ # not be evaluated until `whodunnit` is called later, usually right before
112
+ # inserting a `Version` record.
113
+ #
114
+ # @api public
115
+ def whodunnit=(value)
116
+ store[:whodunnit] = value
117
+ end
118
+
119
+ # Returns who is reponsible for any changes that occur during request.
120
+ #
121
+ # @api public
122
+ def whodunnit
123
+ who = store[:whodunnit]
124
+ who.respond_to?(:call) ? who.call : who
125
+ end
126
+
127
+ private
128
+
129
+ # @api private
130
+ def merge(options)
131
+ options.to_h.each do |k, v|
132
+ store[k] = v
133
+ end
134
+ end
135
+
136
+ # @api private
137
+ def set(options)
138
+ store.clear
139
+ merge(options)
140
+ end
141
+
142
+ # Returns a Hash, initializing with default values if necessary.
143
+ # @api private
144
+ def store
145
+ RequestStore.store[:snail_trail] ||= {
146
+ enabled: true
147
+ }
148
+ end
149
+
150
+ # Returns a deep copy of the internal hash from our RequestStore. Keys are
151
+ # all symbols. Values are mostly primitives, but whodunnit can be a Proc.
152
+ # We cannot use Marshal.dump here because it doesn't support Proc. It is
153
+ # unclear exactly how `deep_dup` handles a Proc, but it doesn't complain.
154
+ # @api private
155
+ def to_h
156
+ store.deep_dup
157
+ end
158
+
159
+ # Provide a helpful error message if someone has a typo in one of their
160
+ # option keys. We don't validate option values here. That's traditionally
161
+ # been handled with casting (`to_s`, `!!`) in the accessor method.
162
+ # @api private
163
+ def validate_public_options(options)
164
+ options.each do |k, _v|
165
+ case k
166
+ when :controller_info,
167
+ /enabled_for_/,
168
+ :enabled,
169
+ :whodunnit
170
+ next
171
+ when :transaction_id
172
+ raise ::SnailTrail::Request::InvalidOption, "Cannot set private option: transaction_id"
173
+ else
174
+ raise InvalidOption, "Invalid option: #{k}"
175
+ end
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ module Serializers
5
+ # An alternate serializer for, e.g. `versions.object`.
6
+ module JSON
7
+ extend self # makes all instance methods become module methods as well
8
+
9
+ def load(string)
10
+ ActiveSupport::JSON.decode string
11
+ end
12
+
13
+ def dump(object)
14
+ ActiveSupport::JSON.encode object
15
+ end
16
+
17
+ # Returns a SQL LIKE condition to be used to match the given field and
18
+ # value in the serialized object.
19
+ def where_object_condition(arel_field, field, value)
20
+ # Convert to JSON to handle strings and nulls correctly.
21
+ json_value = value.to_json
22
+
23
+ # If the value is a number, we need to ensure that we find the next
24
+ # character too, which is either `,` or `}`, to ensure that searching
25
+ # for the value 12 doesn't yield false positives when the value is
26
+ # 123.
27
+ if value.is_a? Numeric
28
+ arel_field.matches("%\"#{field}\":#{json_value},%").
29
+ or(arel_field.matches("%\"#{field}\":#{json_value}}%"))
30
+ else
31
+ arel_field.matches("%\"#{field}\":#{json_value}%")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module SnailTrail
6
+ module Serializers
7
+ # The default serializer for, e.g. `versions.object`.
8
+ module YAML
9
+ extend self # makes all instance methods become module methods as well
10
+
11
+ def load(string)
12
+ if use_safe_load?
13
+ ::YAML.safe_load(
14
+ string,
15
+ permitted_classes: yaml_column_permitted_classes,
16
+ aliases: true
17
+ )
18
+ elsif ::YAML.respond_to?(:unsafe_load)
19
+ ::YAML.unsafe_load(string)
20
+ else
21
+ ::YAML.load(string)
22
+ end
23
+ end
24
+
25
+ # @param object (Hash | HashWithIndifferentAccess) - Coming from
26
+ # `recordable_object` `object` will be a plain `Hash`. However, due to
27
+ # recent [memory optimizations](https://github.com/BrandsInsurance/snail_trail/pull/1189),
28
+ # when coming from `recordable_object_changes`, it will be a `HashWithIndifferentAccess`.
29
+ def dump(object)
30
+ object = object.to_hash if object.is_a?(ActiveSupport::HashWithIndifferentAccess)
31
+ ::YAML.dump object
32
+ end
33
+
34
+ # Returns a SQL LIKE condition to be used to match the given field and
35
+ # value in the serialized object.
36
+ def where_object_condition(arel_field, field, value)
37
+ arel_field.matches("%\n#{field}: #{value}\n%")
38
+ end
39
+
40
+ private
41
+
42
+ def use_safe_load?
43
+ if ::ActiveRecord.gem_version >= Gem::Version.new("7.0.3.1")
44
+ # `use_yaml_unsafe_load` may be removed in the future, at which point
45
+ # safe loading will be the default.
46
+ !defined?(ActiveRecord.use_yaml_unsafe_load) || !ActiveRecord.use_yaml_unsafe_load
47
+ elsif defined?(ActiveRecord::Base.use_yaml_unsafe_load)
48
+ # Rails 5.2.8.1, 6.0.5.1, 6.1.6.1
49
+ !ActiveRecord::Base.use_yaml_unsafe_load
50
+ else
51
+ false
52
+ end
53
+ end
54
+
55
+ def yaml_column_permitted_classes
56
+ if defined?(ActiveRecord.yaml_column_permitted_classes)
57
+ # Rails >= 7.0.3.1
58
+ ActiveRecord.yaml_column_permitted_classes
59
+ elsif defined?(ActiveRecord::Base.yaml_column_permitted_classes)
60
+ # Rails 5.2.8.1, 6.0.5.1, 6.1.6.1
61
+ ActiveRecord::Base.yaml_column_permitted_classes
62
+ else
63
+ []
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ module TypeSerializers
5
+ # Provides an alternative method of serialization
6
+ # and deserialization of PostgreSQL array columns.
7
+ class PostgresArraySerializer
8
+ def initialize(subtype, delimiter)
9
+ @subtype = subtype
10
+ @delimiter = delimiter
11
+ end
12
+
13
+ def serialize(array)
14
+ array
15
+ end
16
+
17
+ def deserialize(array)
18
+ case array
19
+ # Needed for legacy data. If serialized array is a string
20
+ # then it was serialized with Rails < 5.0.2.
21
+ when ::String then deserialize_with_ar(array)
22
+ else array
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def deserialize_with_ar(array)
29
+ ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array.
30
+ new(@subtype, @delimiter).
31
+ deserialize(array)
32
+ end
33
+ end
34
+ end
35
+ end