rails_audit_log 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 99754d2a1e8f4d7bba4cff5815b8bd6df8e8569f8817da525611e97a586d3114
4
- data.tar.gz: 9fe8b370070705d1eb5685bceaa82474b63f981a51b22911db337793dabe38ff
3
+ metadata.gz: cb82307e35624fe226336c4fb33559b5432a6290c4978076aadec80e6939afa0
4
+ data.tar.gz: 47e700f38e3d1fbe2a0953ba687285389721b31e8a6531dcbc5e336c6d9af438
5
5
  SHA512:
6
- metadata.gz: d7122bfd0a99dd298a48597690267a050a8baac1c7d7152f16e2b5766b3e4fac60b1d334f1fdc897a35d6e5b9dd26019204497ec73386257c3eecbe02ea9e36a
7
- data.tar.gz: fea300d386237a044f3f57eb3fe94ac0109b5d8ec4e5704bcacdf80137bfeff8bb3df2ae05ee67baaaeee72b9f6a05f86cd8da625603d87b2fd9f801e27dd1c5
6
+ metadata.gz: 5dc66b780e8d1875260ef21c143b3ec823832ffa7e90c58f8392e002515d4cbe30c797936f4766bd15791a9b1b37e2f8918973f33fec58e396ca117a3623a563
7
+ data.tar.gz: d7fbf0c15e39d95a57270203b296ce5bac2835cc15f69d44cd6a7b000a884bc8d639cee91de5c1dd84aeaec64795fa6325397a1926a67f9a8317cc1128c18808
data/README.md CHANGED
@@ -39,7 +39,7 @@ Every `create`, `update`, and `destroy` is now recorded automatically:
39
39
 
40
40
  ```ruby
41
41
  article = Article.create!(title: "Hello")
42
- article.audit_log_entries.count # => 1
42
+ article.audit_log_entries.count # => 1
43
43
  article.audit_log_entries.first.event # => "create"
44
44
 
45
45
  article.update!(title: "World")
@@ -77,6 +77,123 @@ RailsAuditLog.with_actor(current_user) do
77
77
  end
78
78
  ```
79
79
 
80
+ ### Querying the audit log
81
+
82
+ ```ruby
83
+ # Event scopes
84
+ article.audit_log_entries.created_events
85
+ article.audit_log_entries.updated_events
86
+ article.audit_log_entries.destroyed_events
87
+
88
+ # Filter by actor, resource, or time
89
+ RailsAuditLog::AuditLogEntry.by_actor(current_user)
90
+ RailsAuditLog::AuditLogEntry.for_resource(Article)
91
+ RailsAuditLog::AuditLogEntry.for_resource(article)
92
+ RailsAuditLog::AuditLogEntry.since(1.week.ago)
93
+ RailsAuditLog::AuditLogEntry.until(Date.yesterday)
94
+
95
+ # Find entries that touched a specific attribute
96
+ RailsAuditLog::AuditLogEntry.touching(:title)
97
+
98
+ # Inspect what changed
99
+ entry = article.audit_log_entries.last
100
+ entry.changed_attributes # => ["title"]
101
+ entry.diff
102
+ # => { "title" => { from: "Hello", to: "World" } }
103
+ ```
104
+
105
+ ### Selective tracking
106
+
107
+ Track only specific attributes, or exclude noisy ones:
108
+
109
+ ```ruby
110
+ class Article < ApplicationRecord
111
+ include RailsAuditLog::Auditable
112
+
113
+ # Track only these columns
114
+ audit_log only: [:title, :status]
115
+
116
+ # Or exclude specific columns
117
+ audit_log ignore: [:cached_at, :views_count]
118
+ end
119
+ ```
120
+
121
+ Configure global defaults in an initializer:
122
+
123
+ ```ruby
124
+ # config/initializers/rails_audit_log.rb
125
+ RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
126
+ ```
127
+
128
+ Updates that produce no tracked changes after filtering are silently skipped.
129
+
130
+ ### Disabling auditing
131
+
132
+ Suppress all audit writes inside a block — thread-safe, works in jobs and imports:
133
+
134
+ ```ruby
135
+ RailsAuditLog.disable do
136
+ Article.insert_all!(bulk_rows)
137
+ end
138
+
139
+ RailsAuditLog.enabled? # => true (restored after the block)
140
+ ```
141
+
142
+ Disable on a specific record instance:
143
+
144
+ ```ruby
145
+ article.skip_audit_log { article.update!(cached_at: Time.current) }
146
+ ```
147
+
148
+ ### Object reconstruction
149
+
150
+ #### Reify a single entry
151
+
152
+ `AuditLogEntry#reify` returns an unsaved ActiveRecord instance reflecting the record's state **before** the entry was recorded:
153
+
154
+ ```ruby
155
+ article.update!(title: "v2")
156
+ entry = article.audit_log_entries.updated_events.last
157
+
158
+ previous = entry.reify
159
+ previous.title # => "v1" (the pre-update state)
160
+ previous.persisted? # => false
161
+ ```
162
+
163
+ Returns `nil` for `create` entries (nothing existed before).
164
+
165
+ #### Reconstruct state at any point in time
166
+
167
+ ```ruby
168
+ snapshot = RailsAuditLog.version_at(article, 1.week.ago)
169
+ snapshot.title # => whatever the title was a week ago
170
+ ```
171
+
172
+ Returns `nil` if the record had no history at that time or was already destroyed.
173
+
174
+ #### Navigate the version chain
175
+
176
+ ```ruby
177
+ entry = article.audit_log_entries.updated_events.last
178
+ entry.previous # => the entry before this one
179
+ entry.next # => the entry after this one (nil if last)
180
+ ```
181
+
182
+ ### Object snapshot storage
183
+
184
+ By default every entry stores a full `object` snapshot of the pre-change state alongside `object_changes`. This makes `reify` and `version_at` reliable without any database lookups:
185
+
186
+ ```ruby
187
+ entry.object # => { "id" => 1, "title" => "v1", ... }
188
+ entry.object_changes # => { "title" => ["v1", "v2"] }
189
+ ```
190
+
191
+ To save storage at the cost of reduced reification accuracy, switch to diff-only mode:
192
+
193
+ ```ruby
194
+ RailsAuditLog.store_snapshot = false
195
+ ```
196
+
80
197
  ## Requirements
81
198
 
82
199
  - Ruby >= 3.3
@@ -3,6 +3,9 @@ module RailsAuditLog
3
3
  extend ActiveSupport::Concern
4
4
 
5
5
  included do
6
+ class_attribute :_audit_log_only, default: nil
7
+ class_attribute :_audit_log_ignore, default: nil
8
+
6
9
  has_many :audit_log_entries,
7
10
  class_name: "RailsAuditLog::AuditLogEntry",
8
11
  as: :item,
@@ -13,31 +16,63 @@ module RailsAuditLog
13
16
  after_destroy :record_audit_destroy
14
17
  end
15
18
 
19
+ class_methods do
20
+ def audit_log(only: nil, ignore: nil)
21
+ self._audit_log_only = only.map(&:to_s) if only
22
+ self._audit_log_ignore = ignore.map(&:to_s) if ignore
23
+ end
24
+ end
25
+
26
+ def skip_audit_log
27
+ RailsAuditLog.disable { yield }
28
+ end
29
+
16
30
  private
17
31
 
18
32
  def record_audit_create
19
- record_audit_entry("create", saved_changes)
33
+ record_audit_entry("create", saved_changes, nil)
20
34
  end
21
35
 
22
36
  def record_audit_update
23
- record_audit_entry("update", saved_changes)
37
+ snapshot = attributes.merge(saved_changes.transform_values { |v| v[0] }) if RailsAuditLog.store_snapshot
38
+ record_audit_entry("update", saved_changes, snapshot)
24
39
  end
25
40
 
26
41
  def record_audit_destroy
42
+ snapshot = attributes.dup if RailsAuditLog.store_snapshot
27
43
  changes = attributes.transform_values { |v| [v, nil] }
28
- record_audit_entry("destroy", changes)
44
+ record_audit_entry("destroy", changes, snapshot)
29
45
  end
30
46
 
31
- def record_audit_entry(event, changes)
47
+ def record_audit_entry(event, changes, snapshot = nil)
48
+ return unless RailsAuditLog.enabled?
49
+
50
+ filtered = filter_changes(changes)
51
+ return if filtered.empty? && event == "update"
52
+
32
53
  actor = RailsAuditLog.actor
33
54
  RailsAuditLog::AuditLogEntry.create!(
34
55
  event: event,
35
56
  item_type: self.class.name,
36
57
  item_id: id,
37
- object_changes: changes,
58
+ object_changes: filtered,
59
+ object: snapshot,
38
60
  actor_type: actor&.class&.name,
39
61
  actor_id: actor.respond_to?(:id) ? actor.id : nil
40
62
  )
41
63
  end
64
+
65
+ def filter_changes(changes)
66
+ result = changes.dup
67
+
68
+ if self.class._audit_log_only
69
+ result.select! { |k, _| self.class._audit_log_only.include?(k) }
70
+ else
71
+ ignored = self.class._audit_log_ignore || RailsAuditLog.ignored_attributes
72
+ result.reject! { |k, _| ignored.include?(k) }
73
+ end
74
+
75
+ result
76
+ end
42
77
  end
43
78
  end
@@ -38,6 +38,40 @@ module RailsAuditLog
38
38
 
39
39
  # Instance methods
40
40
 
41
+ def reify
42
+ return nil if event == "create"
43
+
44
+ klass = item_type.constantize
45
+
46
+ if object.present?
47
+ instance = klass.new
48
+ instance.assign_attributes(object.except("id"))
49
+ instance.id = object.fetch("id") { item_id }
50
+ return instance
51
+ end
52
+
53
+ # Fallback: diff-only mode or entries recorded before snapshot support
54
+ from_attrs = (object_changes || {}).transform_values { |from_to| from_to[0] }
55
+
56
+ if event == "update"
57
+ record = klass.find_by(id: item_id)
58
+ from_attrs = record.attributes.merge(from_attrs) if record
59
+ end
60
+
61
+ instance = klass.new
62
+ instance.assign_attributes(from_attrs.except("id"))
63
+ instance.id = from_attrs.fetch("id") { item_id }
64
+ instance
65
+ end
66
+
67
+ def previous
68
+ self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
69
+ end
70
+
71
+ def next
72
+ self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
73
+ end
74
+
41
75
  def changed_attributes
42
76
  object_changes&.keys || []
43
77
  end
@@ -48,11 +82,11 @@ module RailsAuditLog
48
82
  object_changes.transform_values { |from_to| { from: from_to[0], to: from_to[1] } }
49
83
  end
50
84
 
51
- # Attribute scope — uses json_extract (SQLite/MySQL) or json ? key (PostgreSQL)
85
+ # Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
52
86
  scope :touching, ->(attribute) {
53
87
  if connection.adapter_name =~ /PostgreSQL/i
54
88
  # :nocov:
55
- where("object_changes ? :key", key: attribute.to_s)
89
+ where("object_changes->>? IS NOT NULL", attribute.to_s)
56
90
  # :nocov:
57
91
  else
58
92
  where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
@@ -5,6 +5,7 @@ class CreateAuditLogEntries < ActiveRecord::Migration[<%= ActiveRecord::Migratio
5
5
  t.string :item_type, null: false
6
6
  t.bigint :item_id, null: false
7
7
  t.json :object_changes
8
+ t.json :object
8
9
  t.string :actor_type
9
10
  t.bigint :actor_id
10
11
  t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
@@ -1,3 +1,3 @@
1
1
  module RailsAuditLog
2
- VERSION = "0.2.0"
2
+ VERSION = "0.4.0"
3
3
  end
@@ -2,6 +2,11 @@ require "rails_audit_log/version"
2
2
  require "rails_audit_log/engine"
3
3
 
4
4
  module RailsAuditLog
5
+ # Global default columns to ignore across all audited models.
6
+ # Override in an initializer: RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
7
+ mattr_accessor :ignored_attributes, default: %w[updated_at]
8
+ mattr_accessor :store_snapshot, default: true
9
+
5
10
  def self.actor
6
11
  Thread.current[:rails_audit_log_actor]
7
12
  end
@@ -17,4 +22,35 @@ module RailsAuditLog
17
22
  ensure
18
23
  self.actor = previous
19
24
  end
25
+
26
+ def self.enabled?
27
+ !Thread.current[:rails_audit_log_disabled]
28
+ end
29
+
30
+ def self.disable
31
+ previous = Thread.current[:rails_audit_log_disabled]
32
+ Thread.current[:rails_audit_log_disabled] = true
33
+ yield
34
+ ensure
35
+ Thread.current[:rails_audit_log_disabled] = previous
36
+ end
37
+
38
+ def self.version_at(record, time)
39
+ entry = AuditLogEntry
40
+ .where(item_type: record.class.name, item_id: record.id)
41
+ .where(created_at: ..time)
42
+ .order(created_at: :desc, id: :desc)
43
+ .first
44
+
45
+ return nil if entry.nil? || entry.event == "destroy"
46
+
47
+ klass = record.class
48
+ to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
49
+ attrs = entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
50
+
51
+ instance = klass.new
52
+ instance.assign_attributes(attrs.except("id"))
53
+ instance.id = attrs.fetch("id") { entry.item_id }
54
+ instance
55
+ end
20
56
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_audit_log
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Chuck Smith