rails_audit_log 0.3.0 → 0.5.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 +4 -4
- data/README.md +180 -1
- data/app/concerns/rails_audit_log/auditable.rb +31 -11
- data/app/concerns/rails_audit_log/controller.rb +17 -0
- data/app/models/rails_audit_log/audit_log_entry.rb +45 -2
- data/lib/generators/rails_audit_log/install/templates/create_audit_log_entries.rb +4 -0
- data/lib/rails_audit_log/version.rb +1 -1
- data/lib/rails_audit_log.rb +48 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cf93211ea95d97c01b541195ade4517d221d39035896e06a6432c512e5f284c0
|
|
4
|
+
data.tar.gz: f1bce85319393eaca9db2e078a0087e1f0818c1025fdd23e472fb8381a322b91
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f0f9def2049dc5ca95e13fad9746bc84a79c7e8081d8e84dd0f4fbfd3915b57b9dd35aaa600687970475f49b395e15c6725d3c558b1272f7afda6ba3b18054c0
|
|
7
|
+
data.tar.gz: 1699b3f01dbfd55377144207ff36c987a83419adf318671a16676e5513fd31fc98f908e5b2f83cef3a9f7b597bdf7ac8fe2cf4da2c275f569bdcd4837a3a1727
|
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
|
|
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,185 @@ 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
|
+
### Attaching a reason
|
|
183
|
+
|
|
184
|
+
Record a free-text rationale alongside any write using a thread-local block — safe to nest, cleared automatically:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
RailsAuditLog.audit_log_reason("Approved by legal") do
|
|
188
|
+
contract.update!(status: "approved")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
entry.reason # => "Approved by legal"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Arbitrary metadata
|
|
195
|
+
|
|
196
|
+
The `metadata` JSON column accepts any key/value hash. Populate it automatically with `audit_log meta:`:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
class Article < ApplicationRecord
|
|
200
|
+
include RailsAuditLog::Auditable
|
|
201
|
+
|
|
202
|
+
# Zero-arg lambda — evaluated at write time (good for thread-locals)
|
|
203
|
+
# One-arg lambda — receives the record instance
|
|
204
|
+
audit_log meta: {
|
|
205
|
+
tenant_id: -> { Current.tenant.id },
|
|
206
|
+
title_length: ->(record) { record.title.length }
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
entry.metadata # => { "tenant_id" => "acme", "title_length" => 12 }
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Request metadata capture
|
|
214
|
+
|
|
215
|
+
Enable opt-in capture of `remote_ip` and `user_agent` from the current request in an initializer:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
# config/initializers/rails_audit_log.rb
|
|
219
|
+
RailsAuditLog.capture_request_metadata = true
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
When enabled, the `Controller` concern automatically stores these into each entry's `metadata` column:
|
|
223
|
+
|
|
224
|
+
```ruby
|
|
225
|
+
entry.metadata # => { "remote_ip" => "203.0.113.1", "user_agent" => "Mozilla/5.0 ..." }
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Request metadata and `audit_log meta:` values are merged together automatically.
|
|
229
|
+
|
|
230
|
+
### Actor display name snapshot
|
|
231
|
+
|
|
232
|
+
`whodunnit_snapshot` stores the actor's display name at write time so entries remain meaningful even after the actor record is deleted:
|
|
233
|
+
|
|
234
|
+
```ruby
|
|
235
|
+
entry.whodunnit_snapshot # => "Alice" (even if the User record is later deleted)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The default display proc uses `actor.name` if available, otherwise `actor.to_s`. Override globally in an initializer:
|
|
239
|
+
|
|
240
|
+
```ruby
|
|
241
|
+
RailsAuditLog.whodunnit_display = ->(actor) { actor.email }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
### Object snapshot storage
|
|
245
|
+
|
|
246
|
+
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:
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
entry.object # => { "id" => 1, "title" => "v1", ... }
|
|
250
|
+
entry.object_changes # => { "title" => ["v1", "v2"] }
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
To save storage at the cost of reduced reification accuracy, switch to diff-only mode:
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
RailsAuditLog.store_snapshot = false
|
|
257
|
+
```
|
|
258
|
+
|
|
80
259
|
## Requirements
|
|
81
260
|
|
|
82
261
|
- Ruby >= 3.3
|
|
@@ -5,6 +5,7 @@ module RailsAuditLog
|
|
|
5
5
|
included do
|
|
6
6
|
class_attribute :_audit_log_only, default: nil
|
|
7
7
|
class_attribute :_audit_log_ignore, default: nil
|
|
8
|
+
class_attribute :_audit_log_meta, default: nil
|
|
8
9
|
|
|
9
10
|
has_many :audit_log_entries,
|
|
10
11
|
class_name: "RailsAuditLog::AuditLogEntry",
|
|
@@ -17,9 +18,10 @@ module RailsAuditLog
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
class_methods do
|
|
20
|
-
def audit_log(only: nil, ignore: nil)
|
|
21
|
+
def audit_log(only: nil, ignore: nil, meta: nil)
|
|
21
22
|
self._audit_log_only = only.map(&:to_s) if only
|
|
22
23
|
self._audit_log_ignore = ignore.map(&:to_s) if ignore
|
|
24
|
+
self._audit_log_meta = meta if meta
|
|
23
25
|
end
|
|
24
26
|
end
|
|
25
27
|
|
|
@@ -30,35 +32,53 @@ module RailsAuditLog
|
|
|
30
32
|
private
|
|
31
33
|
|
|
32
34
|
def record_audit_create
|
|
33
|
-
record_audit_entry("create", saved_changes)
|
|
35
|
+
record_audit_entry("create", saved_changes, nil)
|
|
34
36
|
end
|
|
35
37
|
|
|
36
38
|
def record_audit_update
|
|
37
|
-
|
|
39
|
+
snapshot = attributes.merge(saved_changes.transform_values { |v| v[0] }) if RailsAuditLog.store_snapshot
|
|
40
|
+
record_audit_entry("update", saved_changes, snapshot)
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def record_audit_destroy
|
|
44
|
+
snapshot = attributes.dup if RailsAuditLog.store_snapshot
|
|
41
45
|
changes = attributes.transform_values { |v| [v, nil] }
|
|
42
|
-
record_audit_entry("destroy", changes)
|
|
46
|
+
record_audit_entry("destroy", changes, snapshot)
|
|
43
47
|
end
|
|
44
48
|
|
|
45
|
-
def record_audit_entry(event, changes)
|
|
49
|
+
def record_audit_entry(event, changes, snapshot = nil)
|
|
46
50
|
return unless RailsAuditLog.enabled?
|
|
47
51
|
|
|
48
52
|
filtered = filter_changes(changes)
|
|
49
53
|
return if filtered.empty? && event == "update"
|
|
50
54
|
|
|
51
55
|
actor = RailsAuditLog.actor
|
|
56
|
+
meta = build_audit_metadata
|
|
52
57
|
RailsAuditLog::AuditLogEntry.create!(
|
|
53
|
-
event:
|
|
54
|
-
item_type:
|
|
55
|
-
item_id:
|
|
56
|
-
object_changes:
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
event: event,
|
|
59
|
+
item_type: self.class.name,
|
|
60
|
+
item_id: id,
|
|
61
|
+
object_changes: filtered,
|
|
62
|
+
object: snapshot,
|
|
63
|
+
reason: RailsAuditLog.reason,
|
|
64
|
+
metadata: meta.presence,
|
|
65
|
+
whodunnit_snapshot: actor ? RailsAuditLog.whodunnit_display.call(actor) : nil,
|
|
66
|
+
actor_type: actor&.class&.name,
|
|
67
|
+
actor_id: actor.respond_to?(:id) ? actor.id : nil
|
|
59
68
|
)
|
|
60
69
|
end
|
|
61
70
|
|
|
71
|
+
def build_audit_metadata
|
|
72
|
+
meta = {}
|
|
73
|
+
if self.class._audit_log_meta
|
|
74
|
+
self.class._audit_log_meta.each do |key, callable|
|
|
75
|
+
meta[key.to_s] = callable.arity == 0 ? callable.call : callable.call(self)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
meta.merge!(RailsAuditLog.request_metadata || {})
|
|
79
|
+
meta
|
|
80
|
+
end
|
|
81
|
+
|
|
62
82
|
def filter_changes(changes)
|
|
63
83
|
result = changes.dup
|
|
64
84
|
|
|
@@ -5,6 +5,8 @@ module RailsAuditLog
|
|
|
5
5
|
included do
|
|
6
6
|
before_action :set_audit_log_actor
|
|
7
7
|
after_action :clear_audit_log_actor
|
|
8
|
+
before_action :capture_audit_log_request_metadata
|
|
9
|
+
after_action :clear_audit_log_request_metadata
|
|
8
10
|
end
|
|
9
11
|
|
|
10
12
|
class_methods do
|
|
@@ -27,5 +29,20 @@ module RailsAuditLog
|
|
|
27
29
|
def clear_audit_log_actor
|
|
28
30
|
RailsAuditLog.actor = nil
|
|
29
31
|
end
|
|
32
|
+
|
|
33
|
+
def capture_audit_log_request_metadata
|
|
34
|
+
return unless RailsAuditLog.capture_request_metadata
|
|
35
|
+
|
|
36
|
+
RailsAuditLog.request_metadata = {
|
|
37
|
+
"remote_ip" => request.remote_ip,
|
|
38
|
+
"user_agent" => request.user_agent
|
|
39
|
+
}.compact
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def clear_audit_log_request_metadata
|
|
43
|
+
return unless RailsAuditLog.capture_request_metadata
|
|
44
|
+
|
|
45
|
+
RailsAuditLog.request_metadata = nil
|
|
46
|
+
end
|
|
30
47
|
end
|
|
31
48
|
end
|
|
@@ -10,6 +10,7 @@ module RailsAuditLog
|
|
|
10
10
|
validates :event, presence: true, inclusion: { in: EVENTS }
|
|
11
11
|
validates :item_type, presence: true
|
|
12
12
|
validates :item_id, presence: true
|
|
13
|
+
validate :metadata_must_be_a_hash
|
|
13
14
|
|
|
14
15
|
# Event scopes
|
|
15
16
|
scope :created_events, -> { where(event: "create") }
|
|
@@ -38,6 +39,40 @@ module RailsAuditLog
|
|
|
38
39
|
|
|
39
40
|
# Instance methods
|
|
40
41
|
|
|
42
|
+
def reify
|
|
43
|
+
return nil if event == "create"
|
|
44
|
+
|
|
45
|
+
klass = item_type.constantize
|
|
46
|
+
|
|
47
|
+
if object.present?
|
|
48
|
+
instance = klass.new
|
|
49
|
+
instance.assign_attributes(object.except("id"))
|
|
50
|
+
instance.id = object.fetch("id") { item_id }
|
|
51
|
+
return instance
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Fallback: diff-only mode or entries recorded before snapshot support
|
|
55
|
+
from_attrs = (object_changes || {}).transform_values { |from_to| from_to[0] }
|
|
56
|
+
|
|
57
|
+
if event == "update"
|
|
58
|
+
record = klass.find_by(id: item_id)
|
|
59
|
+
from_attrs = record.attributes.merge(from_attrs) if record
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
instance = klass.new
|
|
63
|
+
instance.assign_attributes(from_attrs.except("id"))
|
|
64
|
+
instance.id = from_attrs.fetch("id") { item_id }
|
|
65
|
+
instance
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def previous
|
|
69
|
+
self.class.where(item_type: item_type, item_id: item_id).where("id < ?", id).order(id: :desc).first
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def next
|
|
73
|
+
self.class.where(item_type: item_type, item_id: item_id).where("id > ?", id).order(id: :asc).first
|
|
74
|
+
end
|
|
75
|
+
|
|
41
76
|
def changed_attributes
|
|
42
77
|
object_changes&.keys || []
|
|
43
78
|
end
|
|
@@ -48,11 +83,19 @@ module RailsAuditLog
|
|
|
48
83
|
object_changes.transform_values { |from_to| { from: from_to[0], to: from_to[1] } }
|
|
49
84
|
end
|
|
50
85
|
|
|
51
|
-
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def metadata_must_be_a_hash
|
|
89
|
+
errors.add(:metadata, "must be a Hash") if metadata.present? && !metadata.is_a?(Hash)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
public
|
|
93
|
+
|
|
94
|
+
# Attribute scope — uses json_extract (SQLite/MySQL) or ->> (PostgreSQL)
|
|
52
95
|
scope :touching, ->(attribute) {
|
|
53
96
|
if connection.adapter_name =~ /PostgreSQL/i
|
|
54
97
|
# :nocov:
|
|
55
|
-
where("object_changes
|
|
98
|
+
where("object_changes->>? IS NOT NULL", attribute.to_s)
|
|
56
99
|
# :nocov:
|
|
57
100
|
else
|
|
58
101
|
where("json_extract(object_changes, ?) IS NOT NULL", "$.#{attribute}")
|
|
@@ -5,6 +5,10 @@ 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
|
|
9
|
+
t.json :metadata
|
|
10
|
+
t.string :reason
|
|
11
|
+
t.string :whodunnit_snapshot
|
|
8
12
|
t.string :actor_type
|
|
9
13
|
t.bigint :actor_id
|
|
10
14
|
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
data/lib/rails_audit_log.rb
CHANGED
|
@@ -5,6 +5,19 @@ module RailsAuditLog
|
|
|
5
5
|
# Global default columns to ignore across all audited models.
|
|
6
6
|
# Override in an initializer: RailsAuditLog.ignored_attributes = %w[updated_at cached_at]
|
|
7
7
|
mattr_accessor :ignored_attributes, default: %w[updated_at]
|
|
8
|
+
mattr_accessor :store_snapshot, default: true
|
|
9
|
+
mattr_accessor :capture_request_metadata, default: false
|
|
10
|
+
mattr_accessor :whodunnit_display, default: ->(actor) {
|
|
11
|
+
actor.respond_to?(:name) ? actor.name.to_s : actor.to_s
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
def self.request_metadata
|
|
15
|
+
Thread.current[:rails_audit_log_request_metadata]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.request_metadata=(value)
|
|
19
|
+
Thread.current[:rails_audit_log_request_metadata] = value
|
|
20
|
+
end
|
|
8
21
|
|
|
9
22
|
def self.actor
|
|
10
23
|
Thread.current[:rails_audit_log_actor]
|
|
@@ -33,4 +46,39 @@ module RailsAuditLog
|
|
|
33
46
|
ensure
|
|
34
47
|
Thread.current[:rails_audit_log_disabled] = previous
|
|
35
48
|
end
|
|
49
|
+
|
|
50
|
+
def self.reason
|
|
51
|
+
Thread.current[:rails_audit_log_reason]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.reason=(value)
|
|
55
|
+
Thread.current[:rails_audit_log_reason] = value
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.audit_log_reason(value)
|
|
59
|
+
previous = self.reason
|
|
60
|
+
self.reason = value
|
|
61
|
+
yield
|
|
62
|
+
ensure
|
|
63
|
+
self.reason = previous
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.version_at(record, time)
|
|
67
|
+
entry = AuditLogEntry
|
|
68
|
+
.where(item_type: record.class.name, item_id: record.id)
|
|
69
|
+
.where(created_at: ..time)
|
|
70
|
+
.order(created_at: :desc, id: :desc)
|
|
71
|
+
.first
|
|
72
|
+
|
|
73
|
+
return nil if entry.nil? || entry.event == "destroy"
|
|
74
|
+
|
|
75
|
+
klass = record.class
|
|
76
|
+
to_attrs = (entry.object_changes || {}).transform_values { |v| v[1] }
|
|
77
|
+
attrs = entry.object.present? ? entry.object.merge(to_attrs) : to_attrs
|
|
78
|
+
|
|
79
|
+
instance = klass.new
|
|
80
|
+
instance.assign_attributes(attrs.except("id"))
|
|
81
|
+
instance.id = attrs.fetch("id") { entry.item_id }
|
|
82
|
+
instance
|
|
83
|
+
end
|
|
36
84
|
end
|