File: version_concern.rb

package info (click to toggle)
ruby-paper-trail 12.0.0-1
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 1,200 kB
  • sloc: ruby: 6,743; makefile: 6
file content (365 lines) | stat: -rw-r--r-- 12,274 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# frozen_string_literal: true

require "paper_trail/attribute_serializers/object_changes_attribute"
require "paper_trail/queries/versions/where_object"
require "paper_trail/queries/versions/where_object_changes"
require "paper_trail/queries/versions/where_object_changes_from"

module PaperTrail
  # Originally, PaperTrail did not provide this module, and all of this
  # functionality was in `PaperTrail::Version`. That model still exists (and is
  # used by most apps) but by moving the functionality to this module, people
  # can include this concern instead of sub-classing the `Version` model.
  module VersionConcern
    extend ::ActiveSupport::Concern

    included do
      belongs_to :item, polymorphic: true, optional: true
      validates_presence_of :event
      after_create :enforce_version_limit!
    end

    # :nodoc:
    module ClassMethods
      def item_subtype_column_present?
        column_names.include?("item_subtype")
      end

      def with_item_keys(item_type, item_id)
        where item_type: item_type, item_id: item_id
      end

      def creates
        where event: "create"
      end

      def updates
        where event: "update"
      end

      def destroys
        where event: "destroy"
      end

      def not_creates
        where "event <> ?", "create"
      end

      def between(start_time, end_time)
        where(
          arel_table[:created_at].gt(start_time).
          and(arel_table[:created_at].lt(end_time))
        ).order(timestamp_sort_order)
      end

      # Defaults to using the primary key as the secondary sort order if
      # possible.
      def timestamp_sort_order(direction = "asc")
        [arel_table[:created_at].send(direction.downcase)].tap do |array|
          array << arel_table[primary_key].send(direction.downcase) if primary_key_is_int?
        end
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects` column.
      #
      # ```
      # SELECT "versions".*
      # FROM "versions"
      # WHERE ("versions"."object" LIKE '%
      # name: Joan
      # %')
      # ```
      #
      # This is useful for finding versions where a given attribute had a given
      # value. Imagine, in the example above, that Joan had changed her name
      # and we wanted to find the versions before that change.
      #
      # Based on the data type of the `object` column, the appropriate SQL
      # operator is used. For example, a text column will use `like`, and a
      # jsonb column will use `@>`.
      #
      # @api public
      def where_object(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObject.new(self, args).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects_changes` column.
      #
      # ```
      # SELECT "versions".*
      # FROM "versions"
      # WHERE .. ("versions"."object_changes" LIKE '%
      # name:
      # - Joan
      # %' OR "versions"."object_changes" LIKE '%
      # name:
      # -%
      # - Joan
      # %')
      # ```
      #
      # This is useful for finding versions immediately before and after a given
      # attribute had a given value. Imagine, in the example above, that someone
      # changed their name to Joan and we wanted to find the versions
      # immediately before and after that change.
      #
      # Based on the data type of the `object` column, the appropriate SQL
      # operator is used. For example, a text column will use `like`, and a
      # jsonb column will use `@>`.
      #
      # @api public
      def where_object_changes(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObjectChanges.new(self, args).execute
      end

      # Given a hash of attributes like `name: 'Joan'`, query the
      # `versions.objects_changes` column for changes where the version changed
      # from the hash of attributes to other values.
      #
      # This is useful for finding versions where the attribute started with a
      # known value and changed to something else. This is in comparison to
      # `where_object_changes` which will find both the changes before and
      # after.
      #
      # @api public
      def where_object_changes_from(args = {})
        raise ArgumentError, "expected to receive a Hash" unless args.is_a?(Hash)
        Queries::Versions::WhereObjectChangesFrom.new(self, args).execute
      end

      def primary_key_is_int?
        @primary_key_is_int ||= columns_hash[primary_key].type == :integer
      rescue StandardError # TODO: Rescue something more specific
        true
      end

      # Returns whether the `object` column is using the `json` type supported
      # by PostgreSQL.
      def object_col_is_json?
        %i[json jsonb].include?(columns_hash["object"].type)
      end

      # Returns whether the `object_changes` column is using the `json` type
      # supported by PostgreSQL.
      def object_changes_col_is_json?
        %i[json jsonb].include?(columns_hash["object_changes"].try(:type))
      end

      # Returns versions before `obj`.
      #
      # @param obj - a `Version` or a timestamp
      # @param timestamp_arg - boolean - When true, `obj` is a timestamp.
      #   Default: false.
      # @return `ActiveRecord::Relation`
      # @api public
      # rubocop:disable Style/OptionalBooleanParameter
      def preceding(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          preceding_by_id(obj)
        else
          preceding_by_timestamp(obj)
        end
      end
      # rubocop:enable Style/OptionalBooleanParameter

      # Returns versions after `obj`.
      #
      # @param obj - a `Version` or a timestamp
      # @param timestamp_arg - boolean - When true, `obj` is a timestamp.
      #   Default: false.
      # @return `ActiveRecord::Relation`
      # @api public
      # rubocop:disable Style/OptionalBooleanParameter
      def subsequent(obj, timestamp_arg = false)
        if timestamp_arg != true && primary_key_is_int?
          subsequent_by_id(obj)
        else
          subsequent_by_timestamp(obj)
        end
      end
      # rubocop:enable Style/OptionalBooleanParameter

      private

      # @api private
      def preceding_by_id(obj)
        where(arel_table[primary_key].lt(obj.id)).order(arel_table[primary_key].desc)
      end

      # @api private
      def preceding_by_timestamp(obj)
        obj = obj.send(:created_at) if obj.is_a?(self)
        where(arel_table[:created_at].lt(obj)).
          order(timestamp_sort_order("desc"))
      end

      # @api private
      def subsequent_by_id(version)
        where(arel_table[primary_key].gt(version.id)).order(arel_table[primary_key].asc)
      end

      # @api private
      def subsequent_by_timestamp(obj)
        obj = obj.send(:created_at) if obj.is_a?(self)
        where(arel_table[:created_at].gt(obj)).order(timestamp_sort_order)
      end
    end

    # @api private
    def object_deserialized
      if self.class.object_col_is_json?
        object
      else
        PaperTrail.serializer.load(object)
      end
    end

    # Restore the item from this version.
    #
    # Options:
    #
    # - :mark_for_destruction
    #   - `true` - Mark the has_one/has_many associations that did not exist in
    #     the reified version for destruction, instead of removing them.
    #   - `false` - Default. Useful for persisting the reified version.
    # - :dup
    #   - `false` - Default.
    #   - `true` - Always create a new object instance. Useful for
    #     comparing two versions of the same object.
    # - :unversioned_attributes
    #   - `:nil` - Default. Attributes undefined in version record are set to
    #     nil in reified record.
    #   - `:preserve` - Attributes undefined in version record are not modified.
    #
    def reify(options = {})
      unless self.class.column_names.include? "object"
        raise "reify can't be called without an object column"
      end
      return nil if object.nil?
      ::PaperTrail::Reifier.reify(self, options)
    end

    # Returns what changed in this version of the item.
    # `ActiveModel::Dirty#changes`. returns `nil` if your `versions` table does
    # not have an `object_changes` text column.
    def changeset
      return nil unless self.class.column_names.include? "object_changes"
      @changeset ||= load_changeset
    end

    # Returns who put the item into the state stored in this version.
    def paper_trail_originator
      @paper_trail_originator ||= previous.try(:whodunnit)
    end

    # Returns who changed the item from the state it had in this version. This
    # is an alias for `whodunnit`.
    def terminator
      @terminator ||= whodunnit
    end
    alias version_author terminator

    def next
      @next ||= sibling_versions.subsequent(self).first
    end

    def previous
      @previous ||= sibling_versions.preceding(self).first
    end

    # Returns an integer representing the chronological position of the
    # version among its siblings. The "create" event, for example, has an index
    # of 0.
    #
    # @api public
    def index
      @index ||= RecordHistory.new(sibling_versions, self.class).index(self)
    end

    private

    # @api private
    def load_changeset
      if PaperTrail.config.object_changes_adapter.respond_to?(:load_changeset)
        return PaperTrail.config.object_changes_adapter.load_changeset(self)
      end

      # First, deserialize the `object_changes` column.
      changes = HashWithIndifferentAccess.new(object_changes_deserialized)

      # The next step is, perhaps unfortunately, called "de-serialization",
      # and appears to be responsible for custom attribute serializers. For an
      # example of a custom attribute serializer, see
      # `Person::TimeZoneSerializer` in the test suite.
      #
      # Is `item.class` good enough? Does it handle `inheritance_column`
      # as well as `Reifier#version_reification_class`? We were using
      # `item_type.constantize`, but that is problematic when the STI parent
      # is not versioned. (See `Vehicle` and `Car` in the test suite).
      #
      # Note: `item` returns nil if `event` is "destroy".
      unless item.nil?
        AttributeSerializers::ObjectChangesAttribute.
          new(item.class).
          deserialize(changes)
      end

      # Finally, return a Hash mapping each attribute name to
      # a two-element array representing before and after.
      changes
    end

    # If the `object_changes` column is a Postgres JSON column, then
    # ActiveRecord will deserialize it for us. Otherwise, it's a string column
    # and we must deserialize it ourselves.
    # @api private
    def object_changes_deserialized
      if self.class.object_changes_col_is_json?
        object_changes
      else
        begin
          PaperTrail.serializer.load(object_changes)
        rescue StandardError # TODO: Rescue something more specific
          {}
        end
      end
    end

    # Enforces the `version_limit`, if set. Default: no limit.
    # @api private
    def enforce_version_limit!
      limit = version_limit
      return unless limit.is_a? Numeric
      previous_versions = sibling_versions.not_creates.
        order(self.class.timestamp_sort_order("asc"))
      return unless previous_versions.size > limit
      excess_versions = previous_versions - previous_versions.last(limit)
      excess_versions.map(&:destroy)
    end

    # @api private
    def sibling_versions
      @sibling_versions ||= self.class.with_item_keys(item_type, item_id)
    end

    # See docs section 2.e. Limiting the Number of Versions Created.
    # The version limit can be global or per-model.
    #
    # @api private
    #
    # TODO: Duplication: similar `constantize` in Reifier#version_reification_class
    def version_limit
      if self.class.item_subtype_column_present?
        klass = (item_subtype || item_type).constantize
        if klass&.paper_trail_options&.key?(:limit)
          return klass.paper_trail_options[:limit]
        end
      end
      PaperTrail.config.version_limit
    end
  end
end