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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ce4b87054886d17b9dea51dfcf6e9ddc948eaf331417b8dc5b992793e177a670
4
+ data.tar.gz: 32f2ff0f24978fe54e08a65dfe8c83ec023eaaf9ab676c0f791b398907a8fc80
5
+ SHA512:
6
+ metadata.gz: 772518531867cafb80017ebbb42837412fc67b80ec9416cfc9269595084e266f312609115171868b4124747769f1aba03fd5ee1b932691b52128aaec6fa2c583
7
+ data.tar.gz: 3b28ed9bbbf1fef2eac6d106c5fedcb1566b88d7877782e115130f4f8386f5e6bf60f4123edc1518e41478b918fac9c371c98fea5e0b49745fcd384cffa85baa
@@ -0,0 +1,31 @@
1
+ Description:
2
+ Generates (but does not run) a migration to add a versions table. Also generates an initializer
3
+ file for configuring PaperTrail. Can be customized by providing a Version class name.
4
+ See section 5.c. Generators in README.md for more information.
5
+
6
+ Examples:
7
+ rails generate paper_trail:install
8
+
9
+ This will create:
10
+ db/migrate/[TIMESTAMP]_create_versions.rb
11
+ config/initializers/paper_trail.rb
12
+
13
+ rails generate paper_trail:install --with-changes
14
+
15
+ This will create:
16
+ db/migrate/[TIMESTAMP]_create_versions.rb
17
+ db/migrate/[TIMESTAMP]_add_object_changes_to_versions.rb
18
+ config/initializers/paper_trail.rb
19
+
20
+ rails generate paper_trail:install CommentVersion
21
+
22
+ This will create:
23
+ db/migrate/[TIMESTAMP]_create_comment_versions.rb
24
+ config/initializers/paper_trail.rb
25
+
26
+ rails generate paper_trail:install ProductVersion --with-changes --uuid
27
+
28
+ This will create:
29
+ db/migrate/[TIMESTAMP]_create_product_versions.rb
30
+ db/migrate/[TIMESTAMP]_add_object_changes_to_product_versions.rb
31
+ config/initializers/paper_trail.rb
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migration_generator"
4
+
5
+ module PaperTrail
6
+ # Installs PaperTrail in a rails app.
7
+ class InstallGenerator < MigrationGenerator
8
+ # Class names of MySQL adapters.
9
+ # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`.
10
+ # - `Mysql2Adapter` - Used by `mysql2` gem.
11
+ MYSQL_ADAPTERS = [
12
+ "ActiveRecord::ConnectionAdapters::MysqlAdapter",
13
+ "ActiveRecord::ConnectionAdapters::Mysql2Adapter"
14
+ ].freeze
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+ class_option(
18
+ :with_changes,
19
+ type: :boolean,
20
+ default: false,
21
+ desc: "Store changeset (diff) with each version"
22
+ )
23
+ class_option(
24
+ :uuid,
25
+ type: :boolean,
26
+ default: false,
27
+ desc: "Use uuid instead of bigint for item_id type (use only if tables use UUIDs)"
28
+ )
29
+
30
+ desc "Generates (but does not run) a migration to add a versions table. " \
31
+ "Can be customized by providing a Version class name. " \
32
+ "See section 5.c. Generators in README.md for more information."
33
+
34
+ def create_migration_file
35
+ # Use the table_name to create the proper migration filename
36
+ add_paper_trail_migration(
37
+ "create_#{table_name}",
38
+ item_type_options: item_type_options,
39
+ versions_table_options: versions_table_options,
40
+ item_id_type_options: item_id_type_options,
41
+ version_table_primary_key_type: version_table_primary_key_type
42
+ )
43
+ if options.with_changes?
44
+ add_paper_trail_migration("add_object_changes_to_#{table_name}")
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ # To use uuid instead of integer for primary key
51
+ def item_id_type_options
52
+ options.uuid? ? "string" : "bigint"
53
+ end
54
+
55
+ # To use uuid for version table primary key
56
+ def version_table_primary_key_type
57
+ if options.uuid?
58
+ ", id: :uuid"
59
+ else
60
+ ""
61
+ end
62
+ end
63
+
64
+ # MySQL 5.6 utf8mb4 limit is 191 chars for keys used in indexes.
65
+ # See https://github.com/paper-trail-gem/paper_trail/issues/651
66
+ def item_type_options
67
+ if mysql?
68
+ ", null: false, limit: 191"
69
+ else
70
+ ", null: false"
71
+ end
72
+ end
73
+
74
+ def mysql?
75
+ MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name)
76
+ end
77
+
78
+ # Even modern versions of MySQL still use `latin1` as the default character
79
+ # encoding. Many users are not aware of this, and run into trouble when they
80
+ # try to use PaperTrail in apps that otherwise tend to use UTF-8. Postgres, by
81
+ # comparison, uses UTF-8 except in the unusual case where the OS is configured
82
+ # with a custom locale.
83
+ #
84
+ # - https://dev.mysql.com/doc/refman/5.7/en/charset-applications.html
85
+ # - http://www.postgresql.org/docs/9.4/static/multibyte.html
86
+ #
87
+ # Furthermore, MySQL's original implementation of UTF-8 was flawed, and had
88
+ # to be fixed later by introducing a new charset, `utf8mb4`.
89
+ #
90
+ # - https://mathiasbynens.be/notes/mysql-utf8mb4
91
+ # - https://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html
92
+ #
93
+ def versions_table_options
94
+ if mysql?
95
+ ', options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"'
96
+ else
97
+ ""
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,12 @@
1
+ # This migration adds the optional `object_changes` column, in which PaperTrail
2
+ # will store the `changes` diff for each update event. See the readme for
3
+ # details.
4
+ class AddObjectChangesTo<%= version_class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
5
+ # The largest text column available in all supported RDBMS.
6
+ # See `create_versions.rb` for details.
7
+ TEXT_BYTES = 1_073_741_823
8
+
9
+ def change
10
+ add_column :<%= table_name %>, :object_changes, :text, limit: TEXT_BYTES
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ # This migration creates the `<%= table_name %>` table for the <%= version_class_name %> class.
2
+ # All other migrations PT provides are optional.
3
+ class Create<%= version_class_name.pluralize %> < ActiveRecord::Migration<%= migration_version %>
4
+
5
+ # The largest text column available in all supported RDBMS is
6
+ # 1024^3 - 1 bytes, roughly one gibibyte. We specify a size
7
+ # so that MySQL will use `longtext` instead of `text`. Otherwise,
8
+ # when serializing very large objects, `text` might not be big enough.
9
+ TEXT_BYTES = 1_073_741_823
10
+
11
+ def change
12
+ create_table :<%= table_name %><%= versions_table_options %><%= version_table_primary_key_type %> do |t|
13
+ # Consider using bigint type for performance if you are going to store only numeric ids.
14
+ # t.bigint :whodunnit
15
+ t.string :whodunnit
16
+
17
+ # Known issue in MySQL: fractional second precision
18
+ # -------------------------------------------------
19
+ #
20
+ # MySQL timestamp columns do not support fractional seconds unless
21
+ # defined with "fractional seconds precision". MySQL users should manually
22
+ # add fractional seconds precision to this migration, specifically, to
23
+ # the `created_at` column.
24
+ # (https://dev.mysql.com/doc/refman/5.6/en/fractional-seconds.html)
25
+ #
26
+ # MySQL users should also upgrade to at least rails 4.2, which is the first
27
+ # version of ActiveRecord with support for fractional seconds in MySQL.
28
+ # (https://github.com/rails/rails/pull/14359)
29
+ #
30
+ # MySQL users should use the following line for `created_at`
31
+ # t.datetime :created_at, limit: 6
32
+ t.datetime :created_at
33
+
34
+ t.<%= item_id_type_options %> :item_id, null: false
35
+ t.string :item_type<%= item_type_options %>
36
+ t.string :event, null: false
37
+ t.text :object, limit: TEXT_BYTES
38
+ end
39
+ add_index :<%= table_name %>, %i[item_type item_id]
40
+ end
41
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module PaperTrail
7
+ # Basic structure to support a generator that builds a migration
8
+ class MigrationGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ # Define arguments for the generator
12
+ argument :version_class_name, type: :string, default: "Version",
13
+ desc: "The name of the Version class (e.g., CommentVersion)"
14
+
15
+ def self.next_migration_number(dirname)
16
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
17
+ end
18
+
19
+ protected
20
+
21
+ def add_paper_trail_migration(template, extra_options = {})
22
+ migration_dir = File.expand_path("db/migrate")
23
+ if self.class.migration_exists?(migration_dir, template)
24
+ ::Kernel.warn "Migration already exists: #{template}"
25
+ else
26
+ # Map the dynamic template name to the actual template file
27
+ template_file = map_template_name(template)
28
+
29
+ migration_template(
30
+ "#{template_file}.rb.erb",
31
+ "db/migrate/#{template}.rb",
32
+ {
33
+ migration_version: migration_version,
34
+ table_name: table_name,
35
+ version_class_name: version_class_name
36
+ }.merge(extra_options)
37
+ )
38
+ end
39
+ end
40
+
41
+ def migration_version
42
+ format(
43
+ "[%d.%d]",
44
+ ActiveRecord::VERSION::MAJOR,
45
+ ActiveRecord::VERSION::MINOR
46
+ )
47
+ end
48
+
49
+ # Convert Version class name to table name using Rails conventions
50
+ def table_name
51
+ version_class_name.underscore.pluralize
52
+ end
53
+
54
+ # Map the dynamic template name to the actual template file
55
+ def map_template_name(template)
56
+ if template.start_with?("create_")
57
+ "create_versions"
58
+ elsif template.start_with?("add_object_changes_to_")
59
+ "add_object_changes_to_versions"
60
+ else
61
+ template
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,4 @@
1
+ Description:
2
+ Generates (but does not run) a migration to update item_type for STI entries
3
+ in an existing versions table. See section 5.c. Generators in README.md for
4
+ more information.
@@ -0,0 +1,86 @@
1
+ # This migration updates existing `versions` that have `item_type` that refers to
2
+ # the base_class, and changes them to refer to the subclass instead.
3
+ class Update<%= version_class_name.pluralize %>ForItemSubtype < ActiveRecord::Migration<%= migration_version %>
4
+ include ActionView::Helpers::TextHelper
5
+ def up
6
+ <%=
7
+ # Returns class, column, range
8
+ def self.parse_custom_entry(text)
9
+ parts = text.split("):")
10
+ range = parts.last.split("..").map(&:to_i)
11
+ range = Range.new(range.first, range.last)
12
+ parts.first.split("(") + [range]
13
+ end
14
+ # Running:
15
+ # rails g paper_trail:update_item_subtype Animal(species):1..4 Plant(genus):42..1337
16
+ # results in:
17
+ # # Versions of item_type "Animal" with IDs between 1 and 4 will be updated based on `species`
18
+ # # Versions of item_type "Plant" with IDs between 42 and 1337 will be updated based on `genus`
19
+ # hints = {"Animal"=>{1..4=>"species"}, "Plant"=>{42..1337=>"genus"}}
20
+ hint_descriptions = ""
21
+ # Use @hints over args to not break the test itself since args could now include --version_class_name=CommentVersion
22
+ hints = (@hints || []).inject(Hash.new{|h, k| h[k] = {}}) do |s, v|
23
+ klass, column, range = parse_custom_entry(v)
24
+ hint_descriptions << " # Versions of item_type \"#{klass}\" with IDs between #{
25
+ range.first} and #{range.last} will be updated based on \`#{column}\`\n"
26
+ s[klass][range] = column
27
+ s
28
+ end
29
+
30
+ unless hints.empty?
31
+ "#{hint_descriptions} hints = #{hints.inspect}\n"
32
+ end
33
+ %>
34
+ # Find all ActiveRecord models mentioned in existing versions
35
+ changes = Hash.new { |h, k| h[k] = [] }
36
+ model_names = <%= fully_qualified_version_class_name %>.select(:item_type).distinct
37
+ model_names.map(&:item_type).each do |model_name|
38
+ hint = hints[model_name] if defined?(hints)
39
+ begin
40
+ klass = model_name.constantize
41
+ # Actually implements an inheritance_column? (Usually "type")
42
+ has_inheritance_column = klass.columns.map(&:name).include?(klass.inheritance_column)
43
+ # Find domain of types stored in PaperTrail versions
44
+ <%= fully_qualified_version_class_name %>.where(item_type: model_name, item_subtype: nil).select(:id, :object, :object_changes).each do |obj|
45
+ if (object_detail = PaperTrail.serializer.load(obj.object || obj.object_changes))
46
+ is_found = false
47
+ subtype_name = nil
48
+ hint&.each do |k, v|
49
+ if k === obj.id && (subtype_name = object_detail[v])
50
+ break
51
+ end
52
+ end
53
+ if subtype_name.nil? && has_inheritance_column
54
+ subtype_name = object_detail[klass.inheritance_column]
55
+ end
56
+ if subtype_name
57
+ subtype_name = subtype_name.last if subtype_name.is_a?(Array)
58
+ if subtype_name != model_name
59
+ changes[subtype_name] << obj.id
60
+ end
61
+ end
62
+ end
63
+ end
64
+ rescue NameError => ex
65
+ say "Skipping reference to #{model_name}", subitem: true
66
+ end
67
+ end
68
+ changes.each do |k, v|
69
+ # Update in blocks of up to 100 at a time
70
+ block_of_ids = []
71
+ id_count = 0
72
+ num_updated = 0
73
+ v.sort.each do |id|
74
+ block_of_ids << id
75
+ if (id_count += 1) % 100 == 0
76
+ num_updated += <%= fully_qualified_version_class_name %>.where(id: block_of_ids).update_all(item_subtype: k)
77
+ block_of_ids = []
78
+ end
79
+ end
80
+ num_updated += <%= fully_qualified_version_class_name %>.where(id: block_of_ids).update_all(item_subtype: k)
81
+ if num_updated > 0
82
+ say "Associated #{pluralize(num_updated, 'record')} to #{k}", subitem: true
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migration_generator"
4
+
5
+ module PaperTrail
6
+ # Updates STI entries for PaperTrail
7
+ class UpdateItemSubtypeGenerator < MigrationGenerator
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ # Remove the inherited version_class_name argument as we use an option instead
11
+ remove_argument :version_class_name
12
+
13
+ argument :hints, type: :array, default: [], banner: "hint1 hint2"
14
+
15
+ class_option :version_class_name,
16
+ type: :string,
17
+ default: "Version",
18
+ aliases: ["-v"],
19
+ desc: "The name of the Version class (e.g., CommentVersion)"
20
+
21
+ desc(
22
+ "Generates (but does not run) a migration to update item_subtype for " \
23
+ "STI entries in an existing versions table."
24
+ )
25
+
26
+ def create_migration_file
27
+ add_paper_trail_migration("update_#{table_name}_for_item_subtype", sti_type_options: options)
28
+ end
29
+
30
+ # Return the version class name from options
31
+ def version_class_name
32
+ options[:version_class_name]
33
+ end
34
+
35
+ # Return the fully qualified class name for use in ERB templates
36
+ def fully_qualified_version_class_name
37
+ version_class_name == "Version" ? "PaperTrail::Version" : version_class_name
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,10 @@
1
+ Attribute Serializers
2
+ =====================
3
+
4
+ "Serialization" here refers to the preparation of data for insertion into a
5
+ database, particularly the `object` and `object_changes` columns in the
6
+ `versions` table.
7
+
8
+ Likewise, "deserialization" refers to any processing of data after they
9
+ have been read from the database, for example preparing the result of
10
+ `VersionConcern#changeset`.
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail/type_serializers/postgres_array_serializer"
4
+
5
+ module PaperTrail
6
+ module AttributeSerializers
7
+ # Values returned by some Active Record serializers are
8
+ # not suited for writing JSON to a text column. This factory
9
+ # replaces certain default Active Record serializers
10
+ # with custom PaperTrail ones.
11
+ #
12
+ # @api private
13
+ module AttributeSerializerFactory
14
+ class << self
15
+ # @api private
16
+ def for(klass, attr)
17
+ active_record_serializer = klass.type_for_attribute(attr)
18
+ if ar_pg_array?(active_record_serializer)
19
+ TypeSerializers::PostgresArraySerializer.new(
20
+ active_record_serializer.subtype,
21
+ active_record_serializer.delimiter
22
+ )
23
+ else
24
+ active_record_serializer
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # @api private
31
+ def ar_pg_array?(obj)
32
+ if defined?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array)
33
+ obj.instance_of?(::ActiveRecord::ConnectionAdapters::PostgreSQL::OID::Array)
34
+ else
35
+ false
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail/attribute_serializers/attribute_serializer_factory"
4
+
5
+ module PaperTrail
6
+ # :nodoc:
7
+ module AttributeSerializers
8
+ # The `CastAttributeSerializer` (de)serializes model attribute values. For
9
+ # example, the string "1.99" serializes into the integer `1` when assigned
10
+ # to an attribute of type `ActiveRecord::Type::Integer`.
11
+ class CastAttributeSerializer
12
+ def initialize(klass)
13
+ @klass = klass
14
+ end
15
+
16
+ private
17
+
18
+ # Returns a hash mapping attributes to hashes that map strings to
19
+ # integers. Example:
20
+ #
21
+ # ```
22
+ # { "status" => { "draft"=>0, "published"=>1, "archived"=>2 } }
23
+ # ```
24
+ #
25
+ # ActiveRecord::Enum was added in AR 4.1
26
+ # http://edgeguides.rubyonrails.org/4_1_release_notes.html#active-record-enums
27
+ def defined_enums
28
+ @defined_enums ||= (@klass.respond_to?(:defined_enums) ? @klass.defined_enums : {})
29
+ end
30
+
31
+ def deserialize(attr, val)
32
+ if defined_enums[attr] && val.is_a?(::String)
33
+ # Because PT 4 used to save the string version of enums to `object_changes`
34
+ val
35
+ elsif val.is_a?(ActiveRecord::Type::Time::Value)
36
+ # Because Rails 7 time attribute throws a delegation error when you deserialize
37
+ # it with the factory.
38
+ # See ActiveRecord::Type::Time::Value crashes when loaded from YAML on rails 7.0
39
+ # https://github.com/rails/rails/issues/43966
40
+ val.instance_variable_get(:@time)
41
+ else
42
+ AttributeSerializerFactory.for(@klass, attr).deserialize(val)
43
+ end
44
+ end
45
+
46
+ def serialize(attr, val)
47
+ AttributeSerializerFactory.for(@klass, attr).serialize(val)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail/attribute_serializers/cast_attribute_serializer"
4
+
5
+ module PaperTrail
6
+ module AttributeSerializers
7
+ # Serialize or deserialize the `version.object` column.
8
+ class ObjectAttribute
9
+ def initialize(model_class)
10
+ @model_class = model_class
11
+
12
+ # ActiveRecord since 7.0 has a built-in encryption mechanism
13
+ @encrypted_attributes = @model_class.encrypted_attributes&.map(&:to_s)
14
+ end
15
+
16
+ def serialize(attributes)
17
+ alter(attributes, :serialize)
18
+ end
19
+
20
+ def deserialize(attributes)
21
+ alter(attributes, :deserialize)
22
+ end
23
+
24
+ private
25
+
26
+ # Modifies `attributes` in place.
27
+ # TODO: Return a new hash instead.
28
+ def alter(attributes, serialization_method)
29
+ # Don't serialize non-encrypted before values before inserting into columns of type
30
+ # `JSON` on `PostgreSQL` databases.
31
+ attributes_to_serialize =
32
+ object_col_is_json? ? attributes.slice(*@encrypted_attributes) : attributes
33
+ return attributes if attributes_to_serialize.blank?
34
+
35
+ serializer = CastAttributeSerializer.new(@model_class)
36
+ attributes_to_serialize.each do |key, value|
37
+ attributes[key] = serializer.send(serialization_method, key, value)
38
+ end
39
+
40
+ attributes
41
+ end
42
+
43
+ def object_col_is_json?
44
+ @model_class.paper_trail.version_class.object_col_is_json?
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "paper_trail/attribute_serializers/cast_attribute_serializer"
4
+
5
+ module PaperTrail
6
+ module AttributeSerializers
7
+ # Serialize or deserialize the `version.object_changes` column.
8
+ class ObjectChangesAttribute
9
+ def initialize(item_class)
10
+ @item_class = item_class
11
+
12
+ # ActiveRecord since 7.0 has a built-in encryption mechanism
13
+ @encrypted_attributes = @item_class.encrypted_attributes&.map(&:to_s)
14
+ end
15
+
16
+ def serialize(changes)
17
+ alter(changes, :serialize)
18
+ end
19
+
20
+ def deserialize(changes)
21
+ alter(changes, :deserialize)
22
+ end
23
+
24
+ private
25
+
26
+ # Modifies `changes` in place.
27
+ # TODO: Return a new hash instead.
28
+ def alter(changes, serialization_method)
29
+ # Don't serialize non-encrypted before values before inserting into columns of type
30
+ # `JSON` on `PostgreSQL` databases.
31
+ changes_to_serialize =
32
+ object_changes_col_is_json? ? changes.slice(*@encrypted_attributes) : changes.clone
33
+ return changes if changes_to_serialize.blank?
34
+
35
+ serializer = CastAttributeSerializer.new(@item_class)
36
+ changes_to_serialize.each do |key, change|
37
+ # `change` is an Array with two elements, representing before and after.
38
+ changes[key] = Array(change).map do |value|
39
+ serializer.send(serialization_method, key, value)
40
+ end
41
+ end
42
+
43
+ changes
44
+ end
45
+
46
+ def object_changes_col_is_json?
47
+ @item_class.paper_trail.version_class.object_changes_col_is_json?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PaperTrail
4
+ # Utilities for deleting version records.
5
+ module Cleaner
6
+ # Destroys all but the most recent version(s) for items on a given date
7
+ # (or on all dates). Useful for deleting drafts.
8
+ #
9
+ # Options:
10
+ #
11
+ # - :keeping - An `integer` indicating the number of versions to be kept for
12
+ # each item per date. Defaults to `1`. The most recent matching versions
13
+ # are kept.
14
+ # - :date - Should either be a `Date` object specifying which date to
15
+ # destroy versions for or `:all`, which will specify that all dates
16
+ # should be cleaned. Defaults to `:all`.
17
+ # - :item_id - The `id` for the item to be cleaned on, or `nil`, which
18
+ # causes all items to be cleaned. Defaults to `nil`.
19
+ #
20
+ def clean_versions!(options = {})
21
+ options = { keeping: 1, date: :all }.merge(options)
22
+ gather_versions(options[:item_id], options[:date]).each do |_item_id, item_versions|
23
+ group_versions_by_date(item_versions).each do |_date, date_versions|
24
+ # Remove the number of versions we wish to keep from the collection
25
+ # of versions prior to destruction.
26
+ date_versions.pop(options[:keeping])
27
+ date_versions.map(&:destroy)
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # Returns a hash of versions grouped by the `item_id` attribute formatted
35
+ # like this: {:item_id => PaperTrail::Version}. If `item_id` or `date` is
36
+ # set, versions will be narrowed to those pointing at items with those ids
37
+ # that were created on specified date. Versions are returned in
38
+ # chronological order.
39
+ def gather_versions(item_id = nil, date = :all)
40
+ unless date == :all || date.respond_to?(:to_date)
41
+ raise ArgumentError, "Expected date to be a Timestamp or :all"
42
+ end
43
+ versions = item_id ? PaperTrail::Version.where(item_id: item_id) : PaperTrail::Version
44
+ versions = versions.order(PaperTrail::Version.timestamp_sort_order)
45
+ versions = versions.between(date.to_date, date.to_date + 1.day) unless date == :all
46
+
47
+ # If `versions` has not been converted to an ActiveRecord::Relation yet,
48
+ # do so now.
49
+ versions = PaperTrail::Version.all if versions == PaperTrail::Version
50
+ versions.group_by(&:item_id)
51
+ end
52
+
53
+ # Given an array of versions, returns a hash mapping dates to arrays of
54
+ # versions.
55
+ # @api private
56
+ def group_versions_by_date(versions)
57
+ versions.group_by { |v| v.created_at.to_date }
58
+ end
59
+ end
60
+ end