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.
- checksums.yaml +7 -0
- data/.rubocop.yml +81 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +330 -0
- data/Rakefile +16 -0
- data/benchmark/full_benchmark.rb +221 -0
- data/benchmark/integration_memory.rb +70 -0
- data/benchmark/memory_benchmark.rb +141 -0
- data/benchmark/perf_benchmark.rb +130 -0
- data/integration/README.md +65 -0
- data/integration/run_all.rb +62 -0
- data/integration/scenarios/01-basic-tracking/scenario.rb +51 -0
- data/integration/scenarios/02-revert-restoration/scenario.rb +103 -0
- data/integration/scenarios/03-whodunnit-tracking/scenario.rb +72 -0
- data/integration/scenarios/04-cleanup-retention/scenario.rb +66 -0
- data/integration/scenarios/05-without-tracking/scenario.rb +62 -0
- data/integration/scenarios/06-callback-lifecycle/scenario.rb +103 -0
- data/integration/scenarios/07-global-config/scenario.rb +52 -0
- data/integration/scenarios/08-controller-integration/scenario.rb +44 -0
- data/integration/scenarios/09-cleanup-max-traks/scenario.rb +58 -0
- data/integration/scenarios/10-model-configuration/scenario.rb +68 -0
- data/integration/scenarios/11-conditional-tracking/scenario.rb +48 -0
- data/integration/scenarios/12-metadata/scenario.rb +54 -0
- data/integration/scenarios/13-traks-association/scenario.rb +80 -0
- data/integration/scenarios/14-time-travel/scenario.rb +132 -0
- data/integration/scenarios/15-diffing-changeset/scenario.rb +109 -0
- data/integration/scenarios/16-serialization/scenario.rb +159 -0
- data/integration/scenarios/17-associations-tracking/scenario.rb +143 -0
- data/integration/scenarios/18-bulk-operations/scenario.rb +70 -0
- data/integration/scenarios/19-transactions/scenario.rb +89 -0
- data/integration/scenarios/20-performance/scenario.rb +89 -0
- data/integration/scenarios/21-storage-backends/scenario.rb +52 -0
- data/integration/scenarios/22-multi-tenancy/scenario.rb +49 -0
- data/integration/scenarios/23-sti/scenario.rb +58 -0
- data/integration/scenarios/24-edge-cases-part1/scenario.rb +86 -0
- data/integration/scenarios/25-edge-cases-part2/scenario.rb +74 -0
- data/integration/scenarios/26-edge-cases-part3/scenario.rb +76 -0
- data/integration/scenarios/27-api-query-interface/scenario.rb +78 -0
- data/integration/scenarios/28-security-compliance/scenario.rb +61 -0
- data/integration/scenarios/29-soft-delete/scenario.rb +43 -0
- data/integration/scenarios/30-custom-events/scenario.rb +45 -0
- data/integration/scenarios/31-gem-packaging/scenario.rb +58 -0
- data/integration/scenarios/32-bypass-fail-closed/scenario.rb +77 -0
- data/integration/scenarios/33-coexistence-standalone/scenario.rb +53 -0
- data/integration/scenarios/34-real-tracking/scenario.rb +254 -0
- data/integration/scenarios/35-revert-undo/scenario.rb +235 -0
- data/integration/scenarios/36-whodunnit-deep/scenario.rb +281 -0
- data/integration/scenarios/37-real-world-use-cases/scenario.rb +1213 -0
- data/integration/scenarios/38-concurrency/scenario.rb +163 -0
- data/integration/scenarios/39-query-scopes/scenario.rb +126 -0
- data/integration/scenarios/40-whodunnit-config/scenario.rb +113 -0
- data/integration/scenarios/41-batch-cleanup/scenario.rb +186 -0
- data/integration/scenarios/scenario_runner.rb +68 -0
- data/lib/generators/trakable/install_generator.rb +28 -0
- data/lib/generators/trakable/templates/create_traks_migration.rb +23 -0
- data/lib/generators/trakable/templates/trakable_initializer.rb +15 -0
- data/lib/trakable/cleanup.rb +89 -0
- data/lib/trakable/config.rb +22 -0
- data/lib/trakable/context.rb +85 -0
- data/lib/trakable/controller.rb +25 -0
- data/lib/trakable/model.rb +99 -0
- data/lib/trakable/railtie.rb +28 -0
- data/lib/trakable/revertable.rb +166 -0
- data/lib/trakable/tracker.rb +134 -0
- data/lib/trakable/trak.rb +98 -0
- data/lib/trakable/version.rb +5 -0
- data/lib/trakable.rb +51 -0
- data/trakable.gemspec +41 -0
- 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
|