trakable 0.2.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/.rubocop.yml +81 -0
  3. data/CHANGELOG.md +50 -0
  4. data/LICENSE +21 -0
  5. data/README.md +330 -0
  6. data/Rakefile +16 -0
  7. data/benchmark/full_benchmark.rb +221 -0
  8. data/benchmark/integration_memory.rb +70 -0
  9. data/benchmark/memory_benchmark.rb +141 -0
  10. data/benchmark/perf_benchmark.rb +130 -0
  11. data/integration/README.md +65 -0
  12. data/integration/run_all.rb +62 -0
  13. data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
  14. data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
  15. data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
  16. data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
  17. data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
  18. data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
  19. data/integration/scenarios/07-global-config/scenario.rb +52 -0
  20. data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
  21. data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
  22. data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
  23. data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
  24. data/integration/scenarios/12-metadata/scenario.rb +54 -0
  25. data/integration/scenarios/13-traks-association/scenario.rb +80 -0
  26. data/integration/scenarios/14-time-travel/scenario.rb +132 -0
  27. data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
  28. data/integration/scenarios/16-serialization/scenario.rb +159 -0
  29. data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
  30. data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
  31. data/integration/scenarios/19-transactions/scenario.rb +89 -0
  32. data/integration/scenarios/20-performance/scenario.rb +89 -0
  33. data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
  34. data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
  35. data/integration/scenarios/23-sti/scenario.rb +58 -0
  36. data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
  37. data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
  38. data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
  39. data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
  40. data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
  41. data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
  42. data/integration/scenarios/30-custom-events/scenario.rb +45 -0
  43. data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
  44. data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
  45. data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
  46. data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
  47. data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
  48. data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
  49. data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
  50. data/integration/scenarios/38-concurrency/scenario.rb +163 -0
  51. data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
  52. data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
  53. data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
  54. data/integration/scenarios/scenario_runner.rb +68 -0
  55. data/lib/generators/trakable/install_generator.rb +28 -0
  56. data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
  57. data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
  58. data/lib/trakable/cleanup.rb +89 -0
  59. data/lib/trakable/config.rb +22 -0
  60. data/lib/trakable/context.rb +85 -0
  61. data/lib/trakable/controller.rb +25 -0
  62. data/lib/trakable/model.rb +99 -0
  63. data/lib/trakable/railtie.rb +28 -0
  64. data/lib/trakable/revertable.rb +166 -0
  65. data/lib/trakable/tracker.rb +134 -0
  66. data/lib/trakable/trak.rb +98 -0
  67. data/lib/trakable/version.rb +5 -0
  68. data/lib/trakable.rb +51 -0
  69. data/trakable.gemspec +41 -0
  70. metadata +242 -0
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateTraks < ActiveRecord::Migration[7.1]
4
+ def change
5
+ create_table :traks do |t|
6
+ t.string :item_type, null: false
7
+ t.bigint :item_id, null: false
8
+ t.string :event, null: false
9
+ t.text :object
10
+ t.text :changeset
11
+ t.string :whodunnit_type
12
+ t.bigint :whodunnit_id
13
+ t.text :metadata
14
+ t.datetime :created_at, null: false
15
+ end
16
+
17
+ add_index :traks, %i[item_type item_id]
18
+ add_index :traks, :created_at
19
+ add_index :traks, %i[whodunnit_type whodunnit_id]
20
+ add_index :traks, :event
21
+ add_index :traks, %i[item_type created_at]
22
+ end
23
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Trakable configuration
4
+ # See https://github.com/hadrienblanc/trakable for more options
5
+
6
+ Trakable.configure do |config|
7
+ # Enable/disable tracking globally
8
+ # config.enabled = true
9
+
10
+ # Attributes to ignore by default
11
+ # config.ignored_attrs = %w[created_at updated_at id]
12
+
13
+ # Controller method that returns the current user (default: :current_user)
14
+ # config.whodunnit_method = :current_user
15
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ # Cleanup handles retention and max_traks pruning for Trak records.
5
+ #
6
+ # Intended to run from background jobs, not synchronously.
7
+ #
8
+ # Usage:
9
+ # # Per-record cleanup
10
+ # Trakable::Cleanup.run(record)
11
+ #
12
+ # # Bulk retention cleanup (in a cron/background job)
13
+ # Trakable::Cleanup.run_retention(Post)
14
+ # Trakable::Cleanup.run_retention(Post, batch_size: 5_000)
15
+ #
16
+ class Cleanup
17
+ BATCH_SIZE = 1_000
18
+
19
+ attr_reader :record
20
+
21
+ def initialize(record)
22
+ @record = record
23
+ end
24
+
25
+ # Run cleanup for a single record.
26
+ #
27
+ # @param record [ActiveRecord::Base] The record with traks to clean up
28
+ #
29
+ def self.run(record)
30
+ new(record).run
31
+ end
32
+
33
+ def run # rubocop:disable Naming/PredicateMethod
34
+ enforce_max_traks
35
+ true
36
+ end
37
+
38
+ # Run retention cleanup for all records of a model class.
39
+ # Deletes in batches to avoid locking the table on large datasets.
40
+ #
41
+ # @param model_class [Class] The model class to clean up
42
+ # @param retention_period [Integer, nil] Override retention period in seconds
43
+ # @param batch_size [Integer] Number of rows to delete per batch (default: 1_000)
44
+ # @return [Integer, nil] Total number of deleted rows, or nil if no retention configured
45
+ #
46
+ def self.run_retention(model_class, retention_period: nil, batch_size: BATCH_SIZE)
47
+ retention = retention_period || model_class.trakable_options[:retention]
48
+ return nil unless retention
49
+
50
+ trak_class = resolve_trak_class
51
+ return 0 unless trak_class.respond_to?(:where)
52
+
53
+ cutoff = Time.now - retention
54
+ scope = trak_class.where(item_type: model_class.to_s)
55
+ .where(trak_class.arel_table[:created_at].lt(cutoff))
56
+
57
+ delete_in_batches(scope, batch_size)
58
+ end
59
+
60
+ def self.resolve_trak_class
61
+ Trakable::Trak
62
+ end
63
+ private_class_method :resolve_trak_class
64
+
65
+ def self.delete_in_batches(scope, batch_size)
66
+ total = 0
67
+ loop do
68
+ deleted = scope.limit(batch_size).delete_all
69
+ total += deleted
70
+ break if deleted < batch_size
71
+ end
72
+ total
73
+ end
74
+ private_class_method :delete_in_batches
75
+
76
+ private
77
+
78
+ def enforce_max_traks
79
+ max = record.trakable_options[:max_traks]
80
+ return unless max
81
+ return unless record.respond_to?(:traks)
82
+
83
+ traks = record.traks
84
+ return unless traks.respond_to?(:where)
85
+
86
+ traks.order(created_at: :desc).offset(max).delete_all
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ # Stores global configuration for Trakable gem.
5
+ # Use Trakable.configure to set options.
6
+ class Configuration
7
+ attr_accessor :enabled, :whodunnit_method
8
+
9
+ def initialize
10
+ @enabled = true
11
+ @ignored_attrs = %w[created_at updated_at id]
12
+ @whodunnit_method = :current_user
13
+ end
14
+
15
+ # Ensure ignored_attrs are always stored as strings for performance
16
+ def ignored_attrs=(attrs)
17
+ @ignored_attrs = Array(attrs).map(&:to_s)
18
+ end
19
+
20
+ attr_reader :ignored_attrs
21
+ end
22
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ # Thread-safe context for storing whodunnit and tracking state
5
+ class Context
6
+ THREAD_KEY = :trakable_context
7
+
8
+ class << self
9
+ def whodunnit
10
+ context[:whodunnit]
11
+ end
12
+
13
+ def whodunnit=(value)
14
+ context[:whodunnit] = value
15
+ end
16
+
17
+ def metadata
18
+ context[:metadata]
19
+ end
20
+
21
+ def metadata=(value)
22
+ context[:metadata] = value
23
+ end
24
+
25
+ def tracking_enabled?
26
+ return Trakable.enabled? unless context.key?(:tracking_enabled)
27
+
28
+ context[:tracking_enabled]
29
+ end
30
+
31
+ def tracking_enabled=(value)
32
+ context[:tracking_enabled] = value
33
+ end
34
+
35
+ def with_user(user)
36
+ raise ArgumentError, 'with_user requires a block' unless block_given?
37
+
38
+ previous = whodunnit
39
+ self.whodunnit = user
40
+ yield
41
+ ensure
42
+ self.whodunnit = previous
43
+ end
44
+
45
+ def with_tracking
46
+ raise ArgumentError, 'with_tracking requires a block' unless block_given?
47
+
48
+ previous = context[:tracking_enabled]
49
+ self.tracking_enabled = true
50
+ yield
51
+ ensure
52
+ if previous.nil?
53
+ context.delete(:tracking_enabled)
54
+ else
55
+ self.tracking_enabled = previous
56
+ end
57
+ end
58
+
59
+ def without_tracking
60
+ raise ArgumentError, 'without_tracking requires a block' unless block_given?
61
+
62
+ previous = context[:tracking_enabled]
63
+ self.tracking_enabled = false
64
+ yield
65
+ ensure
66
+ if previous.nil?
67
+ context.delete(:tracking_enabled)
68
+ else
69
+ self.tracking_enabled = previous
70
+ end
71
+ end
72
+
73
+ def reset!
74
+ Thread.current.thread_variable_set(THREAD_KEY, nil)
75
+ end
76
+
77
+ private
78
+
79
+ def context
80
+ Thread.current.thread_variable_get(THREAD_KEY) ||
81
+ Thread.current.thread_variable_set(THREAD_KEY, {})
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ module Trakable
6
+ # Controller concern for automatically setting whodunnit.
7
+ #
8
+ # Auto-included in ActionController::Base via Railtie.
9
+ # Uses Trakable.configuration.whodunnit_method (default: :current_user).
10
+ #
11
+ module Controller
12
+ extend ActiveSupport::Concern
13
+
14
+ included do
15
+ around_action :_set_trakable_whodunnit if respond_to?(:around_action)
16
+ end
17
+
18
+ private
19
+
20
+ def _set_trakable_whodunnit(&)
21
+ user = send(Trakable.configuration.whodunnit_method)
22
+ Trakable.with_user(user, &)
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/class/attribute'
5
+
6
+ module Trakable
7
+ # Trakable Model Concern
8
+ #
9
+ # Include this in your ActiveRecord models to enable tracking:
10
+ #
11
+ # class Post < ApplicationRecord
12
+ # include Trakable::Model
13
+ # trakable only: %i[title body], ignore: %i[views_count]
14
+ # end
15
+ #
16
+ module Model
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ # Store trakable options at class level
21
+ class_attribute :trakable_options, instance_writer: false, default: {}
22
+
23
+ # has_many :traks association
24
+ has_many :traks, as: :item, class_name: 'Trakable::Trak', dependent: :nullify
25
+
26
+ # Include revertable methods
27
+ include ModelRevertable
28
+ end
29
+
30
+ class_methods do
31
+ # Configure tracking for this model
32
+ #
33
+ # Options:
34
+ # only: Array of attrs to track (default: all except ignored)
35
+ # ignore: Array of attrs to skip (default: global ignored_attrs)
36
+ # if: Proc/Method name - track only if true
37
+ # unless: Proc/Method name - skip tracking if true
38
+ # on: Array of events to track (default: %i[create update destroy])
39
+ # callback_type: :after (default) or :after_commit
40
+ #
41
+ def trakable(options = {})
42
+ normalized = options.dup
43
+ callback_type = normalized.delete(:callback_type) || :after
44
+
45
+ # Pre-convert symbols to strings for performance
46
+ normalized[:only] = Array(normalized[:only]).map(&:to_s) if normalized[:only]
47
+ normalized[:ignore] = Array(normalized[:ignore]).map(&:to_s) if normalized[:ignore]
48
+
49
+ self.trakable_options = normalized
50
+
51
+ register_trakable_callbacks(normalized[:on], callback_type)
52
+ end
53
+
54
+ private
55
+
56
+ def register_trakable_callbacks(events, callback_type = :after)
57
+ events = Array(events).presence || %i[create update destroy]
58
+
59
+ if callback_type == :after_commit
60
+ register_after_commit_callbacks(events)
61
+ else
62
+ register_after_callbacks(events)
63
+ end
64
+ end
65
+
66
+ def register_after_callbacks(events)
67
+ events.each do |event|
68
+ case event.to_sym
69
+ when :create then after_create :trak_create
70
+ when :update then after_update :trak_update
71
+ when :destroy then after_destroy :trak_destroy
72
+ end
73
+ end
74
+ end
75
+
76
+ def register_after_commit_callbacks(events)
77
+ events.each do |event|
78
+ case event.to_sym
79
+ when :create then after_commit(on: :create, &:trak_create)
80
+ when :update then after_commit(on: :update, &:trak_update)
81
+ when :destroy then after_commit(on: :destroy, &:trak_destroy)
82
+ end
83
+ end
84
+ end
85
+ end
86
+
87
+ def trak_create
88
+ Trakable::Tracker.call(self, 'create')
89
+ end
90
+
91
+ def trak_update
92
+ Trakable::Tracker.call(self, 'update')
93
+ end
94
+
95
+ def trak_destroy
96
+ Trakable::Tracker.call(self, 'destroy')
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ class Railtie < ::Rails::Railtie
5
+ generators do
6
+ require 'generators/trakable/install_generator'
7
+ end
8
+
9
+ initializer 'trakable.configure' do |app|
10
+ if app.config.respond_to?(:trakable)
11
+ Trakable.configure do |config|
12
+ config.enabled = app.config.trakable.enabled if app.config.trakable.respond_to?(:enabled)
13
+ config.ignored_attrs = app.config.trakable.ignored_attrs if app.config.trakable.respond_to?(:ignored_attrs)
14
+ if app.config.trakable.respond_to?(:whodunnit_method)
15
+ config.whodunnit_method = app.config.trakable.whodunnit_method
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ # Auto-include Controller concern — no manual include needed
22
+ initializer 'trakable.controller' do
23
+ ActiveSupport.on_load(:action_controller_base) do
24
+ include Trakable::Controller
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ # Revertable provides methods for restoring previous states from Traks.
5
+ #
6
+ # Included in Trak to provide:
7
+ # - reify: Build a non-persisted record with state at this Trak
8
+ # - revert!: Restore the record to the state before this Trak
9
+ #
10
+ # Included in Model to provide:
11
+ # - trak_at: Get state at a specific timestamp
12
+ #
13
+ module Revertable
14
+ # Build a non-persisted record with the state stored in this Trak.
15
+ #
16
+ # For update/destroy traks, uses the object (state before change).
17
+ # For create traks, returns nil (no previous state exists).
18
+ #
19
+ # @return [ActiveRecord::Base, nil] Non-persisted record or nil for create events
20
+ #
21
+ # @example
22
+ # trak = post.traks.last
23
+ # previous_state = trak.reify
24
+ # previous_state.title # => "Old Title"
25
+ # previous_state.persisted? # => false
26
+ #
27
+ def reify
28
+ return nil if create?
29
+ return nil if object.nil? || object.empty?
30
+
31
+ current = item
32
+
33
+ # For update traks, delta storage requires the current record
34
+ # to reconstruct full previous state. Without it, return nil.
35
+ return nil if update? && current.nil?
36
+
37
+ model_class.new.tap do |record|
38
+ if current
39
+ current.attributes.except('id').each do |attr, val|
40
+ record.write_attribute(attr, val) if record.respond_to?(attr)
41
+ end
42
+ end
43
+ # Apply stored old values on top (delta or full snapshot)
44
+ object.each do |attr, value|
45
+ record.write_attribute(attr, value) if record.respond_to?(attr)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Restore the record to the state before this Trak was created.
51
+ #
52
+ # For create traks: destroys the record
53
+ # For update traks: restores attributes before the change
54
+ # For destroy traks: re-creates the record with old attributes (new primary key)
55
+ #
56
+ # @param trak_revert [Boolean] Whether to create a new trak for this revert
57
+ # @return [ActiveRecord::Base, true, false] The restored record or true/false for success
58
+ #
59
+ # @example
60
+ # post.traks.last.revert! # => restores previous state
61
+ # post.traks.last.revert!(trak_revert: true) # => restores and creates a trak
62
+ #
63
+ def revert!(trak_revert: false)
64
+ case event
65
+ when 'create'
66
+ perform_revert_create(trak_revert: trak_revert)
67
+ when 'update'
68
+ perform_revert_update(trak_revert: trak_revert)
69
+ when 'destroy'
70
+ perform_revert_destroy(trak_revert: trak_revert)
71
+ end
72
+ end
73
+
74
+ private
75
+
76
+ def model_class
77
+ item_type.constantize
78
+ rescue NameError
79
+ raise "Cannot reify: model class #{item_type} not found"
80
+ end
81
+
82
+ def perform_revert_create(trak_revert:) # rubocop:disable Naming/PredicateMethod
83
+ target = item
84
+ return false unless target
85
+
86
+ Trakable.without_tracking { target.destroy }
87
+
88
+ build_revert_trak if trak_revert
89
+ true
90
+ end
91
+
92
+ def perform_revert_update(trak_revert:)
93
+ target = item
94
+ return false unless target
95
+
96
+ restored = reify
97
+ return false unless restored
98
+
99
+ Trakable.without_tracking do
100
+ object&.each do |attr, value|
101
+ target.write_attribute(attr, value) if target.respond_to?(attr)
102
+ end
103
+ target.save!(validate: false)
104
+ end
105
+
106
+ build_revert_trak if trak_revert
107
+ target
108
+ end
109
+
110
+ def perform_revert_destroy(trak_revert:)
111
+ restored = reify
112
+ return false unless restored
113
+
114
+ Trakable.without_tracking { restored.save!(validate: false) }
115
+
116
+ build_revert_trak(restored) if trak_revert
117
+ restored
118
+ end
119
+
120
+ def build_revert_trak(restored_item = nil)
121
+ Trakable::Tracker.call(restored_item || item, 'revert')
122
+ end
123
+ end
124
+
125
+ # Module for Model concern to add trak_at method
126
+ module ModelRevertable
127
+ # Get the state of this record at a specific point in time.
128
+ #
129
+ # @param timestamp [Time, DateTime] The point in time
130
+ # @return [ActiveRecord::Base, nil] Non-persisted record with state at that time, or nil
131
+ #
132
+ # @example
133
+ # post.trak_at(1.day.ago) # => post state from 1 day ago
134
+ # post.trak_at(Time.now + 1.hour) # => current state (future returns current)
135
+ #
136
+ def trak_at(timestamp)
137
+ timestamp = timestamp.to_time if timestamp.respond_to?(:to_time)
138
+
139
+ return nil if before_creation?(timestamp)
140
+
141
+ target_trak = find_trak_at(timestamp)
142
+
143
+ reify_or_dup(target_trak)
144
+ end
145
+
146
+ private
147
+
148
+ def before_creation?(timestamp)
149
+ respond_to?(:created_at) && created_at && timestamp < created_at
150
+ end
151
+
152
+ def find_trak_at(timestamp)
153
+ if traks.respond_to?(:where)
154
+ traks.where('created_at <= ?', timestamp).order(created_at: :desc).first
155
+ else
156
+ traks.select { |t| t.created_at <= timestamp }.max_by(&:created_at)
157
+ end
158
+ end
159
+
160
+ def reify_or_dup(target_trak)
161
+ return dup.tap { |r| r.id = nil } if target_trak.nil?
162
+
163
+ target_trak.reify || dup.tap { |r| r.id = nil }
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Trakable
4
+ # Tracker is responsible for building Trak records from ActiveRecord callbacks.
5
+ #
6
+ # Usage:
7
+ # Trakable::Tracker.call(record, 'create')
8
+ # Trakable::Tracker.call(record, 'update')
9
+ # Trakable::Tracker.call(record, 'destroy')
10
+ #
11
+ class Tracker
12
+ attr_reader :record, :event
13
+
14
+ def initialize(record, event)
15
+ @record = record
16
+ @event = event.to_s
17
+ end
18
+
19
+ # Entry point: build a Trak for the given record/event
20
+ def self.call(record, event)
21
+ new(record, event).call
22
+ end
23
+
24
+ def call
25
+ return unless tracking_enabled?
26
+ return if skip?
27
+
28
+ build_trak
29
+ end
30
+
31
+ private
32
+
33
+ def tracking_enabled?
34
+ return false unless Trakable.enabled?
35
+
36
+ Context.tracking_enabled?
37
+ end
38
+
39
+ def skip?
40
+ return false unless trakable_record?
41
+
42
+ skip_if_condition? || skip_unless_condition?
43
+ end
44
+
45
+ def trakable_record?
46
+ return @trakable_record if defined?(@trakable_record)
47
+
48
+ @trakable_record = record.respond_to?(:trakable_options)
49
+ end
50
+
51
+ def skip_if_condition?
52
+ condition = record.trakable_options[:if]
53
+ condition && !record.instance_eval(&condition)
54
+ end
55
+
56
+ def skip_unless_condition?
57
+ condition = record.trakable_options[:unless]
58
+ condition && record.instance_eval(&condition)
59
+ end
60
+
61
+ def build_trak
62
+ trak = Trak.build(
63
+ item: record,
64
+ event: event,
65
+ changeset: changeset,
66
+ object: object_state,
67
+ whodunnit: whodunnit,
68
+ metadata: metadata
69
+ )
70
+ trak.save! if defined?(ActiveRecord::Base) && trak.is_a?(ActiveRecord::Base)
71
+ trak
72
+ end
73
+
74
+ def object_state
75
+ case event
76
+ when 'create'
77
+ nil
78
+ when 'update'
79
+ build_object_from_previous
80
+ else
81
+ record.attributes.except('id')
82
+ end
83
+ end
84
+
85
+ def changeset
86
+ return {} if event == 'destroy'
87
+
88
+ filter_changeset(record.previous_changes)
89
+ end
90
+
91
+ def filter_changeset(changes)
92
+ return {} if changes.empty?
93
+
94
+ result = apply_only_filter(changes)
95
+ apply_ignore_filter(result)
96
+ end
97
+
98
+ def apply_only_filter(changes)
99
+ return changes unless trakable_record?
100
+
101
+ only = record.trakable_options[:only]
102
+ return changes unless only
103
+
104
+ changes.slice(*only)
105
+ end
106
+
107
+ def apply_ignore_filter(changes)
108
+ ignore = []
109
+ if trakable_record?
110
+ record_ignore = record.trakable_options[:ignore]
111
+ ignore.concat(record_ignore) if record_ignore
112
+ end
113
+ global = Trakable.configuration.ignored_attrs
114
+ ignore.concat(global) if global&.any?
115
+ return changes unless ignore.any?
116
+
117
+ changes.except(*ignore)
118
+ end
119
+
120
+ def build_object_from_previous
121
+ delta = {}
122
+ record.previous_changes.each { |attr, (old, _)| delta[attr] = old }
123
+ delta
124
+ end
125
+
126
+ def whodunnit
127
+ Context.whodunnit
128
+ end
129
+
130
+ def metadata
131
+ Context.metadata
132
+ end
133
+ end
134
+ end