paper_trail_diff 0.1.0 → 0.3.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 +62 -0
- data/QUICKSTART.md +302 -0
- data/README.md +258 -19
- data/lib/paper_trail_diff/activity_boundary.rb +34 -3
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +73 -0
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +261 -45
- data/lib/paper_trail_diff/activity_history.rb +136 -0
- data/lib/paper_trail_diff/activity_range.rb +28 -12
- data/lib/paper_trail_diff/activity_snapshot_delta.rb +64 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +3 -3
- data/lib/paper_trail_diff/activity_timeline_builder.rb +54 -45
- data/lib/paper_trail_diff/activity_version_collector.rb +14 -17
- data/lib/paper_trail_diff/association_diff_traversal.rb +184 -0
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +5 -3
- data/lib/paper_trail_diff/collection_comparator.rb +155 -0
- data/lib/paper_trail_diff/collection_identity_index.rb +53 -0
- data/lib/paper_trail_diff/collection_transition.rb +25 -0
- data/lib/paper_trail_diff/comparison_batch.rb +151 -0
- data/lib/paper_trail_diff/engine.rb +5 -45
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +55 -3
- data/lib/paper_trail_diff/instrumentation.rb +30 -0
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +133 -0
- data/lib/paper_trail_diff/live_endpoint_provider.rb +48 -0
- data/lib/paper_trail_diff/live_graph_collector.rb +52 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +61 -34
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +48 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +54 -17
- data/lib/paper_trail_diff/prepared_history.rb +14 -4
- data/lib/paper_trail_diff/prepared_history_loader.rb +17 -9
- data/lib/paper_trail_diff/prepared_record_index.rb +138 -10
- data/lib/paper_trail_diff/snapshot.rb +66 -3
- data/lib/paper_trail_diff/snapshot_traversal.rb +97 -0
- data/lib/paper_trail_diff/step.rb +9 -0
- data/lib/paper_trail_diff/support.rb +18 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +111 -0
- data/lib/paper_trail_diff/time_range.rb +85 -0
- data/lib/paper_trail_diff/time_version_range.rb +53 -0
- data/lib/paper_trail_diff/timeline_builder.rb +13 -7
- data/lib/paper_trail_diff/timeline_range.rb +72 -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/version_association_candidate_scope.rb +73 -0
- data/lib/paper_trail_diff.rb +84 -13
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +17 -2
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +40 -0
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +49 -8
- data/sig/generated/paper_trail_diff/activity_history.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +14 -5
- data/sig/generated/paper_trail_diff/activity_snapshot_delta.rbs +33 -0
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +15 -10
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -5
- data/sig/generated/paper_trail_diff/association_diff_traversal.rbs +58 -0
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +59 -0
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +29 -0
- data/sig/generated/paper_trail_diff/collection_transition.rbs +18 -0
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +65 -0
- data/sig/generated/paper_trail_diff/engine.rbs +0 -12
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +22 -2
- data/sig/generated/paper_trail_diff/instrumentation.rbs +12 -0
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +52 -0
- data/sig/generated/paper_trail_diff/live_endpoint_provider.rbs +23 -0
- data/sig/generated/paper_trail_diff/live_graph_collector.rbs +27 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +21 -10
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +22 -8
- data/sig/generated/paper_trail_diff/prepared_history.rbs +6 -0
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +4 -2
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +61 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +37 -2
- 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/support.rbs +6 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +46 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +41 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +27 -0
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +6 -5
- data/sig/generated/paper_trail_diff/timeline_range.rbs +41 -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/version_association_candidate_scope.rbs +32 -0
- data/sig/generated/paper_trail_diff.rbs +24 -8
- metadata +49 -4
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:
|
|
@@ -80,6 +82,95 @@ association caches and in-memory edits are not compared. Use a database
|
|
|
80
82
|
transaction with an appropriate isolation level when several live association
|
|
81
83
|
queries must represent one atomic application snapshot.
|
|
82
84
|
|
|
85
|
+
For collection-level reports, use `compare_many` to reload current roots and
|
|
86
|
+
preload each explicitly selected live association path across the batch. Each
|
|
87
|
+
entry has the same endpoints and options as `compare`; results are returned in
|
|
88
|
+
input order as a frozen hash keyed by `[item_type, item_id]` strings:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
diffs = PaperTrailDiff.compare_many(
|
|
92
|
+
[
|
|
93
|
+
{ from: first_versions.fetch(order_a.id), to: order_a },
|
|
94
|
+
{ from: first_versions.fetch(order_b.id), to: order_b }
|
|
95
|
+
],
|
|
96
|
+
associations: [:line_items],
|
|
97
|
+
ignore: []
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
diffs.fetch(["Order", order_a.id.to_s]) # => PaperTrailDiff::Diff
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Root identities must be unique within one call. Historical reconstruction for
|
|
104
|
+
ordinary versioned, unscoped association paths is also prepared across the
|
|
105
|
+
collection. Paths that require the existing point-in-time PT-AT fallback retain
|
|
106
|
+
their per-endpoint behavior and all historical reconstruction retains the same
|
|
107
|
+
PaperTrail Association Tracking requirements as `compare`. Live collection
|
|
108
|
+
scopes with per-owner semantics, such as `limit`, `offset`, or an owner
|
|
109
|
+
argument, are loaded per root; other selected branches remain batched. Callers
|
|
110
|
+
should still use an appropriate database transaction when all live queries
|
|
111
|
+
must observe one atomic snapshot.
|
|
112
|
+
|
|
113
|
+
### Reuse already-preloaded current endpoints
|
|
114
|
+
|
|
115
|
+
`compare` and `compare_many` reload current endpoints by default. A caller that
|
|
116
|
+
already owns a consistent, fully preloaded graph may opt out:
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
orders = Order.where(id: ids).preload(line_items: :product).to_a
|
|
120
|
+
|
|
121
|
+
diffs = PaperTrailDiff.compare_many(
|
|
122
|
+
orders.map do |order|
|
|
123
|
+
{ from: first_versions.fetch(order.id), to: order }
|
|
124
|
+
end,
|
|
125
|
+
associations: ["line_items.product"],
|
|
126
|
+
reload_live_endpoints: false
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
With `reload_live_endpoints: false`, scalar values and association targets come
|
|
131
|
+
from the supplied instances. Every requested association path must already be
|
|
132
|
+
loaded; otherwise `PaperTrailDiff::UnloadedAssociationError` is raised before
|
|
133
|
+
normalization instead of silently issuing an N+1 query. The caller is
|
|
134
|
+
responsible for the consistency and freshness of that in-memory graph.
|
|
135
|
+
|
|
136
|
+
### Runtime performance diagnostics
|
|
137
|
+
|
|
138
|
+
The gem never prints or logs automatically. It emits these
|
|
139
|
+
`ActiveSupport::Notifications` events so applications can choose their logger
|
|
140
|
+
and formatting:
|
|
141
|
+
|
|
142
|
+
- `compare.paper_trail_diff`
|
|
143
|
+
- `compare_many.paper_trail_diff`
|
|
144
|
+
- `activity_timeline.paper_trail_diff`
|
|
145
|
+
- `load_live_endpoints.paper_trail_diff`
|
|
146
|
+
- `prepare_history.paper_trail_diff`
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
ActiveSupport::Notifications.subscribe(/\.paper_trail_diff\z/) do |name, start, finish, _id, payload|
|
|
150
|
+
Rails.logger.debug(
|
|
151
|
+
event: name,
|
|
152
|
+
duration_ms: ((finish - start) * 1_000).round(1),
|
|
153
|
+
**payload
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The payloads contain counts, model names, association paths, and reload mode,
|
|
159
|
+
not endpoint objects or record attributes. The activity-timeline event wraps
|
|
160
|
+
the complete call and includes `step_count`, so its notification duration is
|
|
161
|
+
the user-visible runtime. To inspect the exact SQL generated by one report,
|
|
162
|
+
scope a standard `sql.active_record` subscriber around it:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
callback = proc do |_name, _start, _finish, _id, payload|
|
|
166
|
+
Rails.logger.debug(payload[:sql]) unless payload[:cached] || payload[:name] == "SCHEMA"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do
|
|
170
|
+
PaperTrailDiff.compare_many(comparisons, associations: [:line_items])
|
|
171
|
+
end
|
|
172
|
+
```
|
|
173
|
+
|
|
83
174
|
## Build a root-checkpoint timeline
|
|
84
175
|
|
|
85
176
|
`timeline` accepts two version objects from the supplied record's history. The
|
|
@@ -95,11 +186,22 @@ steps = PaperTrailDiff.timeline(
|
|
|
95
186
|
|
|
96
187
|
steps.first.from_version # the original PaperTrail version
|
|
97
188
|
steps.first.to_version # the next PaperTrail version
|
|
189
|
+
steps.first.from_boundary # immutable presentation metadata
|
|
190
|
+
steps.first.to_boundary
|
|
98
191
|
steps.first.diff # a PaperTrailDiff::Diff
|
|
192
|
+
steps.first.empty? # delegates to the diff
|
|
99
193
|
steps.first.to_h
|
|
100
194
|
# => { from_version_id: 2, to_version_id: 3, diff: { ... } }
|
|
101
195
|
```
|
|
102
196
|
|
|
197
|
+
Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
|
|
198
|
+
`empty?`. A historical boundary has `event`, `whodunnit`, `record`,
|
|
199
|
+
`recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
|
|
200
|
+
also retain their original `from_version` and `to_version` for callers that
|
|
201
|
+
need custom PaperTrail metadata. Existing `Step#to_h` and
|
|
202
|
+
`ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
|
|
203
|
+
metadata.
|
|
204
|
+
|
|
103
205
|
Every version boundary remains in the result, even when its diff is empty after
|
|
104
206
|
ignored fields are removed. Equal boundaries return a frozen empty array.
|
|
105
207
|
|
|
@@ -120,7 +222,7 @@ steps = PaperTrailDiff.activity_timeline(
|
|
|
120
222
|
associations: ["comments.replies", :author]
|
|
121
223
|
)
|
|
122
224
|
|
|
123
|
-
steps.reject
|
|
225
|
+
steps.reject(&:empty?)
|
|
124
226
|
```
|
|
125
227
|
|
|
126
228
|
Pass the record explicitly as `to:` to include current state without creating a
|
|
@@ -164,9 +266,55 @@ intermediate events without transaction-backed owner checkpoints. Historical
|
|
|
164
266
|
HABTM activity and `compare(version, article, associations: [:tags])` remain
|
|
165
267
|
supported where the historical endpoint has usable PT-AT metadata.
|
|
166
268
|
|
|
167
|
-
Both timeline APIs preserve empty boundaries. A display may
|
|
168
|
-
`
|
|
169
|
-
boundary.
|
|
269
|
+
Both timeline APIs preserve empty boundaries. A display may use
|
|
270
|
+
`steps.reject(&:empty?)`, while audit-oriented callers can retain every
|
|
271
|
+
recorded boundary.
|
|
272
|
+
|
|
273
|
+
## Select mutations by time
|
|
274
|
+
|
|
275
|
+
`timeline`, `activity_timeline`, and `analyze` also accept a finite time range
|
|
276
|
+
through `within:` instead of explicit `from:` and `to:` versions. Half-open
|
|
277
|
+
ranges are recommended for adjacent reporting windows:
|
|
278
|
+
|
|
279
|
+
```ruby
|
|
280
|
+
window = Time.zone.parse("2026-08-01")...Time.zone.parse("2026-09-01")
|
|
281
|
+
|
|
282
|
+
steps = PaperTrailDiff.timeline(article, within: window)
|
|
283
|
+
|
|
284
|
+
activity_steps = PaperTrailDiff.activity_timeline(
|
|
285
|
+
article,
|
|
286
|
+
within: window,
|
|
287
|
+
associations: ["comments.replies", :author]
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
analysis = PaperTrailDiff.analyze(
|
|
291
|
+
article,
|
|
292
|
+
within: window,
|
|
293
|
+
associations: ["comments.replies"],
|
|
294
|
+
activity: true
|
|
295
|
+
)
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
The range selects mutations by the timestamp of their source PaperTrail
|
|
299
|
+
version. Ruby's inclusive (`..`) and exclusive (`...`) end semantics are
|
|
300
|
+
honored. A returned step's `to_boundary` may be after the range: because a
|
|
301
|
+
PaperTrail version is a pre-change snapshot, the gem needs one later root
|
|
302
|
+
version to reveal the final selected mutation. That version is reconstruction
|
|
303
|
+
context, not an additional selected mutation.
|
|
304
|
+
|
|
305
|
+
If the window contains a relevant mutation but no later root version exists,
|
|
306
|
+
the call raises `PaperTrailDiff::IncompleteTimeRangeError`. Create a root
|
|
307
|
+
checkpoint after the reporting window before running historical analysis. The
|
|
308
|
+
gem does not silently substitute current database state. A root-only window
|
|
309
|
+
with no selected mutation returns a frozen empty timeline.
|
|
310
|
+
|
|
311
|
+
Time ranges and explicit `from:`/`to:` endpoints are mutually exclusive.
|
|
312
|
+
Malformed, open-ended, or reversed ranges raise
|
|
313
|
+
`PaperTrailDiff::InvalidTimeRangeError`. With `activity_timeline`, selected
|
|
314
|
+
descendant versions are included even when no root mutation occurred inside
|
|
315
|
+
the window; the later root checkpoint is still required so PT-AT can
|
|
316
|
+
reconstruct the enclosing graph and determine whether descendant activity is
|
|
317
|
+
present. The ordinary `timeline` remains a root-only checkpoint timeline.
|
|
170
318
|
|
|
171
319
|
## Build an endpoint and timeline together
|
|
172
320
|
|
|
@@ -191,8 +339,9 @@ analysis.activity_timeline
|
|
|
191
339
|
```
|
|
192
340
|
|
|
193
341
|
Without `activity: true`, `analysis.activity_timeline` is `nil` and no activity
|
|
194
|
-
work is performed. `analyze`
|
|
195
|
-
`
|
|
342
|
+
work is performed. `analyze` accepts explicit historical versions or
|
|
343
|
+
`within:`, but not a current-record endpoint; use the standalone
|
|
344
|
+
`activity_timeline(..., to: article)` API when the final boundary must be live.
|
|
196
345
|
|
|
197
346
|
`timeline`, `activity_timeline`, and both forms of `analyze` prepare the selected
|
|
198
347
|
historical range once. The loader walks only the explicit association paths and
|
|
@@ -211,20 +360,49 @@ and other unsupported shapes fall back to the ordinary PT-AT point reifier on
|
|
|
211
360
|
a per-reflection basis. This hybrid path preserves existing reconstruction
|
|
212
361
|
behavior while avoiding repeated association queries for supported paths.
|
|
213
362
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
without
|
|
363
|
+
For supported collection events, adjacent snapshots share an immutable
|
|
364
|
+
identity-position index and carry a one-step transition hint. Diffing therefore
|
|
365
|
+
visits only the changed member instead of hashing the whole collection again.
|
|
366
|
+
Prepared scalar history also exposes the predecessor and successor attributes
|
|
367
|
+
for isolated update events, allowing activity reconstruction to update the
|
|
368
|
+
immutable snapshot directly instead of reifying and deserializing the same
|
|
369
|
+
PaperTrail event again. Compatible scalar payloads are decoded into prepared
|
|
370
|
+
state without first constructing a disposable Active Record object. Encrypted,
|
|
371
|
+
schema-mismatched, missing, ambiguous, or identity-changing states fall back to
|
|
372
|
+
the ordinary event reifier.
|
|
373
|
+
For a direct nested `has_many` such as `comments.replies`, the child's foreign
|
|
374
|
+
key also locates its parent snapshot directly rather than walking every
|
|
375
|
+
comment. Membership changes and ambiguous or unsupported relationship shapes
|
|
376
|
+
retain the general comparator and traversal fallback.
|
|
377
|
+
|
|
378
|
+
Activity event loading is bounded to the selected range. Association identity
|
|
379
|
+
discovery retains one indexed checkpoint for members present at the starting
|
|
380
|
+
boundary, then considers later association activity and current members; it no
|
|
381
|
+
longer materializes every pre-range association row. Prepared scalar state also
|
|
382
|
+
retains later successor versions for selected identities because a PaperTrail
|
|
383
|
+
version is a pre-change snapshot and may be the only correct state for an
|
|
384
|
+
earlier boundary. Memory therefore scales with relevant selected history, not
|
|
385
|
+
only with the number of returned steps. Keep requested paths and ranges
|
|
386
|
+
intentional. An activity timeline must also emit a diff for every selected
|
|
387
|
+
event. Repeated events within one wide collection still copy the frozen records
|
|
388
|
+
array when producing each immutable snapshot, so they can do pointer-copying
|
|
389
|
+
work proportional to the number of events times the collection width even when
|
|
390
|
+
SQL and Ruby-level comparison work stay linear. Bound or paginate unusually
|
|
391
|
+
wide activity ranges in latency-sensitive requests.
|
|
392
|
+
|
|
393
|
+
For large histories, applications should give the database a matching
|
|
394
|
+
composite index. A typical PaperTrail installation can add one without making
|
|
395
|
+
it a requirement of this gem:
|
|
222
396
|
|
|
223
397
|
```ruby
|
|
224
398
|
add_index :versions, %i[item_type item_id created_at id],
|
|
225
399
|
name: "index_versions_on_item_and_timeline"
|
|
226
400
|
```
|
|
227
401
|
|
|
402
|
+
This index is especially useful for repeated `within:` queries because root
|
|
403
|
+
selection is constrained by `item_type`, `item_id`, and `created_at`, then
|
|
404
|
+
ordered deterministically by timestamp and version ID.
|
|
405
|
+
|
|
228
406
|
PT-AT's normal index beginning with `foreign_key_name`, `foreign_key_id`, and
|
|
229
407
|
`foreign_type` should also be retained on `version_associations`.
|
|
230
408
|
|
|
@@ -323,6 +501,61 @@ database state. Unknown names and unsupported macros raise explicit
|
|
|
323
501
|
Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
|
|
324
502
|
that base error.
|
|
325
503
|
|
|
504
|
+
## Traverse result trees
|
|
505
|
+
|
|
506
|
+
The nested tree remains the canonical, lossless result. For renderers,
|
|
507
|
+
counters, exports, and notifications, `Diff#each_change` provides a
|
|
508
|
+
deterministic depth-first stream of semantic changes:
|
|
509
|
+
|
|
510
|
+
<!-- executable:readme-traversal-changes -->
|
|
511
|
+
```ruby
|
|
512
|
+
diff.each_change do |entry|
|
|
513
|
+
entry.kind # :attribute_changed, :record_added, ...
|
|
514
|
+
entry.association_path # ["comments", "replies"]
|
|
515
|
+
entry.record_path # frozen RecordReference objects
|
|
516
|
+
entry.association_kind # :has_many, :belongs_to, ...
|
|
517
|
+
entry.attribute # "body" for an attribute entry
|
|
518
|
+
entry.value # ValueChange, RecordChange, or RecordSnapshot
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
counts = diff.each_change.map(&:kind).tally
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
`record_changed` entries are emitted before that record's attribute and nested
|
|
525
|
+
association changes, allowing an application to count both changed records and
|
|
526
|
+
changed fields. Singular relationship operations are classified separately as
|
|
527
|
+
`relationship_added`, `relationship_removed`, or `relationship_replaced`.
|
|
528
|
+
Root create/delete transitions remain `record_presence_changed`.
|
|
529
|
+
|
|
530
|
+
Added, removed, and replaced records carry complete bounded snapshots. Use
|
|
531
|
+
`each_entry` when a renderer also needs that nested state without writing a
|
|
532
|
+
second snapshot walker:
|
|
533
|
+
|
|
534
|
+
<!-- executable:readme-traversal-entries -->
|
|
535
|
+
```ruby
|
|
536
|
+
diff.each_entry do |entry|
|
|
537
|
+
next unless entry.included_state?
|
|
538
|
+
|
|
539
|
+
entry.kind # :record_included, :attribute_included, :association_included
|
|
540
|
+
entry.state # :before or :after
|
|
541
|
+
entry.context # :included_state
|
|
542
|
+
end
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
An included record is historical context, not evidence that the nested record
|
|
546
|
+
changed independently. `each_change` therefore omits included-state entries.
|
|
547
|
+
Both methods return an `Enumerator` when no block is supplied and return the
|
|
548
|
+
diff when called with a block. Entries and their paths are immutable and have
|
|
549
|
+
deterministic `to_h` output. The root location uses empty association and record
|
|
550
|
+
paths; descendant `record_path` values contain only the explicitly traversed
|
|
551
|
+
descendant identities.
|
|
552
|
+
|
|
553
|
+
Traversal is path-preserving and does not deduplicate. If the same record is
|
|
554
|
+
reachable through two selected paths, it appears at both locations so the
|
|
555
|
+
consumer can choose whether path identity or record identity controls counting.
|
|
556
|
+
Walking a result never performs database access and never discovers additional
|
|
557
|
+
associations.
|
|
558
|
+
|
|
326
559
|
## Discover and diagnose associations
|
|
327
560
|
|
|
328
561
|
Configuration UIs can use bounded public reflection instead of duplicating the
|
|
@@ -392,6 +625,7 @@ The public result types are:
|
|
|
392
625
|
- `PaperTrailDiff::ActivityBoundary`
|
|
393
626
|
- `PaperTrailDiff::Analysis`
|
|
394
627
|
- `PaperTrailDiff::ValueChange`
|
|
628
|
+
- `PaperTrailDiff::TraversalEntry`
|
|
395
629
|
- `PaperTrailDiff::RecordReference`
|
|
396
630
|
- `PaperTrailDiff::RecordSnapshot`
|
|
397
631
|
- `PaperTrailDiff::RecordChange`
|
|
@@ -404,11 +638,13 @@ The public result types are:
|
|
|
404
638
|
They expose readers, are frozen after construction, and provide deterministic
|
|
405
639
|
`to_h` output. Structural hash keys are symbols; attribute and association
|
|
406
640
|
names are strings. Attribute values retain their Ruby types. `RecordChange#record`
|
|
407
|
-
is a `RecordReference` with `type` and `id` readers. `
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
641
|
+
is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
|
|
642
|
+
`#association` return the final components of their corresponding paths. `Step`
|
|
643
|
+
itself is frozen but intentionally retains the original, potentially mutable
|
|
644
|
+
PaperTrail version objects for metadata access; its new boundary readers provide
|
|
645
|
+
an immutable presentation interface, while `Step#to_h` continues to emit only
|
|
646
|
+
version IDs. `ActivityStep` retains only immutable boundary metadata and
|
|
647
|
+
serializes as `{ from: ..., to: ..., diff: ... }`.
|
|
412
648
|
|
|
413
649
|
## Historical correctness and limitations
|
|
414
650
|
|
|
@@ -459,6 +695,9 @@ mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
|
|
|
459
695
|
|
|
460
696
|
The default Rake task runs core specs without PT-AT loaded, association specs in
|
|
461
697
|
a separate process, RuboCop, generated-signature verification, and Steep.
|
|
698
|
+
Selected Ruby blocks in this README and the quickstart are executed in those
|
|
699
|
+
same isolated test sessions; the `<!-- executable:... -->` marker opts a block
|
|
700
|
+
into the appropriate stateful session.
|
|
462
701
|
Inline `#:` annotations in `lib/` generate the committed RBS files under
|
|
463
702
|
`sig/generated/`.
|
|
464
703
|
|
|
@@ -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,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Loads direct-child identities active at or after one activity boundary.
|
|
6
|
+
class ActivityChildCandidateLoader
|
|
7
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> void
|
|
8
|
+
def initialize(parent_class, parent_ids, reflection, range)
|
|
9
|
+
@parent_class = parent_class
|
|
10
|
+
@parent_ids = parent_ids
|
|
11
|
+
@reflection = reflection
|
|
12
|
+
@range = range
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: () -> Array[untyped]
|
|
16
|
+
def call
|
|
17
|
+
historical_ids | current_ids
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# @rbs @parent_class: untyped
|
|
23
|
+
# @rbs @parent_ids: Array[untyped]
|
|
24
|
+
# @rbs @reflection: untyped
|
|
25
|
+
# @rbs @range: ActivityRange
|
|
26
|
+
|
|
27
|
+
#: () -> Array[untyped]
|
|
28
|
+
def historical_ids
|
|
29
|
+
version_class = @parent_class.paper_trail.version_class
|
|
30
|
+
candidate_scope(version_class).call(historical_relation(version_class))
|
|
31
|
+
.distinct.pluck(version_class.arel_table[:item_id])
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (untyped) -> untyped
|
|
35
|
+
def historical_relation(version_class)
|
|
36
|
+
@parent_class.paper_trail.version_association_class.joins(:version).where(
|
|
37
|
+
foreign_key_name: @reflection.foreign_key.to_s,
|
|
38
|
+
foreign_key_id: @parent_ids,
|
|
39
|
+
foreign_type: parent_types
|
|
40
|
+
).where(version_class.table_name => { item_type: child_item_type })
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped) -> VersionAssociationCandidateScope
|
|
44
|
+
def candidate_scope(version_class)
|
|
45
|
+
VersionAssociationCandidateScope.new(
|
|
46
|
+
version_class,
|
|
47
|
+
@range.start_time,
|
|
48
|
+
end_at: @range.end_time
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
#: () -> Array[untyped]
|
|
53
|
+
def current_ids
|
|
54
|
+
return [] unless @range.current_end?
|
|
55
|
+
|
|
56
|
+
relation = @reflection.klass.base_class.unscoped.where(
|
|
57
|
+
@reflection.foreign_key => @parent_ids
|
|
58
|
+
)
|
|
59
|
+
relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
|
|
60
|
+
relation.distinct.pluck(@reflection.klass.primary_key)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
#: () -> String
|
|
64
|
+
def child_item_type
|
|
65
|
+
@reflection.klass.base_class.name
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
#: () -> Array[String?]
|
|
69
|
+
def parent_types
|
|
70
|
+
[nil, '', @parent_class.name.to_s, @parent_class.base_class.name.to_s].uniq
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|