paper_trail_diff 0.13.1 → 0.14.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 +287 -0
- data/QUICKSTART.md +9 -6
- data/README.md +84 -9
- data/docs/architecture.md +32 -9
- data/docs/performance.md +106 -13
- data/docs/reference.md +242 -29
- data/docs/testing.md +34 -11
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
- data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
- data/lib/paper_trail_diff/activity_range.rb +14 -2
- data/lib/paper_trail_diff/activity_relationship.rb +12 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
- data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
- data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
- data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
- data/lib/paper_trail_diff/association_discovery.rb +8 -13
- data/lib/paper_trail_diff/association_traversal.rb +169 -45
- data/lib/paper_trail_diff/batch_safety.rb +23 -15
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
- data/lib/paper_trail_diff/comparison_batch.rb +37 -10
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
- data/lib/paper_trail_diff/diagnostics.rb +26 -8
- data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
- data/lib/paper_trail_diff/errors.rb +2 -1
- data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
- data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
- data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
- data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
- data/lib/paper_trail_diff/prepared_history.rb +63 -54
- data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
- data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
- data/lib/paper_trail_diff/recorded_changes.rb +83 -0
- data/lib/paper_trail_diff/recorded_class.rb +53 -0
- data/lib/paper_trail_diff/scope_condition.rb +331 -0
- data/lib/paper_trail_diff/scope_ranking.rb +196 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
- data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
- data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
- data/lib/paper_trail_diff/support.rb +9 -4
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
- data/lib/paper_trail_diff/time_range.rb +9 -12
- data/lib/paper_trail_diff/time_version_range.rb +5 -5
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
- data/lib/paper_trail_diff/version_range.rb +8 -15
- data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
- data/lib/paper_trail_diff.rb +31 -2
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
- data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
- data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
- data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
- data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
- data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
- data/sig/generated/paper_trail_diff/errors.rbs +2 -1
- data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
- data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
- data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
- data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
- data/sig/generated/paper_trail_diff/support.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
- data/sig/generated/paper_trail_diff.rbs +21 -2
- metadata +20 -4
data/docs/performance.md
CHANGED
|
@@ -8,14 +8,103 @@ does not establish that a batch API meets that purpose.
|
|
|
8
8
|
The [follow-up reconstruction investigation](../benchmark/investigation/README.md)
|
|
9
9
|
records the pre-fix N+1 paths and direct-snapshot experiments. Production now uses
|
|
10
10
|
prepared event successors even without changesets, and directly normalizes plain
|
|
11
|
-
root/direct-child states.
|
|
12
|
-
`
|
|
11
|
+
root/direct-child states. Scopes and default scopes made of plain conditions,
|
|
12
|
+
and a `limit` or `offset` with a total integer order, are read from prepared
|
|
13
|
+
history; other scoped batch paths, such as owner-dependent scopes, require
|
|
14
|
+
`allow_expensive: true`, and that opt-in can still cause N+1 work. Model callbacks,
|
|
13
15
|
custom types and callable predicates also remain outside a flat-query guarantee.
|
|
14
16
|
|
|
15
17
|
The benchmark exercises `analyze_scope` with genuine PaperTrail history. It is
|
|
16
18
|
a local SQLite baseline for finding work inside the gem, not a production
|
|
17
19
|
latency guarantee or a comparison of SQL dialects.
|
|
18
20
|
|
|
21
|
+
## Limited associations: 2026-10-29
|
|
22
|
+
|
|
23
|
+
A `limit` or `offset` with a total order on integer or boolean columns, and an
|
|
24
|
+
ordered `has_one`, are now read from prepared history: each record's stored
|
|
25
|
+
values decide whether it meets the scope's conditions, and the matching records
|
|
26
|
+
are ranked as the association's query ranks them. Batch methods no longer need
|
|
27
|
+
`allow_expensive: true` for them. The probe's `limited` scenario reports
|
|
28
|
+
articles with `has_many :limited_comments, -> { order(:id).limit(1) }`:
|
|
29
|
+
|
|
30
|
+
| `limited` | SQL at 1 / 5 / 20 roots | `reify` calls | Model initializations |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| before, with `allow_expensive: true` | 24 / 72 / 252 | 8 / 40 / 160 | 30 / 150 / 600 |
|
|
33
|
+
| after, without an opt-in | 13 / 17 / 32 | 0 / 0 / 0 | 10 / 50 / 200 |
|
|
34
|
+
|
|
35
|
+
One query per root remains: the current end loads each article's limited
|
|
36
|
+
comments on its own, because Active Record's preloader applies a limit or
|
|
37
|
+
offset to all owners' records at once rather than to each owner's.
|
|
38
|
+
|
|
39
|
+
## Scoped associations: 2026-10-29
|
|
40
|
+
|
|
41
|
+
An association scope, or a target's default scope, made of plain conditions
|
|
42
|
+
(`where(reviewer_id: nil)`, `where.not(body: "hidden")`) used to fall back to
|
|
43
|
+
PT-AT for every record, so batch methods required `allow_expensive: true` for
|
|
44
|
+
it. It is now read from prepared history, checking each record's stored values
|
|
45
|
+
against the scope. The probe below reports articles with their comments,
|
|
46
|
+
through `has_many :scoped_comments, -> { where(reviewer_id: nil) }`
|
|
47
|
+
(`scoped`) and through a `TrackedComment` default scope (`default_scope`):
|
|
48
|
+
|
|
49
|
+
| Scenario and code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
|
|
50
|
+
| --- | --- | --- |
|
|
51
|
+
| `scoped`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
|
|
52
|
+
| `scoped`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
|
|
53
|
+
| `default_scope`, before, with `allow_expensive: true` | 15 / 27 / 72 | 1 / 5 / 20 |
|
|
54
|
+
| `default_scope`, after, without an opt-in | 12 / 12 / 12 | 0 / 0 / 0 |
|
|
55
|
+
|
|
56
|
+
Model initializations fell from 17 / 85 / 340 (`scoped`) and 19 / 95 / 380
|
|
57
|
+
(`default_scope`) to 10 / 50 / 200, the same as unscoped `comments`. The
|
|
58
|
+
default-scoped comments are read without `reify` because their scope assigns
|
|
59
|
+
no attributes to a new record; one such as `where(hidden: false)` does, so
|
|
60
|
+
those records are still rebuilt with `reify`, though the query count stays
|
|
61
|
+
flat. `limited` (`order(:id).limit(1)`) and `owner` (a scope that takes the
|
|
62
|
+
article) still raise without the opt-in, and cost what they did.
|
|
63
|
+
|
|
64
|
+
## Fewer `reify` calls: 2026-10-29
|
|
65
|
+
|
|
66
|
+
The `reify` calls that remained came from one place: building a root's
|
|
67
|
+
snapshot from its own version (`HistoricalSnapshotStore#snapshot_from_version`).
|
|
68
|
+
That snapshot is built from stored attributes only when every requested
|
|
69
|
+
association can be read the same way, and `has_many :through` and polymorphic
|
|
70
|
+
`belongs_to` could not. So the whole root was rebuilt with `reify`, and its
|
|
71
|
+
associations as model instances. Both are now read from stored attributes,
|
|
72
|
+
resolving each part of a through as the record path does, and keeping only
|
|
73
|
+
join records of the `source_type:` when there is one. A type that names no
|
|
74
|
+
class, a default-scoped or unversioned target, and every scoped association
|
|
75
|
+
still take the record path as before.
|
|
76
|
+
|
|
77
|
+
Counts from `benchmark/investigation/reconstruction.rb` at 1 / 5 / 20 roots.
|
|
78
|
+
`source_type_through` and `polymorphic_belongs_to` are new probe scenarios: a
|
|
79
|
+
holder's links to an author and to a tag, one moving to another author.
|
|
80
|
+
|
|
81
|
+
| Scenario | `reify` before | `reify` after | Model instances before | Model instances after |
|
|
82
|
+
| --- | --- | --- | --- | --- |
|
|
83
|
+
| `through_has_many` | 7 / 35 / 140 | 0 / 0 / 0 | 36 / 180 / 720 | 14 / 70 / 280 |
|
|
84
|
+
| `source_type_through` | 7 / 35 / 140 | 0 / 0 / 0 | 45 / 225 / 900 | 22 / 110 / 440 |
|
|
85
|
+
| `polymorphic_belongs_to` | 2 / 10 / 40 | 0 / 0 / 0 | 14 / 70 / 280 | 10 / 50 / 200 |
|
|
86
|
+
|
|
87
|
+
Query counts are unchanged: 20, 20 and 14 at every size. `belongs_to`,
|
|
88
|
+
`comments` and `checkpoints` made no `reify` calls before and are unchanged.
|
|
89
|
+
Model instances still grow with the roots: they are the snapshots' own records
|
|
90
|
+
and, for the two polymorphic scenarios, a few states the activity timeline
|
|
91
|
+
builds as records.
|
|
92
|
+
|
|
93
|
+
## Through associations: 2026-10-29
|
|
94
|
+
|
|
95
|
+
A `has_many :through` whose source is a `has_many` used to fall back to PT-AT
|
|
96
|
+
for every record, so batch methods required `allow_expensive: true` for it. It
|
|
97
|
+
now reads prepared history like any other through. The five-root
|
|
98
|
+
`through_has_many` probe below, an author's comments through articles:
|
|
99
|
+
|
|
100
|
+
| Code | SQL at 1 / 5 / 20 roots | `reify` calls at 1 / 5 / 20 roots |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| Before, with `allow_expensive: true` | 56 / 200 / 740 | 15 / 75 / 300 |
|
|
103
|
+
| After, without an opt-in | 20 / 20 / 20 | 7 / 35 / 140 |
|
|
104
|
+
|
|
105
|
+
The `reify` calls still grew with the roots: each root's own versions were
|
|
106
|
+
still rebuilt that way, until the change above.
|
|
107
|
+
|
|
19
108
|
## Direct reconstruction follow-up: 2026-09-07
|
|
20
109
|
|
|
21
110
|
Implementation: `1b6b1b9`, followed by belongs-to discovery fix `f6a4ae2`.
|
|
@@ -45,9 +134,10 @@ Ruby memory was about 15.0, 20.8 and 19.9 MiB respectively. These timings do not
|
|
|
45
134
|
establish a retained-activity improvement.
|
|
46
135
|
|
|
47
136
|
A querying initialization callback still produced 10 / 26 / 86 queries, and a
|
|
48
|
-
querying historical predicate produced 8 / 12 / 27. Custom behavior,
|
|
49
|
-
|
|
50
|
-
|
|
137
|
+
querying historical predicate produced 8 / 12 / 27. Custom behavior, advanced
|
|
138
|
+
graph reconstruction, and non-leaf activity fallbacks may still instantiate
|
|
139
|
+
historical records, as does a polymorphic `belongs_to` to a default-scoped
|
|
140
|
+
class. There is no blanket no-N+1 guarantee.
|
|
51
141
|
|
|
52
142
|
Production parity matches PaperTrail for STI, enum, decimal, date, boolean, JSON
|
|
53
143
|
and serialized values, including missing fields supplied by callbacks/default
|
|
@@ -57,7 +147,7 @@ scripts are not included in the installed gem), reproduce these checks with:
|
|
|
57
147
|
```console
|
|
58
148
|
PTD_PRODUCTION=1 mise exec -- bundle exec ruby benchmark/investigation/scalar_parity.rb
|
|
59
149
|
mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb comments_without_changes 20
|
|
60
|
-
|
|
150
|
+
mise exec -- bundle exec ruby benchmark/investigation/reconstruction.rb through_has_many 5
|
|
61
151
|
```
|
|
62
152
|
|
|
63
153
|
## Reproduce
|
|
@@ -300,10 +390,13 @@ Deleted history has no original timed counterpart here. Its five queries at both
|
|
|
300
390
|
a small root ceiling can load a full 250-candidate version batch, subject to the
|
|
301
391
|
separate version budget. Requested analysis history and retained snapshots
|
|
302
392
|
still consume memory proportional to the data involved.
|
|
303
|
-
- **Selection
|
|
304
|
-
record held during the window, not continuous-time membership.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
393
|
+
- **Selection reads the window's versions.** Historical filters test the states a
|
|
394
|
+
record held during the window, not continuous-time membership. Activity
|
|
395
|
+
selection also reaches roots through their requested associations, with one
|
|
396
|
+
query per association level, not per root. Those queries scan the window's
|
|
397
|
+
versions of each associated model and their `version_associations` rows; ids
|
|
398
|
+
stay in subqueries unless `versions.item_id` and the keys differ in type, in
|
|
399
|
+
which case they are read in slices of 250.
|
|
400
|
+
|
|
401
|
+
Batching scoped associations needs tests of membership over time like those
|
|
402
|
+
for `has_many :through`, especially around ownership changes.
|
data/docs/reference.md
CHANGED
|
@@ -19,6 +19,7 @@ including ones most applications never need.
|
|
|
19
19
|
- [Ignoring fields](#ignoring-fields)
|
|
20
20
|
- [Reading results](#reading-results)
|
|
21
21
|
- [JSON columns](#json-columns)
|
|
22
|
+
- [What a version recorded](#what-a-version-recorded): `recorded_changes`
|
|
22
23
|
- [Checking your setup](#checking-your-setup)
|
|
23
24
|
- [Models, schemas, and edge cases](#models-schemas-and-edge-cases)
|
|
24
25
|
- [Instrumentation](#instrumentation)
|
|
@@ -60,7 +61,7 @@ Take an article created as `"Draft"`, then updated to `"Published"`, then to
|
|
|
60
61
|
only in the table
|
|
61
62
|
```
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
Four things follow from this:
|
|
64
65
|
|
|
65
66
|
- **A `create` version stands for "no record yet".** Its `reify` returns `nil`.
|
|
66
67
|
Comparing it with a later state reports the record as added
|
|
@@ -70,6 +71,14 @@ Three things follow from this:
|
|
|
70
71
|
change from the current record.
|
|
71
72
|
- **A change shows up between two points, never at one.** That is why the
|
|
72
73
|
timeline methods return steps, each comparing one state with the next.
|
|
74
|
+
- **A change saved without a version joins the change before it.** Writing
|
|
75
|
+
with `update_columns` or with versioning off creates no version, but it
|
|
76
|
+
changes the values the record's next version stores, or its current row. The
|
|
77
|
+
gem reads the result of a change from there, so the write appears in the step
|
|
78
|
+
for that record's previous recorded change and is attributed to its author.
|
|
79
|
+
Ending a range at a version leaves out only the writes after it.
|
|
80
|
+
[`recorded_changes`](#what-a-version-recorded) reads what a version itself
|
|
81
|
+
recorded.
|
|
73
82
|
|
|
74
83
|
## Choosing a method
|
|
75
84
|
|
|
@@ -142,8 +151,9 @@ the current record instead. The step's `to_boundary.kind` is then `:current`.
|
|
|
142
151
|
This works the same for a window that ends now and one that ended long ago.
|
|
143
152
|
|
|
144
153
|
Reading the current record has one consequence: it includes changes PaperTrail
|
|
145
|
-
never recorded, such as an `update_columns` call.
|
|
146
|
-
|
|
154
|
+
never recorded, such as an `update_columns` call. Like any
|
|
155
|
+
[change saved without a version](#how-papertrail-stores-history), each appears
|
|
156
|
+
in the step for that record's last recorded change.
|
|
147
157
|
|
|
148
158
|
If the window's last change destroyed the record, the window closes on that
|
|
149
159
|
deletion instead. See [a destroyed record](#a-destroyed-record).
|
|
@@ -216,6 +226,11 @@ diff.to_h
|
|
|
216
226
|
`ReversedEndpointsError`, because the result would look valid with every
|
|
217
227
|
change backwards. A current record can be on either side.
|
|
218
228
|
- Both sides must belong to the same record.
|
|
229
|
+
- Associations at a version are read from the history recorded since then, plus
|
|
230
|
+
the current rows for records that have not changed since. A comment that later
|
|
231
|
+
moved to another article is still found at the versions where it belonged.
|
|
232
|
+
This also holds when both sides are versions, or the record has since been
|
|
233
|
+
deleted.
|
|
219
234
|
- A current record must be saved, not destroyed, and have no unsaved changes.
|
|
220
235
|
The gem reloads it, so in-memory edits and cached associations are ignored.
|
|
221
236
|
- Options: `associations:`, `ignore:`, and `reload_live_endpoints:` (see
|
|
@@ -317,9 +332,10 @@ steps.reject(&:empty?).each do |step|
|
|
|
317
332
|
end
|
|
318
333
|
```
|
|
319
334
|
|
|
320
|
-
- Each step's `diff` is what the event at its `source_boundary` changed
|
|
321
|
-
|
|
322
|
-
|
|
335
|
+
- Each step's `diff` is what the event at its `source_boundary` changed, plus
|
|
336
|
+
anything [saved without a version](#how-papertrail-stores-history) to the
|
|
337
|
+
same record before its next version. Use `source_boundary` for attribution.
|
|
338
|
+
`to_boundary` is only the next point in time and may belong to someone else.
|
|
323
339
|
- Boundaries have a `kind`: `:version`, `:current` (the current record), or
|
|
324
340
|
`:destroyed` (after a deletion). They also have `event`, `whodunnit`,
|
|
325
341
|
`recorded_at`, `item_type`, `item_id`, `transaction_id`, and `record`, and
|
|
@@ -347,7 +363,7 @@ attributed = steps.reject(&:empty?)
|
|
|
347
363
|
by_priya = attributed.select { |step| step.source_boundary.whodunnit == "Priya Shah" }
|
|
348
364
|
|
|
349
365
|
by_priya.first.source_boundary.whodunnit # "Priya Shah"
|
|
350
|
-
by_priya.first.diff #
|
|
366
|
+
by_priya.first.diff # what that one event changed
|
|
351
367
|
|
|
352
368
|
authors = attributed.group_by { |step| step.source_boundary.whodunnit }
|
|
353
369
|
.transform_values(&:length)
|
|
@@ -429,6 +445,13 @@ the range at a version instead (see the
|
|
|
429
445
|
[checkpoint recipe](#saving-a-checkpoint)), or use a join model with
|
|
430
446
|
`has_many :through`.
|
|
431
447
|
|
|
448
|
+
PT-AT records HABTM members under the transaction a version was saved in, and
|
|
449
|
+
does not say which record they belong to. If another record with a HABTM of the
|
|
450
|
+
same name, such as a second article, also saved a version in that transaction
|
|
451
|
+
and members were recorded there, reading the root's membership at that version
|
|
452
|
+
raises `IncompleteAssociationHistoryError` instead of mixing both records'
|
|
453
|
+
members. Save each HABTM owner's versions in a transaction of its own.
|
|
454
|
+
|
|
432
455
|
## analyze
|
|
433
456
|
|
|
434
457
|
`analyze(record)` returns an `Analysis` with the net `diff` and the `timeline`
|
|
@@ -519,9 +542,18 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
|
|
|
519
542
|
|
|
520
543
|
### Which records are selected
|
|
521
544
|
|
|
522
|
-
- **A record
|
|
523
|
-
|
|
524
|
-
|
|
545
|
+
- **A record is selected when it, or a record on a requested association,
|
|
546
|
+
changed in the window.** With `activity: true`, which `report` always uses,
|
|
547
|
+
editing only a comment selects its article when you request
|
|
548
|
+
`associations: [:comments]`. A comment that moved between articles selects
|
|
549
|
+
both. Nested paths count at every level, and a `has_many :through` counts its
|
|
550
|
+
join rows too. Without `activity: true`, an analysis compares a record's own
|
|
551
|
+
versions and cannot show a change to its associations alone, so only a
|
|
552
|
+
record's own version selects it.
|
|
553
|
+
- **An analysis can still be empty.** A record is selected by any version on its
|
|
554
|
+
paths, but its analysis compares only the state you requested. A join row
|
|
555
|
+
whose role changed, or a version that touched only ignored columns, selects a
|
|
556
|
+
record with nothing to report.
|
|
525
557
|
- **A relation checks current values.** `Order.where(status: "open")` means open
|
|
526
558
|
now, not open during the window. Use `historical_filter:` to select by past
|
|
527
559
|
values.
|
|
@@ -533,14 +565,17 @@ It accepts `associations:`, `ignore:`, `snapshots:`, `group:`,
|
|
|
533
565
|
ID tie-breaker to any other order. If a join repeats a record, it is analyzed
|
|
534
566
|
once, in order of first appearance.
|
|
535
567
|
- Selection still scans the versions in the window, so keep windows reasonable
|
|
536
|
-
for large tables.
|
|
568
|
+
for large tables. Each requested association adds one query per level, however
|
|
569
|
+
many records it selects. Changes to associations are found from PT-AT's
|
|
570
|
+
`version_associations`, so history recorded before PT-AT was installed selects
|
|
571
|
+
nothing.
|
|
537
572
|
|
|
538
573
|
### Deleted records
|
|
539
574
|
|
|
540
575
|
A deleted record cannot be tested against a relation's conditions, because it
|
|
541
576
|
has no row. Records that changed in the window but no longer exist are listed in
|
|
542
|
-
`unreachable
|
|
543
|
-
relation.
|
|
577
|
+
`unreachable`, including those selected by a change to their associations. It
|
|
578
|
+
is not paginated and does not mean those records matched the relation.
|
|
544
579
|
|
|
545
580
|
`include_deleted: true` also analyzes them from their `destroy` versions and
|
|
546
581
|
returns the results in `deleted`, keyed the same way. A value is `nil` when no
|
|
@@ -552,7 +587,10 @@ missing_history = report.deleted.select { |_identity, analysis| analysis.nil? }.
|
|
|
552
587
|
```
|
|
553
588
|
|
|
554
589
|
A deleted record's `diff` and `to_snapshot` end at "no record". Its `timeline`
|
|
555
|
-
lists only edits; its `activity_timeline` ends with the deletion.
|
|
590
|
+
lists only edits; its `activity_timeline` ends with the deletion. That last
|
|
591
|
+
step's `diff.record_presence_change.from` is the record just before it was
|
|
592
|
+
deleted, even when it was also created inside the window, where
|
|
593
|
+
`from_snapshot` is `nil`.
|
|
556
594
|
|
|
557
595
|
### Selecting by past values: `historical_filter:`
|
|
558
596
|
|
|
@@ -592,13 +630,75 @@ model instance and may run queries of its own.
|
|
|
592
630
|
|
|
593
631
|
### Costly associations
|
|
594
632
|
|
|
595
|
-
Most associations are loaded for all records together.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
633
|
+
Most associations are loaded for all records together. That includes an
|
|
634
|
+
association whose scope, or whose target's default scope, is plain conditions
|
|
635
|
+
on the target's own columns:
|
|
636
|
+
|
|
637
|
+
```ruby
|
|
638
|
+
has_many :active_cards, -> { where(archived: false) }, class_name: "Card"
|
|
639
|
+
has_many :urgent_cards, -> { where(priority: [1, 2]).order(:id) }, class_name: "Card"
|
|
640
|
+
default_scope { where.not(hidden: true) }
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The gem reads such a scope once and checks each record's stored values against
|
|
644
|
+
it at every point, so a record that moves into or out of the scope is added or
|
|
645
|
+
removed at the version that moved it. It reads equality, `IN`, `nil`, their
|
|
646
|
+
`where.not` forms, and `and`/`or` combinations of them on integer, boolean,
|
|
647
|
+
and enum columns. String and text columns are read only on SQLite and
|
|
648
|
+
PostgreSQL, and only without a column collation: MySQL and SQL Server compare
|
|
649
|
+
strings without regard to case or trailing spaces by default, which the gem
|
|
650
|
+
cannot repeat.
|
|
651
|
+
|
|
652
|
+
A `limit` or `offset`, and the order of a `has_one`, are read the same way
|
|
653
|
+
when the order is total and the gem can repeat it:
|
|
654
|
+
|
|
655
|
+
```ruby
|
|
656
|
+
has_many :first_comments, -> { order(:position, :id).limit(3) }
|
|
657
|
+
has_one :latest_comment, -> { order(id: :desc) }
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
At every point the gem ranks the records that meet the scope's conditions by
|
|
661
|
+
the order, then skips the offset and keeps the limit, as the association's own
|
|
662
|
+
query would have. The order must reach the primary key, so that no two records
|
|
663
|
+
tie, and each key before it must be an integer, boolean, integer enum, date,
|
|
664
|
+
or datetime column of the target, ascending or descending. NULL sorts where the
|
|
665
|
+
database puts it by default: last in an ascending order on PostgreSQL, first on
|
|
666
|
+
SQLite, MySQL, and SQL Server.
|
|
667
|
+
|
|
668
|
+
A datetime key, such as `order(created_at: :desc)`, is read when its column
|
|
669
|
+
declares a precision, as Rails 7 and later give every new `datetime` column,
|
|
670
|
+
when history keeps that many fractional digits, and when Active Record stores
|
|
671
|
+
times in UTC, its default. Active Record truncates a time to its column's
|
|
672
|
+
precision before writing it, so history then holds the value the database
|
|
673
|
+
sorts by. YAML history keeps every digit; JSON history, and a `json` or
|
|
674
|
+
`jsonb` object column, keep `ActiveSupport::JSON::Encoding.time_precision`
|
|
675
|
+
digits, 3 by default, so with them only a `datetime(3)` or coarser column is
|
|
676
|
+
read. A time-of-day column is not read: Active Record keeps the date it was
|
|
677
|
+
given in memory but stores only the time.
|
|
678
|
+
|
|
679
|
+
A `has_many :through` reads its parts as Active Record's own query joins them:
|
|
680
|
+
it keeps each part's conditions but not its `limit`, `offset`, or `has_one`
|
|
681
|
+
cut, so a through over `has_one :latest_comment` holds the records of every
|
|
682
|
+
comment that meets the part's conditions, not only the latest. Its own `limit`
|
|
683
|
+
or `offset` ranks the rows the join yields, so a record reached through two
|
|
684
|
+
join records fills two places, as in the query. A `has_one :through` whose
|
|
685
|
+
own scope or whose parts have a limit, an offset, or a `has_one` order is not
|
|
686
|
+
read.
|
|
687
|
+
|
|
688
|
+
A few associations still need queries per record: a `limit` or `offset` whose
|
|
689
|
+
order is not total or sorts by another column type (strings follow a
|
|
690
|
+
collation) or by a datetime history cannot rank exactly, scopes that take the
|
|
691
|
+
owner (`->(article) { ... }`), scopes written as SQL strings or ranges, scopes
|
|
692
|
+
that join or select, and conditions on other column types or other tables. Batch methods raise `UnsupportedBatchPathError`
|
|
693
|
+
for these by default and name the association. Remove it from
|
|
694
|
+
`associations:`, or pass `allow_expensive: true` to accept the per-record
|
|
695
|
+
cost. Single-record methods always allow them.
|
|
696
|
+
|
|
697
|
+
A polymorphic `belongs_to` is loaded together too, with one query per target
|
|
698
|
+
class. Its classes are known only from the records, though, so batch methods
|
|
699
|
+
cannot reject one with a scope they cannot read in advance: a record that
|
|
700
|
+
points at such a class is read on its own, without an error. A scope on the
|
|
701
|
+
polymorphic `belongs_to` itself is rejected.
|
|
602
702
|
|
|
603
703
|
This check covers known association cases only. Your model callbacks, custom
|
|
604
704
|
attribute types, and callable filters can still run queries. See the
|
|
@@ -642,9 +742,57 @@ comments.changed.first.associations["replies"].added
|
|
|
642
742
|
the association already reports it. The same applies to the foreign key a
|
|
643
743
|
`has_one` or `has_many` child points back with. Without the association, the
|
|
644
744
|
key is an ordinary attribute.
|
|
745
|
+
- A polymorphic `belongs_to` reads each record's target from its type
|
|
746
|
+
column. Because the targets' classes differ, a path cannot continue past
|
|
747
|
+
it: `"notable.comments"` raises `UnsupportedAssociationError`. A record
|
|
748
|
+
whose type names a model without `has_paper_trail` raises
|
|
749
|
+
`UnversionedAssociationError` when its history is read; the gem cannot
|
|
750
|
+
check this earlier, since only the record names the class. A type that
|
|
751
|
+
names no class, such as one a renamed model left in its versions, raises
|
|
752
|
+
`IncompleteAssociationHistoryError` naming the record.
|
|
645
753
|
- `has_many :through` uses the versioned join model's history, so join-model
|
|
646
754
|
attributes are available. HABTM reports membership and the target's
|
|
647
755
|
attributes; it has no join model, so it has no join attributes.
|
|
756
|
+
- A `has_many :through` holds, at each point, the records the owner reached
|
|
757
|
+
through the join records it had then. A record that moves to another join
|
|
758
|
+
record leaves one owner and joins the other, and so does every record under
|
|
759
|
+
a join record that moves to another owner.
|
|
760
|
+
- A `has_many :through` with `source_type:` holds only the records reached
|
|
761
|
+
through join records whose type column names that class, as Active Record
|
|
762
|
+
reads it. A join record that points at another type is never a member, and
|
|
763
|
+
its changes add no activity step. A join record whose type changes adds or
|
|
764
|
+
removes its target in the step of that version.
|
|
765
|
+
- A `has_many :through` that goes through a HABTM, as its `through:`, its
|
|
766
|
+
`source:`, or further down its chain, raises `UnsupportedAssociationError`
|
|
767
|
+
up front in every method. Active Record joins a HABTM through a hidden
|
|
768
|
+
model with no versions, so the through cannot be rebuilt from history.
|
|
769
|
+
Replace the HABTM with a `has_many :through` a versioned join model to
|
|
770
|
+
compare it. `association_paths` leaves such a through out.
|
|
771
|
+
- A HABTM can only be rebuilt on the root record. PT-AT records membership
|
|
772
|
+
when the record that owns it saves a version, in rows that name the
|
|
773
|
+
transaction and the association but not the owner, so a root checkpoint
|
|
774
|
+
holds the root's membership and no one else's. A HABTM later in a path, such
|
|
775
|
+
as `"tags.articles"` or `"author.articles.tags"`, raises
|
|
776
|
+
`UnsupportedAssociationError` up front in every method, even when the path
|
|
777
|
+
leads back to the root, as `"comments.article.tags"` does.
|
|
778
|
+
`association_paths` leaves such a path out. To follow another record's
|
|
779
|
+
memberships, compare that record's own checkpoints, or replace the HABTM
|
|
780
|
+
with a `has_many :through` a versioned join model.
|
|
781
|
+
- A HABTM with a scope, such as `-> { where(active: true) }`, or whose target
|
|
782
|
+
has a default scope, raises `UnsupportedAssociationError` up front in every
|
|
783
|
+
method. PT-AT records a HABTM's members by loading the association through
|
|
784
|
+
its scope when the owner saves, so a target outside the scope then is never
|
|
785
|
+
recorded, and one that enters the scope before the owner's next save is a
|
|
786
|
+
member history cannot show. Replace it with a `has_many :through` a
|
|
787
|
+
versioned join model, which can be scoped. `association_paths` leaves it
|
|
788
|
+
out.
|
|
789
|
+
- A root that uses single-table inheritance gets the associations of the
|
|
790
|
+
class it had at each version, so you can request one that only a subclass
|
|
791
|
+
declares. Without an `item_subtype` column, PaperTrail stores only the base
|
|
792
|
+
class; the gem reads the subclass from the type column the version stored.
|
|
793
|
+
A version where the record was a class without that association raises
|
|
794
|
+
`UnknownAssociationError`, as a current record of that class would. A
|
|
795
|
+
stored type that names no class raises `InvalidEndpointError`.
|
|
648
796
|
- Requesting associations for a historical state without PT-AT raises
|
|
649
797
|
`AssociationTrackingUnavailableError`. Unknown names raise
|
|
650
798
|
`UnknownAssociationError`; unsupported macros raise
|
|
@@ -671,7 +819,10 @@ end
|
|
|
671
819
|
```
|
|
672
820
|
|
|
673
821
|
Without `synchronize_version_creation_timestamp: false`, the checkpoint takes the
|
|
674
|
-
record's old `updated_at` as its time and lands in the past.
|
|
822
|
+
record's old `updated_at` as its time and lands in the past. Save one record per
|
|
823
|
+
transaction: HABTM membership cannot be read from a transaction in which
|
|
824
|
+
another record with the same HABTM also saved a version (see the
|
|
825
|
+
[HABTM limitation](#habtm-limitation)).
|
|
675
826
|
|
|
676
827
|
### Files from ActiveStorage
|
|
677
828
|
|
|
@@ -837,6 +988,56 @@ PaperTrailDiff.nested_changes(from_value, to_value) # also accepts two values
|
|
|
837
988
|
has the whole change.
|
|
838
989
|
- Keys such as `:a` and `"a"` in one object raise `AmbiguousNestedKeyError`.
|
|
839
990
|
|
|
991
|
+
## What a version recorded
|
|
992
|
+
|
|
993
|
+
`recorded_changes(version)` returns the changes PaperTrail stored with one
|
|
994
|
+
version in its `object_changes` column, as a frozen hash of `ValueChange`
|
|
995
|
+
objects in attribute-name order:
|
|
996
|
+
|
|
997
|
+
```ruby
|
|
998
|
+
PaperTrailDiff.recorded_changes(version)
|
|
999
|
+
# => { "title" => <from "Draft" to "Published"> }
|
|
1000
|
+
```
|
|
1001
|
+
|
|
1002
|
+
A step's `diff` compares the record before and after an event instead. The two
|
|
1003
|
+
usually agree. They differ when:
|
|
1004
|
+
|
|
1005
|
+
- something was [saved without a version](#how-papertrail-stores-history)
|
|
1006
|
+
before the record's next version. The diff includes it; this does not.
|
|
1007
|
+
- `has_paper_trail` ignores an attribute or leaves it out of `only:`. PaperTrail
|
|
1008
|
+
stores its value but not its changes, so only the diff reports them.
|
|
1009
|
+
- the version is a `create` or a `destroy`. The diff reports
|
|
1010
|
+
`record_presence_change`; this lists each attribute changing from `nil`, or
|
|
1011
|
+
to `nil`.
|
|
1012
|
+
- an included association's foreign key changed. The diff reports the
|
|
1013
|
+
association instead; this keeps the column.
|
|
1014
|
+
|
|
1015
|
+
Other details:
|
|
1016
|
+
|
|
1017
|
+
- It returns `nil` when nothing was recorded: the version table has no
|
|
1018
|
+
`object_changes` column, or this version has no value in it. A version that
|
|
1019
|
+
recorded no changes, such as a [checkpoint](#saving-a-checkpoint), returns
|
|
1020
|
+
`{}`.
|
|
1021
|
+
- `ignore:` takes an array of attribute names and defaults to `updated_at`, as
|
|
1022
|
+
elsewhere. It does not take `paths:`, because a version does not say which
|
|
1023
|
+
association path it was reached through. Primary keys are left out.
|
|
1024
|
+
- Unlike PaperTrail's `version.changeset`, it never loads the record. That
|
|
1025
|
+
saves a query per version, and a deleted record's values keep their types,
|
|
1026
|
+
such as an enum's label rather than the integer PaperTrail stored.
|
|
1027
|
+
|
|
1028
|
+
Steps name their source version by ID. Load the versions in one query rather
|
|
1029
|
+
than one per step; a model with its own version class needs that class here:
|
|
1030
|
+
|
|
1031
|
+
<!-- executable:reference-recorded-changes -->
|
|
1032
|
+
```ruby
|
|
1033
|
+
ids = steps.map { |step| step.source_boundary.version_id }
|
|
1034
|
+
recorded = PaperTrail::Version.where(id: ids).to_h do |version|
|
|
1035
|
+
[version.id, PaperTrailDiff.recorded_changes(version)]
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
recorded.fetch(steps.last.source_boundary.version_id)
|
|
1039
|
+
```
|
|
1040
|
+
|
|
840
1041
|
## Checking your setup
|
|
841
1042
|
|
|
842
1043
|
List the associations a model supports, for example to build a settings
|
|
@@ -850,6 +1051,14 @@ PaperTrailDiff.association_paths(Article, max_depth: 3).map(&:to_h)
|
|
|
850
1051
|
Each descriptor has `path`, `kind`, `target_type`, `through`, `polymorphic`,
|
|
851
1052
|
and `cycle`. Cycles are marked and not followed.
|
|
852
1053
|
|
|
1054
|
+
Only associations a request accepts are listed, so each path can be passed to
|
|
1055
|
+
`associations:`. The PaperTrail versions association, a `has_many :through`
|
|
1056
|
+
that goes through a HABTM, a scoped HABTM, and a HABTM below the root are left
|
|
1057
|
+
out. A
|
|
1058
|
+
`has_many :through` Active Record cannot follow, such as one through a
|
|
1059
|
+
polymorphic `belongs_to`, raises Active Record's own error from any model
|
|
1060
|
+
discovery reaches, as requesting it does.
|
|
1061
|
+
|
|
853
1062
|
Check two versions for known problems before relying on association history:
|
|
854
1063
|
|
|
855
1064
|
```ruby
|
|
@@ -933,7 +1142,7 @@ All errors inherit from `PaperTrailDiff::Error`.
|
|
|
933
1142
|
| --- | --- |
|
|
934
1143
|
| `ConfigurationError` | An option is invalid |
|
|
935
1144
|
| `UnsupportedBatchPathError` | A batch requests a [costly association](#costly-associations) without `allow_expensive: true` |
|
|
936
|
-
| `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes |
|
|
1145
|
+
| `InvalidEndpointError` | A record is unsaved, destroyed, or has unsaved changes, or `recorded_changes` is given something other than a version |
|
|
937
1146
|
| `UnloadedAssociationError` | `reload_live_endpoints: false` and an association is not loaded |
|
|
938
1147
|
| `VersionMismatchError` | Two endpoints belong to different records |
|
|
939
1148
|
| `ReversedEndpointsError` | Two versions are given newest first |
|
|
@@ -945,9 +1154,9 @@ All errors inherit from `PaperTrailDiff::Error`.
|
|
|
945
1154
|
| `FilterLimitExceededError` | `historical_filter:` needs more than `max_filter_versions:` |
|
|
946
1155
|
| `AssociationTrackingUnavailableError` | Association history is requested without PT-AT |
|
|
947
1156
|
| `UnknownAssociationError` | An association name does not exist |
|
|
948
|
-
| `UnsupportedAssociationError` | An association's macro is not supported |
|
|
949
|
-
| `UnversionedAssociationError` | An association's target model has no `has_paper_trail` |
|
|
950
|
-
| `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction |
|
|
1157
|
+
| `UnsupportedAssociationError` | An association's macro is not supported, a path continues past a polymorphic `belongs_to`, a `has_many :through` goes through a HABTM, or a HABTM is scoped or below the root |
|
|
1158
|
+
| `UnversionedAssociationError` | An association's target model has no `has_paper_trail`, including the class a polymorphic `belongs_to` names |
|
|
1159
|
+
| `IncompleteAssociationHistoryError` | PT-AT did not record enough to rebuild an association, such as HABTM without a transaction or in a transaction another record with that HABTM also saved a version in, or a polymorphic type names no class |
|
|
951
1160
|
| `UnsupportedLiveActivityError` | A HABTM activity range ends at the current record |
|
|
952
1161
|
| `AmbiguousNestedKeyError` | `nested_changes` finds keys like `:a` and `"a"` together |
|
|
953
1162
|
|
|
@@ -956,14 +1165,18 @@ All errors inherit from `PaperTrailDiff::Error`.
|
|
|
956
1165
|
Results can only be as complete as the history PaperTrail and PT-AT recorded.
|
|
957
1166
|
|
|
958
1167
|
- Changes made with versioning off, with `update_columns`, or to `skip:` columns
|
|
959
|
-
were never recorded.
|
|
960
|
-
|
|
1168
|
+
were never recorded. The first two still appear, in the step for the record's
|
|
1169
|
+
previous recorded change and attributed to its author; see
|
|
1170
|
+
[how PaperTrail stores history](#how-papertrail-stores-history).
|
|
961
1171
|
- Deleted versions, schema changes, and serializer changes can affect how old
|
|
962
1172
|
versions read.
|
|
963
1173
|
- Several queries against the current record are not one database snapshot.
|
|
964
1174
|
Use a transaction with a suitable isolation level if that matters.
|
|
965
1175
|
- HABTM membership history exists only where PT-AT recorded it in a
|
|
966
|
-
transaction
|
|
1176
|
+
transaction, and only for the root record without a scope: a scoped HABTM,
|
|
1177
|
+
or one below the root, raises `UnsupportedAssociationError`, and a transaction that another record with
|
|
1178
|
+
that HABTM also saved a version in raises `IncompleteAssociationHistoryError`. HABTM changes cannot become their own
|
|
1179
|
+
activity steps.
|
|
967
1180
|
- PT-AT has its own
|
|
968
1181
|
[limitations](https://github.com/westonganger/paper_trail-association_tracking#limitations),
|
|
969
1182
|
especially for some `has_one` histories. The gem follows PT-AT's configured
|
data/docs/testing.md
CHANGED
|
@@ -46,19 +46,40 @@ passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
|
|
|
46
46
|
- what counts as a change: `Engine`, `CollectionComparator`,
|
|
47
47
|
`CollectionIdentityIndex`, and `NestedComparator`;
|
|
48
48
|
- which columns are compared: `SnapshotNormalizer` and `SnapshotPool`;
|
|
49
|
-
- which
|
|
50
|
-
and `
|
|
49
|
+
- which versions a range reads: `TimeRange`, `TimelineRange`, `VersionRange`,
|
|
50
|
+
`TimeVersionRange`, and `RootVersionPlan`;
|
|
51
|
+
- which versions a batch or filter reads: `BatchedRootVersions`,
|
|
52
|
+
`DeletedAnalysisBatch`, and `HistoricalFilter`;
|
|
53
|
+
- what a version recorded: `RecordedChanges`;
|
|
54
|
+
- which records a scope or report selects: `ScopedRootSelection`,
|
|
55
|
+
`AssociationActivitySelection`, and `AssociationActivityEdge`;
|
|
56
|
+
- which children and targets a record had at each point:
|
|
57
|
+
`PreparedHistoryLoader`, `PreparedEdgeLoader`,
|
|
58
|
+
`ActivityTargetCandidateLoader`, and `PreparedActivityIndex`;
|
|
59
|
+
- where an activity range starts and ends, and which association history its
|
|
60
|
+
query reads: `ActivityRange` and `VersionAssociationCandidateScope`;
|
|
61
|
+
- what activity a record shows: `ActivityTimelineBuilder` and
|
|
62
|
+
`ActivityTransactionGrouper`;
|
|
63
|
+
- which association paths a request accepts: `AssociationTraversal`;
|
|
64
|
+
- whether a transaction's HABTM members are one record's:
|
|
65
|
+
`HabtmTransactionOwners`;
|
|
66
|
+
- which association paths a batch refuses: `BatchSafety`;
|
|
67
|
+
- which records an association scope admits, and how it ranks them:
|
|
68
|
+
`ScopeCondition` and `ScopeRanking`.
|
|
51
69
|
|
|
52
70
|
The specs for the classes that read the database use tables of their own
|
|
53
|
-
(`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb
|
|
54
|
-
they run in both the core and
|
|
71
|
+
(`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb`,
|
|
72
|
+
`spec/support/recorded_changes_database.rb`), so they run in both the core and
|
|
73
|
+
the association suite. The association classes need PT-AT, so their specs are
|
|
74
|
+
in `spec/association` and define any extra tables in the spec file itself.
|
|
55
75
|
|
|
56
|
-
Mutant
|
|
76
|
+
Mutant reads `spec/unit` and `spec/association`, never `spec/integration`.
|
|
77
|
+
Even there it does not run every spec for every mutation, only those whose
|
|
57
78
|
description names the mutated class (`RSpec.describe PaperTrailDiff::Engine`),
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
falling back to wider names such as `PaperTrailDiff` when none does. A spec
|
|
80
|
+
named after a behavior, as most integration specs are, never runs. So each
|
|
81
|
+
mutated class has a unit spec of its own that catches every mutation in it,
|
|
82
|
+
and a class added to `mutant.yml` needs one too.
|
|
62
83
|
|
|
63
84
|
Mutant counts a mutation that runs past its time limit as caught, so
|
|
64
85
|
`mutant.yml` sets the limit well above the time the whole suite takes. With
|
|
@@ -87,8 +108,10 @@ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --sin
|
|
|
87
108
|
When a mutation survives, either add the spec that would catch it, or, if the
|
|
88
109
|
mutated code means the same thing, simplify the code to that form. Reserve a
|
|
89
110
|
`mutant:disable` comment for a method whose surviving mutations all mean the
|
|
90
|
-
same thing as the code, and say why next to it.
|
|
91
|
-
|
|
111
|
+
same thing as the code, and say why next to it.
|
|
112
|
+
|
|
113
|
+
Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb` gives each
|
|
114
|
+
worker its own copy of the SQLite fixture database.
|
|
92
115
|
|
|
93
116
|
## Test clock
|
|
94
117
|
|