paper_trail 1.4.0 → 17.0.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.
Files changed (70) hide show
  1. checksums.yaml +7 -0
  2. data/lib/generators/paper_trail/install/USAGE +31 -0
  3. data/lib/generators/paper_trail/install/install_generator.rb +101 -0
  4. data/lib/generators/paper_trail/install/templates/add_object_changes_to_versions.rb.erb +12 -0
  5. data/lib/generators/paper_trail/install/templates/create_versions.rb.erb +41 -0
  6. data/lib/generators/paper_trail/migration_generator.rb +65 -0
  7. data/lib/generators/paper_trail/update_item_subtype/USAGE +4 -0
  8. data/lib/generators/paper_trail/update_item_subtype/templates/update_versions_for_item_subtype.rb.erb +86 -0
  9. data/lib/generators/paper_trail/update_item_subtype/update_item_subtype_generator.rb +40 -0
  10. data/lib/paper_trail/attribute_serializers/README.md +10 -0
  11. data/lib/paper_trail/attribute_serializers/attribute_serializer_factory.rb +41 -0
  12. data/lib/paper_trail/attribute_serializers/cast_attribute_serializer.rb +51 -0
  13. data/lib/paper_trail/attribute_serializers/object_attribute.rb +48 -0
  14. data/lib/paper_trail/attribute_serializers/object_changes_attribute.rb +51 -0
  15. data/lib/paper_trail/cleaner.rb +60 -0
  16. data/lib/paper_trail/compatibility.rb +51 -0
  17. data/lib/paper_trail/config.rb +41 -0
  18. data/lib/paper_trail/errors.rb +33 -0
  19. data/lib/paper_trail/events/base.rb +343 -0
  20. data/lib/paper_trail/events/create.rb +32 -0
  21. data/lib/paper_trail/events/destroy.rb +42 -0
  22. data/lib/paper_trail/events/update.rb +76 -0
  23. data/lib/paper_trail/frameworks/active_record/models/paper_trail/version.rb +16 -0
  24. data/lib/paper_trail/frameworks/active_record.rb +12 -0
  25. data/lib/paper_trail/frameworks/cucumber.rb +33 -0
  26. data/lib/paper_trail/frameworks/rails/controller.rb +103 -0
  27. data/lib/paper_trail/frameworks/rails/railtie.rb +34 -0
  28. data/lib/paper_trail/frameworks/rails.rb +3 -0
  29. data/lib/paper_trail/frameworks/rspec/helpers.rb +29 -0
  30. data/lib/paper_trail/frameworks/rspec.rb +42 -0
  31. data/lib/paper_trail/has_paper_trail.rb +79 -82
  32. data/lib/paper_trail/model_config.rb +257 -0
  33. data/lib/paper_trail/queries/versions/where_attribute_changes.rb +50 -0
  34. data/lib/paper_trail/queries/versions/where_object.rb +65 -0
  35. data/lib/paper_trail/queries/versions/where_object_changes.rb +70 -0
  36. data/lib/paper_trail/queries/versions/where_object_changes_from.rb +57 -0
  37. data/lib/paper_trail/queries/versions/where_object_changes_to.rb +57 -0
  38. data/lib/paper_trail/record_history.rb +51 -0
  39. data/lib/paper_trail/record_trail.rb +342 -0
  40. data/lib/paper_trail/reifier.rb +147 -0
  41. data/lib/paper_trail/request.rb +163 -0
  42. data/lib/paper_trail/serializers/json.rb +36 -0
  43. data/lib/paper_trail/serializers/yaml.rb +68 -0
  44. data/lib/paper_trail/type_serializers/postgres_array_serializer.rb +35 -0
  45. data/lib/paper_trail/version_concern.rb +406 -0
  46. data/lib/paper_trail/version_number.rb +23 -0
  47. data/lib/paper_trail.rb +128 -19
  48. metadata +444 -70
  49. data/.gitignore +0 -3
  50. data/README.md +0 -225
  51. data/Rakefile +0 -50
  52. data/VERSION +0 -1
  53. data/generators/paper_trail/USAGE +0 -2
  54. data/generators/paper_trail/paper_trail_generator.rb +0 -9
  55. data/generators/paper_trail/templates/create_versions.rb +0 -18
  56. data/init.rb +0 -1
  57. data/install.rb +0 -1
  58. data/lib/paper_trail/version.rb +0 -59
  59. data/paper_trail.gemspec +0 -67
  60. data/rails/init.rb +0 -1
  61. data/tasks/paper_trail_tasks.rake +0 -0
  62. data/test/database.yml +0 -18
  63. data/test/paper_trail_controller_test.rb +0 -70
  64. data/test/paper_trail_model_test.rb +0 -448
  65. data/test/paper_trail_schema_test.rb +0 -15
  66. data/test/schema.rb +0 -48
  67. data/test/schema_change.rb +0 -3
  68. data/test/test_helper.rb +0 -43
  69. data/uninstall.rb +0 -1
  70. /data/{MIT-LICENSE → LICENSE} +0 -0
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail/attribute_serializers/object_attribute"
4
+
5
+ module PaperTrail
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,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "request_store"
4
+
5
+ module PaperTrail
6
+ # Manages variables that affect the current HTTP request, such as `whodunnit`.
7
+ #
8
+ # Please do not use `PaperTrail::Request` directly, use `PaperTrail.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
+ # Sets any data from the controller that you want PaperTrail to store.
17
+ # See also `PaperTrail::Rails::Controller#info_for_paper_trail`.
18
+ #
19
+ # PaperTrail.request.controller_info = { ip: request_user_ip }
20
+ # PaperTrail.request.controller_info # => { ip: '127.0.0.1' }
21
+ #
22
+ # @api public
23
+ def controller_info=(value)
24
+ store[:controller_info] = value
25
+ end
26
+
27
+ # Returns the data from the controller that you want PaperTrail to store.
28
+ # See also `PaperTrail::Rails::Controller#info_for_paper_trail`.
29
+ #
30
+ # PaperTrail.request.controller_info = { ip: request_user_ip }
31
+ # PaperTrail.request.controller_info # => { ip: '127.0.0.1' }
32
+ #
33
+ # @api public
34
+ def controller_info
35
+ store[:controller_info]
36
+ end
37
+
38
+ # Switches PaperTrail off for the given model.
39
+ # @api public
40
+ def disable_model(model_class)
41
+ enabled_for_model(model_class, false)
42
+ end
43
+
44
+ # Switches PaperTrail on for the given model.
45
+ # @api public
46
+ def enable_model(model_class)
47
+ enabled_for_model(model_class, true)
48
+ end
49
+
50
+ # Sets whether PaperTrail is enabled or disabled for the current request.
51
+ # @api public
52
+ def enabled=(value)
53
+ store[:enabled] = value
54
+ end
55
+
56
+ # Returns `true` if PaperTrail is enabled for the request, `false` otherwise.
57
+ # See `PaperTrail::Rails::Controller#paper_trail_enabled_for_controller`.
58
+ # @api public
59
+ def enabled?
60
+ !!store[:enabled]
61
+ end
62
+
63
+ # Sets whether PaperTrail is enabled or disabled for this model in the
64
+ # current request.
65
+ # @api public
66
+ def enabled_for_model(model, value)
67
+ store[:"enabled_for_#{model}"] = value
68
+ end
69
+
70
+ # Returns `true` if PaperTrail is enabled for this model in the current
71
+ # request, `false` otherwise.
72
+ # @api public
73
+ def enabled_for_model?(model)
74
+ model.include?(::PaperTrail::Model::InstanceMethods) &&
75
+ !!store.fetch(:"enabled_for_#{model}", true)
76
+ end
77
+
78
+ # Temporarily set `options` and execute a block.
79
+ # @api private
80
+ def with(options)
81
+ return unless block_given?
82
+ validate_public_options(options)
83
+ before = to_h
84
+ merge(options)
85
+ yield
86
+ ensure
87
+ set(before)
88
+ end
89
+
90
+ # Sets who is responsible for any changes that occur during request. You
91
+ # would normally use this in a migration or on the console, when working
92
+ # with models directly.
93
+ #
94
+ # `value` is usually a string, the name of a person, but you can set
95
+ # anything that responds to `to_s`. You can also set a Proc, which will
96
+ # not be evaluated until `whodunnit` is called later, usually right before
97
+ # inserting a `Version` record.
98
+ #
99
+ # @api public
100
+ def whodunnit=(value)
101
+ store[:whodunnit] = value
102
+ end
103
+
104
+ # Returns who is reponsible for any changes that occur during request.
105
+ #
106
+ # @api public
107
+ def whodunnit
108
+ who = store[:whodunnit]
109
+ who.respond_to?(:call) ? who.call : who
110
+ end
111
+
112
+ private
113
+
114
+ # @api private
115
+ def merge(options)
116
+ options.to_h.each do |k, v|
117
+ store[k] = v
118
+ end
119
+ end
120
+
121
+ # @api private
122
+ def set(options)
123
+ store.clear
124
+ merge(options)
125
+ end
126
+
127
+ # Returns a Hash, initializing with default values if necessary.
128
+ # @api private
129
+ def store
130
+ RequestStore.store[:paper_trail] ||= {
131
+ enabled: true
132
+ }
133
+ end
134
+
135
+ # Returns a deep copy of the internal hash from our RequestStore. Keys are
136
+ # all symbols. Values are mostly primitives, but whodunnit can be a Proc.
137
+ # We cannot use Marshal.dump here because it doesn't support Proc. It is
138
+ # unclear exactly how `deep_dup` handles a Proc, but it doesn't complain.
139
+ # @api private
140
+ def to_h
141
+ store.deep_dup
142
+ end
143
+
144
+ # Provide a helpful error message if someone has a typo in one of their
145
+ # option keys. We don't validate option values here. That's traditionally
146
+ # been handled with casting (`to_s`, `!!`) in the accessor method.
147
+ # @api private
148
+ def validate_public_options(options)
149
+ options.each do |k, _v|
150
+ case k
151
+ when :controller_info,
152
+ /enabled_for_/,
153
+ :enabled,
154
+ :whodunnit
155
+ next
156
+ else
157
+ raise InvalidOption, "Invalid option: #{k}"
158
+ end
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
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 PaperTrail
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/paper-trail-gem/paper_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 PaperTrail
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