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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d7a0475fb88d2e0c16bb910bad898f36aea9a7f18d40740aad49e9ede70baa8
4
- data.tar.gz: 1aabf782bb08a12d1350a164ebfcbabfd6311a031c33b23ba804d442dca38b83
3
+ metadata.gz: 34a10af427a067fb323553b27c698c21dbe273bd4b3b2a5824b8ca9373ef682c
4
+ data.tar.gz: 4b2ca7ed5a477e073a9b1a5d6670192fba44873d987b8e57e2bbac6986d8f718
5
5
  SHA512:
6
- metadata.gz: a1395f6dc2ef5266a1dac348df61bca06806edd3c2e1a2eed6da5ae20c79629feff11a22a9ec457d54a970ff8a50a99f3bf828cc2c69a93daae8f5db2222ade4
7
- data.tar.gz: b28f4d62c23ec6d77ab14300521fc1a2979ba54f1e4e959342a148951c67b076ed1cd704bc04fdfe3a85638a5206ae479cf3d8a8682e996e4d7d8765eb1d8618
6
+ metadata.gz: ab135318ef1d757e8ce9657fdf686856b37eaf3ebdedd7b6af5ad24c6b18672d6dc1358bd8e4c45f9d03bd84f6d4f75623702ece5dda81f63774f5915b86fdfd
7
+ data.tar.gz: a1ef06646ed4fb613fa7505206ac4daeb93ef99f334ab09580c8a10426ec83900838ed618fcbb97fa3f9f6e318c92f48bea622609fcf8f26c3a9bbd4582c1d7e
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  Copyright (c) 2009 Andy Stewart, AirBlade Software Ltd.
2
2
  Copyright (c) 2018 Weston Ganger
3
- Copyright (c) 2025 Marcus Wyatt
3
+ Copyright (c) 2025 Brands Insurance Agency, Inc
4
4
 
5
5
 
6
6
  Permission is hereby granted, free of charge, to any person obtaining
@@ -0,0 +1,3 @@
1
+ Description:
2
+ Generates (but does not run) a migration to add a versions table. Also generates an initializer
3
+ file for configuring SnailTrail. See section 5.c. Generators in README.md for more information.
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migration_generator"
4
+
5
+ module SnailTrail
6
+ # Installs SnailTrail 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
+ class_option(
30
+ :with_transaction_id,
31
+ type: :boolean,
32
+ default: false,
33
+ desc: "Add a transaction_id column to versions table"
34
+ )
35
+
36
+ desc "Generates (but does not run) a migration to add a versions table. " \
37
+ "See section 5.c. Generators in README.md for more information."
38
+
39
+ def create_migration_file
40
+ add_snail_trail_migration(
41
+ "create_versions",
42
+ item_type_options: item_type_options,
43
+ versions_table_options: versions_table_options,
44
+ item_id_type_options: item_id_type_options,
45
+ version_table_primary_key_type: version_table_primary_key_type
46
+ )
47
+ if options.with_changes?
48
+ add_snail_trail_migration("add_object_changes_to_versions")
49
+ end
50
+ if options.with_transaction_id?
51
+ add_snail_trail_migration("add_transaction_id_column_to_versions")
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ # To use uuid instead of integer for primary key
58
+ def item_id_type_options
59
+ options.uuid? ? "string" : "bigint"
60
+ end
61
+
62
+ # To use uuid for version table primary key
63
+ def version_table_primary_key_type
64
+ if options.uuid?
65
+ ", id: :uuid"
66
+ else
67
+ ""
68
+ end
69
+ end
70
+
71
+ # MySQL 5.6 utf8mb4 limit is 191 chars for keys used in indexes.
72
+ # See https://github.com/BrandsInsurance/snail_trail/issues/651
73
+ def item_type_options
74
+ if mysql?
75
+ ", null: false, limit: 191"
76
+ else
77
+ ", null: false"
78
+ end
79
+ end
80
+
81
+ def mysql?
82
+ MYSQL_ADAPTERS.include?(ActiveRecord::Base.connection.class.name)
83
+ end
84
+
85
+ # Even modern versions of MySQL still use `latin1` as the default character
86
+ # encoding. Many users are not aware of this, and run into trouble when they
87
+ # try to use SnailTrail in apps that otherwise tend to use UTF-8. Postgres, by
88
+ # comparison, uses UTF-8 except in the unusual case where the OS is configured
89
+ # with a custom locale.
90
+ #
91
+ # - https://dev.mysql.com/doc/refman/5.7/en/charset-applications.html
92
+ # - http://www.postgresql.org/docs/9.4/static/multibyte.html
93
+ #
94
+ # Furthermore, MySQL's original implementation of UTF-8 was flawed, and had
95
+ # to be fixed later by introducing a new charset, `utf8mb4`.
96
+ #
97
+ # - https://mathiasbynens.be/notes/mysql-utf8mb4
98
+ # - https://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html
99
+ #
100
+ def versions_table_options
101
+ if mysql?
102
+ ', options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci"'
103
+ else
104
+ ""
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,12 @@
1
+ # This migration adds the optional `object_changes` column, in which SnailTrail
2
+ # will store the `changes` diff for each update event. See the readme for
3
+ # details.
4
+ class AddObjectChangesToVersions < 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 :versions, :object_changes, :text, limit: TEXT_BYTES
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # This migration provides the necessary schema for tracking changes in a single DB transaction.
2
+ class AddTransactionIdColumnToVersions < ActiveRecord::Migration<%= migration_version %>
3
+ def self.up
4
+ add_column :versions, :transaction_id, :integer
5
+ add_index :versions, [:transaction_id]
6
+ end
7
+
8
+ def self.down
9
+ remove_index :versions, [:transaction_id]
10
+ remove_column :versions, :transaction_id
11
+ end
12
+ end
@@ -0,0 +1,41 @@
1
+ # This migration creates the `versions` table, the only schema ST requires.
2
+ # All other migrations ST provides are optional.
3
+ class CreateVersions < 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 :versions<%= 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 :versions, %i[item_type item_id]
40
+ end
41
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/active_record"
5
+
6
+ module SnailTrail
7
+ # Basic structure to support a generator that builds a migration
8
+ class MigrationGenerator < ::Rails::Generators::Base
9
+ include ::Rails::Generators::Migration
10
+
11
+ def self.next_migration_number(dirname)
12
+ ::ActiveRecord::Generators::Base.next_migration_number(dirname)
13
+ end
14
+
15
+ protected
16
+
17
+ def add_snail_trail_migration(template, extra_options = {})
18
+ migration_dir = File.expand_path("db/migrate")
19
+ if self.class.migration_exists?(migration_dir, template)
20
+ ::Kernel.warn "Migration already exists: #{template}"
21
+ else
22
+ migration_template(
23
+ "#{template}.rb.erb",
24
+ "db/migrate/#{template}.rb",
25
+ { migration_version: migration_version }.merge(extra_options)
26
+ )
27
+ end
28
+ end
29
+
30
+ def migration_version
31
+ format(
32
+ "[%d.%d]",
33
+ ActiveRecord::VERSION::MAJOR,
34
+ ActiveRecord::VERSION::MINOR
35
+ )
36
+ end
37
+ end
38
+ 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,85 @@
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 UpdateVersionsForItemSubtype < 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 snail_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
+ hints = args.inject(Hash.new{|h, k| h[k] = {}}) do |s, v|
22
+ klass, column, range = parse_custom_entry(v)
23
+ hint_descriptions << " # Versions of item_type \"#{klass}\" with IDs between #{
24
+ range.first} and #{range.last} will be updated based on \`#{column}\`\n"
25
+ s[klass][range] = column
26
+ s
27
+ end
28
+
29
+ unless hints.empty?
30
+ "#{hint_descriptions} hints = #{hints.inspect}\n"
31
+ end
32
+ %>
33
+ # Find all ActiveRecord models mentioned in existing versions
34
+ changes = Hash.new { |h, k| h[k] = [] }
35
+ model_names = SnailTrail::Version.select(:item_type).distinct
36
+ model_names.map(&:item_type).each do |model_name|
37
+ hint = hints[model_name] if defined?(hints)
38
+ begin
39
+ klass = model_name.constantize
40
+ # Actually implements an inheritance_column? (Usually "type")
41
+ has_inheritance_column = klass.columns.map(&:name).include?(klass.inheritance_column)
42
+ # Find domain of types stored in SnailTrail versions
43
+ SnailTrail::Version.where(item_type: model_name, item_subtype: nil).select(:id, :object, :object_changes).each do |obj|
44
+ if (object_detail = SnailTrail.serializer.load(obj.object || obj.object_changes))
45
+ is_found = false
46
+ subtype_name = nil
47
+ hint&.each do |k, v|
48
+ if k === obj.id && (subtype_name = object_detail[v])
49
+ break
50
+ end
51
+ end
52
+ if subtype_name.nil? && has_inheritance_column
53
+ subtype_name = object_detail[klass.inheritance_column]
54
+ end
55
+ if subtype_name
56
+ subtype_name = subtype_name.last if subtype_name.is_a?(Array)
57
+ if subtype_name != model_name
58
+ changes[subtype_name] << obj.id
59
+ end
60
+ end
61
+ end
62
+ end
63
+ rescue NameError => ex
64
+ say "Skipping reference to #{model_name}", subitem: true
65
+ end
66
+ end
67
+ changes.each do |k, v|
68
+ # Update in blocks of up to 100 at a time
69
+ block_of_ids = []
70
+ id_count = 0
71
+ num_updated = 0
72
+ v.sort.each do |id|
73
+ block_of_ids << id
74
+ if (id_count += 1) % 100 == 0
75
+ num_updated += SnailTrail::Version.where(id: block_of_ids).update_all(item_subtype: k)
76
+ block_of_ids = []
77
+ end
78
+ end
79
+ num_updated += SnailTrail::Version.where(id: block_of_ids).update_all(item_subtype: k)
80
+ if num_updated > 0
81
+ say "Associated #{pluralize(num_updated, 'record')} to #{k}", subitem: true
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migration_generator"
4
+
5
+ module SnailTrail
6
+ # Updates STI entries for SnailTrail
7
+ class UpdateItemSubtypeGenerator < MigrationGenerator
8
+ source_root File.expand_path("templates", __dir__)
9
+
10
+ desc(
11
+ "Generates (but does not run) a migration to update item_subtype for " \
12
+ "STI entries in an existing versions table."
13
+ )
14
+
15
+ def create_migration_file
16
+ add_snail_trail_migration("update_versions_for_item_subtype", sti_type_options: options)
17
+ end
18
+ end
19
+ 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 "snail_trail/type_serializers/postgres_array_serializer"
4
+
5
+ module SnailTrail
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 SnailTrail 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 "snail_trail/attribute_serializers/attribute_serializer_factory"
4
+
5
+ module SnailTrail
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 ST 4 used to save the string version of enums to `object_changes`
34
+ val
35
+ elsif SnailTrail.active_record_gte_7_0? && 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,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snail_trail/attribute_serializers/cast_attribute_serializer"
4
+
5
+ module SnailTrail
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 =
14
+ if SnailTrail.active_record_gte_7_0?
15
+ @model_class.encrypted_attributes&.map(&:to_s)
16
+ end
17
+ end
18
+
19
+ def serialize(attributes)
20
+ alter(attributes, :serialize)
21
+ end
22
+
23
+ def deserialize(attributes)
24
+ alter(attributes, :deserialize)
25
+ end
26
+
27
+ private
28
+
29
+ # Modifies `attributes` in place.
30
+ # TODO: Return a new hash instead.
31
+ def alter(attributes, serialization_method)
32
+ # Don't serialize non-encrypted before values before inserting into columns of type
33
+ # `JSON` on `PostgreSQL` databases.
34
+ attributes_to_serialize =
35
+ object_col_is_json? ? attributes.slice(*@encrypted_attributes) : attributes
36
+ return attributes if attributes_to_serialize.blank?
37
+
38
+ serializer = CastAttributeSerializer.new(@model_class)
39
+ attributes_to_serialize.each do |key, value|
40
+ attributes[key] = serializer.send(serialization_method, key, value)
41
+ end
42
+
43
+ attributes
44
+ end
45
+
46
+ def object_col_is_json?
47
+ @model_class.snail_trail.version_class.object_col_is_json?
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "snail_trail/attribute_serializers/cast_attribute_serializer"
4
+
5
+ module SnailTrail
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 =
14
+ if SnailTrail.active_record_gte_7_0?
15
+ @item_class.encrypted_attributes&.map(&:to_s)
16
+ end
17
+ end
18
+
19
+ def serialize(changes)
20
+ alter(changes, :serialize)
21
+ end
22
+
23
+ def deserialize(changes)
24
+ alter(changes, :deserialize)
25
+ end
26
+
27
+ private
28
+
29
+ # Modifies `changes` in place.
30
+ # TODO: Return a new hash instead.
31
+ def alter(changes, serialization_method)
32
+ # Don't serialize non-encrypted before values before inserting into columns of type
33
+ # `JSON` on `PostgreSQL` databases.
34
+ changes_to_serialize =
35
+ object_changes_col_is_json? ? changes.slice(*@encrypted_attributes) : changes.clone
36
+ return changes if changes_to_serialize.blank?
37
+
38
+ serializer = CastAttributeSerializer.new(@item_class)
39
+ changes_to_serialize.each do |key, change|
40
+ # `change` is an Array with two elements, representing before and after.
41
+ changes[key] = Array(change).map do |value|
42
+ serializer.send(serialization_method, key, value)
43
+ end
44
+ end
45
+
46
+ changes
47
+ end
48
+
49
+ def object_changes_col_is_json?
50
+ @item_class.snail_trail.version_class.object_changes_col_is_json?
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
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 => SnailTrail::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 ? SnailTrail::Version.where(item_id: item_id) : SnailTrail::Version
44
+ versions = versions.order(SnailTrail::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 = SnailTrail::Version.all if versions == SnailTrail::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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SnailTrail
4
+ # Rails does not follow SemVer, makes breaking changes in minor versions.
5
+ # Breaking changes are expected, and are generally good for the rails
6
+ # ecosystem. However, they often require dozens of hours to fix, even with the
7
+ # [help of experts](https://github.com/BrandsInsurance/snail_trail/pull/899).
8
+ #
9
+ # It is not safe to assume that a new version of rails will be compatible with
10
+ # SnailTrail. ST is only compatible with the versions of rails that it is
11
+ # tested against. See `.github/workflows/test.yml`.
12
+ #
13
+ # However, as of
14
+ # [#1213](https://github.com/BrandsInsurance/snail_trail/pull/1213) our
15
+ # gemspec allows installation with newer, incompatible rails versions. We hope
16
+ # this will make it easier for contributors to work on compatibility with
17
+ # newer rails versions. Most ST users should avoid incompatible rails
18
+ # versions.
19
+ module Compatibility
20
+ ACTIVERECORD_GTE = ">= 6.1" # enforced in gemspec
21
+ ACTIVERECORD_LT = "< 8.1" # not enforced in gemspec
22
+
23
+ E_INCOMPATIBLE_AR = <<-EOS
24
+ SnailTrail %s is not compatible with ActiveRecord %s. We allow ST
25
+ contributors to install incompatible versions of ActiveRecord, and this
26
+ warning can be silenced with an environment variable, but this is a bad
27
+ idea for normal use. Please install a compatible version of ActiveRecord
28
+ instead (%s). Please see the discussion in snail_trail/compatibility.rb
29
+ for details.
30
+ EOS
31
+
32
+ # Normal users need a warning if they accidentally install an incompatible
33
+ # version of ActiveRecord. Contributors can silence this warning with an
34
+ # environment variable.
35
+ def self.check_activerecord(ar_version)
36
+ raise ::TypeError unless ar_version.instance_of?(::Gem::Version)
37
+ return if ::ENV["ST_SILENCE_AR_COMPAT_WARNING"].present?
38
+ req = ::Gem::Requirement.new([ACTIVERECORD_GTE, ACTIVERECORD_LT])
39
+ unless req.satisfied_by?(ar_version)
40
+ ::Kernel.warn(
41
+ format(
42
+ E_INCOMPATIBLE_AR,
43
+ ::SnailTrail.gem_version,
44
+ ar_version,
45
+ req
46
+ )
47
+ )
48
+ end
49
+ end
50
+ end
51
+ end