paper_trail_diff 0.1.0 → 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 +4 -4
- data/CHANGELOG.md +15 -0
- data/QUICKSTART.md +260 -0
- data/README.md +79 -5
- data/lib/paper_trail_diff/activity_boundary.rb +34 -3
- data/lib/paper_trail_diff/association_diff_traversal.rb +184 -0
- data/lib/paper_trail_diff/snapshot_traversal.rb +97 -0
- data/lib/paper_trail_diff/step.rb +9 -0
- data/lib/paper_trail_diff/traversal.rb +80 -0
- data/lib/paper_trail_diff/traversal_emitter.rb +42 -0
- data/lib/paper_trail_diff/traversal_entry.rb +94 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +10 -1
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
- data/sig/generated/paper_trail_diff/association_diff_traversal.rbs +58 -0
- data/sig/generated/paper_trail_diff/snapshot_traversal.rbs +29 -0
- data/sig/generated/paper_trail_diff/step.rbs +7 -0
- data/sig/generated/paper_trail_diff/traversal.rbs +41 -0
- data/sig/generated/paper_trail_diff/traversal_emitter.rbs +16 -0
- data/sig/generated/paper_trail_diff/traversal_entry.rbs +52 -0
- data/sig/generated/paper_trail_diff.rbs +8 -0
- metadata +15 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 808e6dba6570e96679cb5b355b774e8afcd3b6bf81c38c1a0b0fc088d6f9f416
|
|
4
|
+
data.tar.gz: 5978755e10f821777aadc86b6f4d79728a827cf48c1ba3e95c89a6f627eaea6e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: '048b927dd9d8274675ae60692ad705ed586c9d31ed6f4c11a581521d86ff8621dedda7daf33a4e4c90223b31a42bc60184c6f376ed4b1363a3cfcb5a2b2ab65a'
|
|
7
|
+
data.tar.gz: 559ea7552ce0133864a3e55df66097fdbbee3315febf9d04cd7485e0caa14e9deb34625b48ecb660f00cd452ec00b4e12d1180689c88a2b2464e59e71182abb5
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,21 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The
|
|
4
4
|
project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.2.0] - 2026-08-09
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Add deterministic `Diff#each_entry` and `Diff#each_change` traversal with
|
|
11
|
+
immutable `TraversalEntry` values for renderers, counters, exports, and
|
|
12
|
+
notifications.
|
|
13
|
+
- Add immutable version metadata and record references to activity boundaries.
|
|
14
|
+
- Give checkpoint `Step` objects `from_boundary` and `to_boundary` readers and
|
|
15
|
+
give both timeline step types an `empty?` predicate.
|
|
16
|
+
- Add a Rails-focused quickstart with minimal endpoint, timeline, ignore, and
|
|
17
|
+
association examples.
|
|
18
|
+
- Execute selected README and quickstart examples in isolated core and PT-AT
|
|
19
|
+
test sessions so documented behavior cannot silently drift.
|
|
20
|
+
|
|
6
21
|
## [0.1.0] - 2026-08-09
|
|
7
22
|
|
|
8
23
|
### Added
|
data/QUICKSTART.md
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# Quickstart
|
|
2
|
+
|
|
3
|
+
This guide gets `paper_trail_diff` running in a Rails application and shows the
|
|
4
|
+
smallest useful examples. Ruby 3.1 or newer and PaperTrail 16 or 17 are
|
|
5
|
+
supported.
|
|
6
|
+
|
|
7
|
+
## 1. Install the gem
|
|
8
|
+
|
|
9
|
+
From the Rails application directory:
|
|
10
|
+
|
|
11
|
+
```console
|
|
12
|
+
bundle add paper_trail_diff
|
|
13
|
+
bin/rails generate paper_trail:install --with-changes
|
|
14
|
+
bin/rails db:migrate
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Skip the generator if the application already has a PaperTrail `versions`
|
|
18
|
+
table. The `object_changes` column created by `--with-changes` is recommended
|
|
19
|
+
for efficient activity analysis, although endpoint comparison is based on
|
|
20
|
+
reified state rather than a PaperTrail changeset.
|
|
21
|
+
|
|
22
|
+
## 2. Version a model
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
# app/models/article.rb
|
|
26
|
+
class Article < ApplicationRecord
|
|
27
|
+
has_paper_trail
|
|
28
|
+
end
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Restart the Rails console after changing the Gemfile or an initializer.
|
|
32
|
+
|
|
33
|
+
## 3. Create a little history
|
|
34
|
+
|
|
35
|
+
Run this in `bin/rails console`:
|
|
36
|
+
|
|
37
|
+
<!-- executable:quickstart-history -->
|
|
38
|
+
```ruby
|
|
39
|
+
article = Article.create!(title: "Draft")
|
|
40
|
+
|
|
41
|
+
article.update!(title: "Published")
|
|
42
|
+
draft_version = article.versions.last
|
|
43
|
+
|
|
44
|
+
article.update!(title: "Final")
|
|
45
|
+
published_version = article.versions.last
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The variable names are intentional. PaperTrail versions contain the state
|
|
49
|
+
*before* their event:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
draft_version.reify.title # => "Draft"
|
|
53
|
+
published_version.reify.title # => "Published"
|
|
54
|
+
article.title # => "Final" (current database state)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 4. Compare two states
|
|
58
|
+
|
|
59
|
+
<!-- executable:quickstart-compare -->
|
|
60
|
+
```ruby
|
|
61
|
+
diff = PaperTrailDiff.compare(draft_version, published_version)
|
|
62
|
+
|
|
63
|
+
diff.empty? # => false
|
|
64
|
+
diff.attributes["title"].from # => "Draft"
|
|
65
|
+
diff.attributes["title"].to # => "Published"
|
|
66
|
+
|
|
67
|
+
diff.to_h
|
|
68
|
+
# => {
|
|
69
|
+
# record_presence_change: nil,
|
|
70
|
+
# attributes: {
|
|
71
|
+
# "title" => { from: "Draft", to: "Published" }
|
|
72
|
+
# },
|
|
73
|
+
# associations: {}
|
|
74
|
+
# }
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`compare` reports only the net difference between its endpoints. It does not
|
|
78
|
+
report intermediate changes.
|
|
79
|
+
|
|
80
|
+
### Compare a version with current state
|
|
81
|
+
|
|
82
|
+
Pass the persisted record explicitly when the second endpoint should be the
|
|
83
|
+
current database state:
|
|
84
|
+
|
|
85
|
+
<!-- executable:quickstart-current -->
|
|
86
|
+
```ruby
|
|
87
|
+
diff = PaperTrailDiff.compare(published_version, article)
|
|
88
|
+
|
|
89
|
+
diff.attributes["title"].to # => "Final"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The record must be persisted, not destroyed, and have no unsaved changes.
|
|
93
|
+
Current state is never inferred automatically.
|
|
94
|
+
|
|
95
|
+
## 5. Build a checkpoint timeline
|
|
96
|
+
|
|
97
|
+
Create one more historical endpoint:
|
|
98
|
+
|
|
99
|
+
<!-- executable:quickstart-timeline-state -->
|
|
100
|
+
```ruby
|
|
101
|
+
article.update!(title: "Archived")
|
|
102
|
+
final_version = article.versions.last # reifies to "Final"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Then compare every adjacent root version:
|
|
106
|
+
|
|
107
|
+
<!-- executable:quickstart-timeline -->
|
|
108
|
+
```ruby
|
|
109
|
+
steps = PaperTrailDiff.timeline(
|
|
110
|
+
article,
|
|
111
|
+
from: draft_version,
|
|
112
|
+
to: final_version
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
steps.map do |step|
|
|
116
|
+
change = step.diff.attributes["title"]
|
|
117
|
+
[change.from, change.to]
|
|
118
|
+
end
|
|
119
|
+
# => [["Draft", "Published"], ["Published", "Final"]]
|
|
120
|
+
|
|
121
|
+
visible_steps = steps.reject { |step| step.diff.empty? }
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Empty steps remain in the timeline. Filter them only when the application's
|
|
125
|
+
display does not need every version boundary.
|
|
126
|
+
|
|
127
|
+
## 6. Choose the right API
|
|
128
|
+
|
|
129
|
+
| Need | Call |
|
|
130
|
+
| --- | --- |
|
|
131
|
+
| Net difference between two endpoints | `compare` |
|
|
132
|
+
| One step per root-record version | `timeline` |
|
|
133
|
+
| Steps for root and selected child versions | `activity_timeline` |
|
|
134
|
+
| Net diff and timelines from one history pass | `analyze` |
|
|
135
|
+
|
|
136
|
+
`timeline` is a root-checkpoint timeline. A child change becomes visible at the
|
|
137
|
+
next root boundary. `activity_timeline` can make a versioned child change its
|
|
138
|
+
own boundary and can end at an explicitly supplied current record.
|
|
139
|
+
|
|
140
|
+
## 7. Ignore noise fields
|
|
141
|
+
|
|
142
|
+
`updated_at` is ignored by default. Passing `ignore:` replaces that default:
|
|
143
|
+
|
|
144
|
+
<!-- executable:quickstart-ignore -->
|
|
145
|
+
```ruby
|
|
146
|
+
PaperTrailDiff.compare(
|
|
147
|
+
draft_version,
|
|
148
|
+
published_version,
|
|
149
|
+
ignore: %i[updated_at lock_version]
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
PaperTrailDiff.compare(
|
|
153
|
+
draft_version,
|
|
154
|
+
published_version,
|
|
155
|
+
ignore: [] # compare every available scalar attribute
|
|
156
|
+
)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
See the main README for exact path-specific ignore rules.
|
|
160
|
+
|
|
161
|
+
## 8. Add association history when needed
|
|
162
|
+
|
|
163
|
+
Associations are optional. Add PT-AT only when the application needs historical
|
|
164
|
+
association reconstruction:
|
|
165
|
+
|
|
166
|
+
```console
|
|
167
|
+
bundle add paper_trail-association_tracking
|
|
168
|
+
bin/rails generate paper_trail_association_tracking:install
|
|
169
|
+
bin/rails db:migrate
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
The generator creates `version_associations` and enables
|
|
173
|
+
`PaperTrail.config.track_associations`. Every model whose historical state is
|
|
174
|
+
needed must be versioned:
|
|
175
|
+
|
|
176
|
+
```ruby
|
|
177
|
+
class Article < ApplicationRecord
|
|
178
|
+
has_many :comments, dependent: :destroy
|
|
179
|
+
has_paper_trail synchronize_version_creation_timestamp: false
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
class Comment < ApplicationRecord
|
|
183
|
+
belongs_to :article
|
|
184
|
+
has_paper_trail
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Take an explicit root checkpoint before the example change:
|
|
189
|
+
|
|
190
|
+
<!-- executable:quickstart-association -->
|
|
191
|
+
```ruby
|
|
192
|
+
Article.transaction do
|
|
193
|
+
Article.find(article.id).paper_trail.save_with_version
|
|
194
|
+
end
|
|
195
|
+
before = article.versions.reload.last
|
|
196
|
+
|
|
197
|
+
article.comments.create!(body: "First comment")
|
|
198
|
+
current = Article.find(article.id)
|
|
199
|
+
|
|
200
|
+
diff = PaperTrailDiff.compare(
|
|
201
|
+
before,
|
|
202
|
+
current,
|
|
203
|
+
associations: [:comments]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
diff.associations["comments"].added.first.attributes["body"]
|
|
207
|
+
# => "First comment"
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Nested paths are explicit and bounded:
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
diff = PaperTrailDiff.compare(
|
|
214
|
+
before,
|
|
215
|
+
current,
|
|
216
|
+
associations: ["comments.replies.author"]
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
To see versioned child events without touching the article after each change:
|
|
221
|
+
|
|
222
|
+
<!-- executable:quickstart-activity -->
|
|
223
|
+
```ruby
|
|
224
|
+
steps = PaperTrailDiff.activity_timeline(
|
|
225
|
+
article,
|
|
226
|
+
from: before,
|
|
227
|
+
to: current,
|
|
228
|
+
associations: ["comments"]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
steps.reject { |step| step.diff.empty? }.each do |step|
|
|
232
|
+
boundary = step.to_boundary
|
|
233
|
+
puts "#{boundary.item_type} ##{boundary.item_id}"
|
|
234
|
+
end
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Use a later transaction-backed root checkpoint instead of `to: current` when
|
|
238
|
+
the result must remain reproducible after the database changes again.
|
|
239
|
+
|
|
240
|
+
## 9. Common surprises
|
|
241
|
+
|
|
242
|
+
- PaperTrail stores pre-change snapshots. The current record is not represented
|
|
243
|
+
by `versions.last`; pass the record explicitly when current state is wanted.
|
|
244
|
+
- `ignore:` replaces the default list. Include `updated_at` yourself when using
|
|
245
|
+
a custom list and you still want it ignored.
|
|
246
|
+
- Historical associations require PT-AT to be installed, loaded, migrated, and
|
|
247
|
+
enabled. Restart the console after setup.
|
|
248
|
+
- Only requested association paths are traversed. The gem never recursively
|
|
249
|
+
discovers the whole model graph.
|
|
250
|
+
- Historical output cannot contain data that PaperTrail or PT-AT did not record
|
|
251
|
+
or can no longer reconstruct.
|
|
252
|
+
|
|
253
|
+
For performance guidance, HABTM limitations, diagnostics, discovery, path-aware
|
|
254
|
+
ignore rules, and complete result shapes, continue with the
|
|
255
|
+
[README](README.md).
|
|
256
|
+
|
|
257
|
+
Upstream setup references:
|
|
258
|
+
[PaperTrail installation](https://github.com/paper-trail-gem/paper_trail#1b-installation)
|
|
259
|
+
and
|
|
260
|
+
[PT-AT installation](https://github.com/westonganger/paper_trail-association_tracking#install).
|
data/README.md
CHANGED
|
@@ -12,6 +12,8 @@ available when
|
|
|
12
12
|
|
|
13
13
|
Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
|
|
14
14
|
|
|
15
|
+
New to the gem? Start with the copyable [Quickstart](QUICKSTART.md).
|
|
16
|
+
|
|
15
17
|
## Installation
|
|
16
18
|
|
|
17
19
|
Add the gem to your bundle:
|
|
@@ -95,11 +97,22 @@ steps = PaperTrailDiff.timeline(
|
|
|
95
97
|
|
|
96
98
|
steps.first.from_version # the original PaperTrail version
|
|
97
99
|
steps.first.to_version # the next PaperTrail version
|
|
100
|
+
steps.first.from_boundary # immutable presentation metadata
|
|
101
|
+
steps.first.to_boundary
|
|
98
102
|
steps.first.diff # a PaperTrailDiff::Diff
|
|
103
|
+
steps.first.empty? # delegates to the diff
|
|
99
104
|
steps.first.to_h
|
|
100
105
|
# => { from_version_id: 2, to_version_id: 3, diff: { ... } }
|
|
101
106
|
```
|
|
102
107
|
|
|
108
|
+
Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
|
|
109
|
+
`empty?`. A historical boundary has `event`, `whodunnit`, `record`,
|
|
110
|
+
`recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
|
|
111
|
+
also retain their original `from_version` and `to_version` for callers that
|
|
112
|
+
need custom PaperTrail metadata. Existing `Step#to_h` and
|
|
113
|
+
`ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
|
|
114
|
+
metadata.
|
|
115
|
+
|
|
103
116
|
Every version boundary remains in the result, even when its diff is empty after
|
|
104
117
|
ignored fields are removed. Equal boundaries return a frozen empty array.
|
|
105
118
|
|
|
@@ -323,6 +336,61 @@ database state. Unknown names and unsupported macros raise explicit
|
|
|
323
336
|
Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
|
|
324
337
|
that base error.
|
|
325
338
|
|
|
339
|
+
## Traverse result trees
|
|
340
|
+
|
|
341
|
+
The nested tree remains the canonical, lossless result. For renderers,
|
|
342
|
+
counters, exports, and notifications, `Diff#each_change` provides a
|
|
343
|
+
deterministic depth-first stream of semantic changes:
|
|
344
|
+
|
|
345
|
+
<!-- executable:readme-traversal-changes -->
|
|
346
|
+
```ruby
|
|
347
|
+
diff.each_change do |entry|
|
|
348
|
+
entry.kind # :attribute_changed, :record_added, ...
|
|
349
|
+
entry.association_path # ["comments", "replies"]
|
|
350
|
+
entry.record_path # frozen RecordReference objects
|
|
351
|
+
entry.association_kind # :has_many, :belongs_to, ...
|
|
352
|
+
entry.attribute # "body" for an attribute entry
|
|
353
|
+
entry.value # ValueChange, RecordChange, or RecordSnapshot
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
counts = diff.each_change.map(&:kind).tally
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`record_changed` entries are emitted before that record's attribute and nested
|
|
360
|
+
association changes, allowing an application to count both changed records and
|
|
361
|
+
changed fields. Singular relationship operations are classified separately as
|
|
362
|
+
`relationship_added`, `relationship_removed`, or `relationship_replaced`.
|
|
363
|
+
Root create/delete transitions remain `record_presence_changed`.
|
|
364
|
+
|
|
365
|
+
Added, removed, and replaced records carry complete bounded snapshots. Use
|
|
366
|
+
`each_entry` when a renderer also needs that nested state without writing a
|
|
367
|
+
second snapshot walker:
|
|
368
|
+
|
|
369
|
+
<!-- executable:readme-traversal-entries -->
|
|
370
|
+
```ruby
|
|
371
|
+
diff.each_entry do |entry|
|
|
372
|
+
next unless entry.included_state?
|
|
373
|
+
|
|
374
|
+
entry.kind # :record_included, :attribute_included, :association_included
|
|
375
|
+
entry.state # :before or :after
|
|
376
|
+
entry.context # :included_state
|
|
377
|
+
end
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
An included record is historical context, not evidence that the nested record
|
|
381
|
+
changed independently. `each_change` therefore omits included-state entries.
|
|
382
|
+
Both methods return an `Enumerator` when no block is supplied and return the
|
|
383
|
+
diff when called with a block. Entries and their paths are immutable and have
|
|
384
|
+
deterministic `to_h` output. The root location uses empty association and record
|
|
385
|
+
paths; descendant `record_path` values contain only the explicitly traversed
|
|
386
|
+
descendant identities.
|
|
387
|
+
|
|
388
|
+
Traversal is path-preserving and does not deduplicate. If the same record is
|
|
389
|
+
reachable through two selected paths, it appears at both locations so the
|
|
390
|
+
consumer can choose whether path identity or record identity controls counting.
|
|
391
|
+
Walking a result never performs database access and never discovers additional
|
|
392
|
+
associations.
|
|
393
|
+
|
|
326
394
|
## Discover and diagnose associations
|
|
327
395
|
|
|
328
396
|
Configuration UIs can use bounded public reflection instead of duplicating the
|
|
@@ -392,6 +460,7 @@ The public result types are:
|
|
|
392
460
|
- `PaperTrailDiff::ActivityBoundary`
|
|
393
461
|
- `PaperTrailDiff::Analysis`
|
|
394
462
|
- `PaperTrailDiff::ValueChange`
|
|
463
|
+
- `PaperTrailDiff::TraversalEntry`
|
|
395
464
|
- `PaperTrailDiff::RecordReference`
|
|
396
465
|
- `PaperTrailDiff::RecordSnapshot`
|
|
397
466
|
- `PaperTrailDiff::RecordChange`
|
|
@@ -404,11 +473,13 @@ The public result types are:
|
|
|
404
473
|
They expose readers, are frozen after construction, and provide deterministic
|
|
405
474
|
`to_h` output. Structural hash keys are symbols; attribute and association
|
|
406
475
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
407
|
-
is a `RecordReference` with `type` and `id` readers. `
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
476
|
+
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
477
|
+
`#association` return the final components of their corresponding paths. `Step`
|
|
478
|
+
itself is frozen but intentionally retains the original, potentially mutable
|
|
479
|
+
PaperTrail version objects for metadata access; its new boundary readers provide
|
|
480
|
+
an immutable presentation interface, while `Step#to_h` continues to emit only
|
|
481
|
+
version IDs. `ActivityStep` retains only immutable boundary metadata and
|
|
482
|
+
serializes as `{ from: ..., to: ..., diff: ... }`.
|
|
412
483
|
|
|
413
484
|
## Historical correctness and limitations
|
|
414
485
|
|
|
@@ -459,6 +530,9 @@ mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
|
|
|
459
530
|
|
|
460
531
|
The default Rake task runs core specs without PT-AT loaded, association specs in
|
|
461
532
|
a separate process, RuboCop, generated-signature verification, and Steep.
|
|
533
|
+
Selected Ruby blocks in this README and the quickstart are executed in those
|
|
534
|
+
same isolated test sessions; the `<!-- executable:... -->` marker opts a block
|
|
535
|
+
into the appropriate stateful session.
|
|
462
536
|
Inline `#:` annotations in `lib/` generate the committed RBS files under
|
|
463
537
|
`sig/generated/`.
|
|
464
538
|
|
|
@@ -9,6 +9,9 @@ module PaperTrailDiff
|
|
|
9
9
|
attr_reader :item_type #: String
|
|
10
10
|
attr_reader :item_id #: untyped
|
|
11
11
|
attr_reader :recorded_at #: untyped
|
|
12
|
+
attr_reader :event #: String?
|
|
13
|
+
attr_reader :whodunnit #: untyped
|
|
14
|
+
attr_reader :record #: RecordReference
|
|
12
15
|
|
|
13
16
|
class << self
|
|
14
17
|
#: (untyped) -> ActivityBoundary
|
|
@@ -18,7 +21,9 @@ module PaperTrailDiff
|
|
|
18
21
|
version_id: version.id,
|
|
19
22
|
item_type: version.item_type,
|
|
20
23
|
item_id: version.item_id,
|
|
21
|
-
recorded_at: version.created_at
|
|
24
|
+
recorded_at: version.created_at,
|
|
25
|
+
event: version.event,
|
|
26
|
+
whodunnit: version.whodunnit
|
|
22
27
|
)
|
|
23
28
|
end
|
|
24
29
|
|
|
@@ -34,16 +39,37 @@ module PaperTrailDiff
|
|
|
34
39
|
end
|
|
35
40
|
end
|
|
36
41
|
|
|
37
|
-
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped) -> void
|
|
38
|
-
def initialize(
|
|
42
|
+
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
|
|
43
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
44
|
+
kind:,
|
|
45
|
+
version_id:,
|
|
46
|
+
item_type:,
|
|
47
|
+
item_id:,
|
|
48
|
+
recorded_at:,
|
|
49
|
+
event: nil,
|
|
50
|
+
whodunnit: nil
|
|
51
|
+
)
|
|
39
52
|
@kind = kind
|
|
40
53
|
@version_id = Support.immutable_copy(version_id)
|
|
41
54
|
@item_type = Support.immutable_copy(item_type.to_s)
|
|
42
55
|
@item_id = Support.immutable_copy(item_id)
|
|
43
56
|
@recorded_at = Support.immutable_copy(recorded_at)
|
|
57
|
+
@event = Support.immutable_copy(event&.to_s)
|
|
58
|
+
@whodunnit = Support.immutable_copy(whodunnit)
|
|
59
|
+
@record = RecordReference.new(type: @item_type, id: @item_id)
|
|
44
60
|
freeze
|
|
45
61
|
end
|
|
46
62
|
|
|
63
|
+
#: () -> bool
|
|
64
|
+
def version?
|
|
65
|
+
kind == :version
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> bool
|
|
69
|
+
def current?
|
|
70
|
+
kind == :current
|
|
71
|
+
end
|
|
72
|
+
|
|
47
73
|
#: () -> Hash[Symbol, untyped]
|
|
48
74
|
def to_h
|
|
49
75
|
{
|
|
@@ -70,6 +96,11 @@ module PaperTrailDiff
|
|
|
70
96
|
freeze
|
|
71
97
|
end
|
|
72
98
|
|
|
99
|
+
#: () -> bool
|
|
100
|
+
def empty?
|
|
101
|
+
diff.empty?
|
|
102
|
+
end
|
|
103
|
+
|
|
73
104
|
#: () -> Hash[Symbol, untyped]
|
|
74
105
|
def to_h
|
|
75
106
|
{
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Walks nested singular and collection association differences.
|
|
6
|
+
class AssociationDiffTraversal < TraversalEmitter
|
|
7
|
+
#: (^(TraversalEntry) -> void) -> void
|
|
8
|
+
def initialize(receiver)
|
|
9
|
+
super
|
|
10
|
+
@snapshots = SnapshotTraversal.new(receiver)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (association_diffs, association_path: traversal_association_path, record_path: traversal_record_path) -> void
|
|
14
|
+
def call(associations, association_path:, record_path:)
|
|
15
|
+
associations.each do |name, association|
|
|
16
|
+
child_path = association_path + [name]
|
|
17
|
+
walk_association(association, child_path, record_path)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (Hash[String, ValueChange], association_path: traversal_association_path, record_path: traversal_record_path, association_kind: Symbol?) -> void
|
|
22
|
+
def attributes(changes, association_path:, record_path:, association_kind:)
|
|
23
|
+
changes.each do |name, change|
|
|
24
|
+
emit_attribute(name, change, association_path, record_path, association_kind)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
# @rbs @snapshots: SnapshotTraversal
|
|
31
|
+
|
|
32
|
+
#: (association_diff, traversal_association_path, traversal_record_path) -> void
|
|
33
|
+
def walk_association(association, association_path, record_path)
|
|
34
|
+
if association.is_a?(CollectionAssociationDiff)
|
|
35
|
+
walk_collection(association, association_path, record_path)
|
|
36
|
+
else
|
|
37
|
+
walk_to_one(association, association_path, record_path)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (String, ValueChange, traversal_association_path, traversal_record_path, Symbol?) -> void
|
|
42
|
+
def emit_attribute(name, change, association_path, record_path, association_kind)
|
|
43
|
+
emit(
|
|
44
|
+
kind: :attribute_changed,
|
|
45
|
+
context: :change,
|
|
46
|
+
association_path: association_path,
|
|
47
|
+
record_path: record_path,
|
|
48
|
+
association_kind: association_kind,
|
|
49
|
+
attribute: name,
|
|
50
|
+
value: change
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
#: (CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
|
|
55
|
+
def walk_collection(association, association_path, record_path)
|
|
56
|
+
walk_memberships(
|
|
57
|
+
association.added, :record_added, :after,
|
|
58
|
+
association, association_path, record_path
|
|
59
|
+
)
|
|
60
|
+
walk_memberships(
|
|
61
|
+
association.removed, :record_removed, :before,
|
|
62
|
+
association, association_path, record_path
|
|
63
|
+
)
|
|
64
|
+
association.changed.each do |change|
|
|
65
|
+
walk_record_change(change, association.kind, association_path, record_path)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
#: (Array[RecordSnapshot], Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
|
|
70
|
+
def walk_memberships( # rubocop:disable Metrics/ParameterLists
|
|
71
|
+
snapshots, kind, state, association, association_path, record_path
|
|
72
|
+
)
|
|
73
|
+
snapshots.each do |snapshot|
|
|
74
|
+
walk_membership(snapshot, kind, state, association, association_path, record_path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
#: (RecordSnapshot, Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
|
|
79
|
+
def walk_membership( # rubocop:disable Metrics/ParameterLists
|
|
80
|
+
snapshot, kind, state, association, association_path, record_path
|
|
81
|
+
)
|
|
82
|
+
child_records = record_path + [snapshot.reference]
|
|
83
|
+
emit_membership(snapshot, kind, state, association, association_path, child_records)
|
|
84
|
+
@snapshots.call(
|
|
85
|
+
snapshot,
|
|
86
|
+
association_path: association_path,
|
|
87
|
+
record_path: child_records,
|
|
88
|
+
state: state,
|
|
89
|
+
association_kind: association.kind,
|
|
90
|
+
include_record: false
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#: (RecordSnapshot, Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
|
|
95
|
+
def emit_membership( # rubocop:disable Metrics/ParameterLists
|
|
96
|
+
snapshot, kind, state, association, association_path, record_path
|
|
97
|
+
)
|
|
98
|
+
emit(
|
|
99
|
+
kind: kind,
|
|
100
|
+
context: :change,
|
|
101
|
+
association_path: association_path,
|
|
102
|
+
record_path: record_path,
|
|
103
|
+
association_kind: association.kind,
|
|
104
|
+
state: state,
|
|
105
|
+
value: snapshot
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
#: (ToOneAssociationDiff, traversal_association_path, traversal_record_path) -> void
|
|
110
|
+
def walk_to_one(association, association_path, record_path)
|
|
111
|
+
if association.relationship
|
|
112
|
+
walk_relationship(association.relationship, association.kind, association_path, record_path)
|
|
113
|
+
elsif association.changed
|
|
114
|
+
walk_record_change(association.changed, association.kind, association_path, record_path)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
#: (ValueChange, Symbol, traversal_association_path, traversal_record_path) -> void
|
|
119
|
+
def walk_relationship(change, association_kind, association_path, record_path)
|
|
120
|
+
emit_relationship(change, association_kind, association_path, record_path)
|
|
121
|
+
walk_related(change.from, :before, association_kind, association_path, record_path)
|
|
122
|
+
walk_related(change.to, :after, association_kind, association_path, record_path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
#: (ValueChange, Symbol, traversal_association_path, traversal_record_path) -> void
|
|
126
|
+
def emit_relationship(change, association_kind, association_path, record_path)
|
|
127
|
+
emit(
|
|
128
|
+
kind: relationship_kind(change),
|
|
129
|
+
context: :change,
|
|
130
|
+
association_path: association_path,
|
|
131
|
+
record_path: record_path,
|
|
132
|
+
association_kind: association_kind,
|
|
133
|
+
value: change
|
|
134
|
+
)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
#: (RecordSnapshot?, traversal_state, Symbol, traversal_association_path, traversal_record_path) -> void
|
|
138
|
+
def walk_related(snapshot, state, association_kind, association_path, record_path)
|
|
139
|
+
return unless snapshot
|
|
140
|
+
|
|
141
|
+
@snapshots.call(
|
|
142
|
+
snapshot,
|
|
143
|
+
association_path: association_path,
|
|
144
|
+
record_path: record_path + [snapshot.reference],
|
|
145
|
+
state: state,
|
|
146
|
+
association_kind: association_kind
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
#: (ValueChange) -> Symbol
|
|
151
|
+
def relationship_kind(change)
|
|
152
|
+
return :relationship_added unless change.from
|
|
153
|
+
return :relationship_removed unless change.to
|
|
154
|
+
|
|
155
|
+
:relationship_replaced
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
#: (RecordChange, Symbol, traversal_association_path, traversal_record_path) -> void
|
|
159
|
+
def walk_record_change(change, association_kind, association_path, record_path)
|
|
160
|
+
child_records = record_path + [change.record]
|
|
161
|
+
emit_record_change(change, association_kind, association_path, child_records)
|
|
162
|
+
attributes(
|
|
163
|
+
change.attributes,
|
|
164
|
+
association_path: association_path,
|
|
165
|
+
record_path: child_records,
|
|
166
|
+
association_kind: association_kind
|
|
167
|
+
)
|
|
168
|
+
call(change.associations, association_path: association_path, record_path: child_records)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
#: (RecordChange, Symbol, traversal_association_path, traversal_record_path) -> void
|
|
172
|
+
def emit_record_change(change, association_kind, association_path, record_path)
|
|
173
|
+
emit(
|
|
174
|
+
kind: :record_changed,
|
|
175
|
+
context: :change,
|
|
176
|
+
association_path: association_path,
|
|
177
|
+
record_path: record_path,
|
|
178
|
+
association_kind: association_kind,
|
|
179
|
+
value: change
|
|
180
|
+
)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
private_constant :AssociationDiffTraversal
|
|
184
|
+
end
|