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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b8f753f8bf15574c43fe572252b467b6066f097d0ad9990b6f1d8a74f41d938
|
|
4
|
+
data.tar.gz: 536266a31306236cc26e71b3024af058f96a0544d363f0fb971b5b49f9b1b578
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5070f94bc6bb46a58d6a80352aebd2f7c2392e0486c0197fe8977cb257af3b1a7644643fc1587df33bf6b0138df25ac67a3629ccf004fc627de3be883b321db1
|
|
7
|
+
data.tar.gz: 8771c47edd7d8ec39347b7d76883af7c2ff7137ac7670064672dbc5fccea0c196d5e461624c183c8d51890b8a84472625fba8cc283c72d1375a0331fd3cb1efd
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,293 @@
|
|
|
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.14.0] - 2026-10-29
|
|
7
|
+
|
|
8
|
+
### Breaking changes
|
|
9
|
+
|
|
10
|
+
- A `has_and_belongs_to_many` with a scope, or whose target has a default
|
|
11
|
+
scope, raises `UnsupportedAssociationError` up front in every method, and
|
|
12
|
+
`association_paths` leaves it out. PT-AT records a HABTM's members through
|
|
13
|
+
its scope when the owner saves, so a target that entered the scope between
|
|
14
|
+
saves was missing from history, and `activity_timeline` raised `KeyError`.
|
|
15
|
+
Replace it with a `has_many :through` a versioned join model.
|
|
16
|
+
- `report`, and `analyze_scope` with `activity: true`, also select a record
|
|
17
|
+
when only a record on one of its requested `associations:` changed in the
|
|
18
|
+
window: editing a comment selects its article. They used to skip it, although
|
|
19
|
+
`analyze_many` reports that activity. A report can therefore return more
|
|
20
|
+
records and reach `limit:` sooner. Nested paths count at every level,
|
|
21
|
+
`has_many :through` counts its join rows, and a child that moved selects its
|
|
22
|
+
old and new owner. Selection adds one query per association level, not per
|
|
23
|
+
record. Checkpoint analyses (`activity: false`) are unchanged: they compare a
|
|
24
|
+
record's own versions, so only those select it. To keep only records that
|
|
25
|
+
changed themselves, drop analyses whose `timeline` is empty. A record
|
|
26
|
+
selected through a HABTM path in a window that ends at the current record
|
|
27
|
+
raises `UnsupportedLiveActivityError`, as one that changed itself already
|
|
28
|
+
does.
|
|
29
|
+
- A path that continues past a polymorphic `belongs_to`, such as
|
|
30
|
+
`"notable.comments"`, raises `UnsupportedAssociationError`. The part after
|
|
31
|
+
it used to be ignored without a word.
|
|
32
|
+
- A HABTM below the root, such as `"tags.articles"`, `"author.articles.tags"`,
|
|
33
|
+
or `"comments.article.tags"`, raises `UnsupportedAssociationError` up front
|
|
34
|
+
in every method, naming the path and the HABTM. `association_paths` leaves
|
|
35
|
+
such a path out. PT-AT records HABTM membership only when its owner saves a
|
|
36
|
+
version, in rows that do not name the owner, so a root checkpoint holds the
|
|
37
|
+
root's membership alone. These paths read the root's rows instead and
|
|
38
|
+
returned a wrong diff. An article that joined a tag went unreported, and
|
|
39
|
+
against the current record every member read as added. A sibling article
|
|
40
|
+
reached through its author was given the root's tags. `activity_timeline`
|
|
41
|
+
raised `KeyError`. Only a path that led back to the root, like
|
|
42
|
+
`"comments.article.tags"`, read correctly; request `"tags"` instead.
|
|
43
|
+
- A root HABTM read at a version saved in the same transaction as a version of
|
|
44
|
+
another record with a HABTM of that name raises
|
|
45
|
+
`IncompleteAssociationHistoryError`, naming the association and the
|
|
46
|
+
transaction, when PT-AT recorded members there. Those rows do not say which
|
|
47
|
+
record they belong to, so the root used to read every such record's members
|
|
48
|
+
as its own: two articles updated in one transaction each gained the other's
|
|
49
|
+
tags, and an unchanged article reported them removed. It happened in every
|
|
50
|
+
method, with no error. A transaction where only one record with that
|
|
51
|
+
association saved a version, or where none recorded a member, reads as
|
|
52
|
+
before, and `diagnose` reports the shared case as `habtm_transaction_shared`.
|
|
53
|
+
Save HABTM owners' versions in separate transactions, as a
|
|
54
|
+
[checkpoint](docs/reference.md#saving-a-checkpoint) does.
|
|
55
|
+
|
|
56
|
+
### Added
|
|
57
|
+
|
|
58
|
+
- `recorded_changes(version)` returns what PaperTrail stored with one version
|
|
59
|
+
in `object_changes`, as a frozen hash of `ValueChange` objects, typed by the
|
|
60
|
+
version's model. Unlike PaperTrail's `version.changeset`, it never loads the
|
|
61
|
+
record, so it costs no query per version and still types a deleted record's
|
|
62
|
+
values. It returns `nil` when nothing was recorded, and takes an `ignore:`
|
|
63
|
+
array that defaults to `updated_at`.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- Batch reads of a `has_many :through` and of a polymorphic `belongs_to` no
|
|
68
|
+
longer call `reify` on each root version. They are read from stored
|
|
69
|
+
attributes like other associations. In the reconstruction probe, `report`
|
|
70
|
+
over a through went from 7 `reify` calls per root to none and builds about
|
|
71
|
+
half as many model instances. Results are unchanged. A type that names no
|
|
72
|
+
class, an unversioned target, and a scope that cannot be read as conditions
|
|
73
|
+
still take the record path.
|
|
74
|
+
- Batch methods read an association scope, or a target's default scope, made
|
|
75
|
+
of plain conditions on the target's columns (equality, `IN`, `nil`, their
|
|
76
|
+
`where.not` forms, combined with `and` and `or`) from prepared history, so
|
|
77
|
+
they no longer need `allow_expensive: true` for it and its query count no
|
|
78
|
+
longer grows with the records. String conditions are read this way only on
|
|
79
|
+
SQLite and PostgreSQL. Scopes that take the owner and other conditions are
|
|
80
|
+
still rejected without the opt-in. A default-scoped model whose scope assigns
|
|
81
|
+
no attributes is read without `reify`.
|
|
82
|
+
- Batch methods also read a `limit` or `offset`, and the order of a `has_one`,
|
|
83
|
+
from prepared history when the order is total, reaching the primary key, and
|
|
84
|
+
sorts only by integer, boolean, or integer enum columns of the target. NULL
|
|
85
|
+
sorts as each adapter puts it by default. In the reconstruction probe,
|
|
86
|
+
`report` over `order(:id).limit(1)` went from 252 queries and 160 `reify`
|
|
87
|
+
calls at 20 roots, with the opt-in, to 32 queries and none without it. A
|
|
88
|
+
string order, an order with ties, and a `has_one :through` over a ranked
|
|
89
|
+
part are still rejected without the opt-in.
|
|
90
|
+
- Batch methods also rank by date columns, and by datetime columns when the
|
|
91
|
+
column declares a precision that history keeps and times are stored in UTC,
|
|
92
|
+
such as `order(created_at: :desc).limit(5)` with YAML history. JSON history
|
|
93
|
+
keeps milliseconds by default, so with it only a `datetime(3)` column is
|
|
94
|
+
read. A time-of-day column, and a datetime without a precision, are still
|
|
95
|
+
rejected without the opt-in.
|
|
96
|
+
- Batch methods read a `has_many :through` over a part with a `limit`,
|
|
97
|
+
`offset`, or `has_one` order, and one with its own `limit` or `offset`, from
|
|
98
|
+
prepared history without the opt-in.
|
|
99
|
+
- The README recipe for deleted records also reads each record as it was just
|
|
100
|
+
before deletion, which a record created inside the window has no other
|
|
101
|
+
snapshot of.
|
|
102
|
+
- The documentation now says where a change saved without a version, such as
|
|
103
|
+
with `update_columns`, appears: in the step for that record's previous
|
|
104
|
+
recorded change, credited to its author, anywhere in a range. It used to say
|
|
105
|
+
only that the last step of a range ending at the current record shows them,
|
|
106
|
+
and that a step's diff is exactly what its event changed. Ending a range at a
|
|
107
|
+
version leaves out only the writes after it. Specs pin this behavior, which
|
|
108
|
+
is unchanged.
|
|
109
|
+
- The README has a recipe that turns a report into one row per changed field,
|
|
110
|
+
with the record, whether it changed, appeared, or disappeared, and the step's
|
|
111
|
+
author, event, and time.
|
|
112
|
+
- Batch methods no longer need `allow_expensive: true` for a
|
|
113
|
+
`has_many :through` whose source is a `has_many`, such as an author's
|
|
114
|
+
comments through articles. It is read from prepared history like the other
|
|
115
|
+
associations, so its queries no longer grow with the records: the five-record
|
|
116
|
+
probe in `docs/performance.md` went from 200 queries to 20.
|
|
117
|
+
|
|
118
|
+
### Fixed
|
|
119
|
+
|
|
120
|
+
- A `limit` or `offset` scope, or an ordered `has_one`, read at a version
|
|
121
|
+
returns the records its query returned then, when the order is total and
|
|
122
|
+
sorts by columns batch methods can rank. It used to start from
|
|
123
|
+
the association's current rows, so a range whose records later changed
|
|
124
|
+
reported the wrong ones: with comments `first` and `second` under
|
|
125
|
+
`order(:id).limit(1)`, destroying `first` and adding `third` reported
|
|
126
|
+
`third` added instead of `second`. Other ranked scopes still read the
|
|
127
|
+
current rows.
|
|
128
|
+
- A `has_many :through` reads its parts as its own query does, keeping their
|
|
129
|
+
conditions but not their `limit`, `offset`, or `has_one` cut. History used
|
|
130
|
+
to apply them, and read with the opt-in it could raise PT-AT's
|
|
131
|
+
`FoundMoreThanOne`; batched history kept only one record through a `has_one`
|
|
132
|
+
part. A through's own `limit` or `offset` counts a record reached through
|
|
133
|
+
two join records twice, as the query does.
|
|
134
|
+
- Batch comparisons with a current endpoint load a `has_many :through` over a
|
|
135
|
+
part with a `limit`, `offset`, or `has_one` per record. Active Record's
|
|
136
|
+
preloader applied the part's limit across every record in the batch, so a
|
|
137
|
+
record could show another's members or none.
|
|
138
|
+
- A record reached through two join records is one member of a current
|
|
139
|
+
association, as it already was of a historical one, so a through loaded per
|
|
140
|
+
record no longer raises a duplicate identity error.
|
|
141
|
+
- In a batch, a `has_one` with an `offset` is loaded per record at the current
|
|
142
|
+
end. The preloader applied the offset across all records at once, so a
|
|
143
|
+
record could show another record's member.
|
|
144
|
+
- Activity reports a change to a `belongs_to` target that is an STI subclass.
|
|
145
|
+
When the target was destroyed, or its `type` changed between a subclass and
|
|
146
|
+
another class, activity reported nothing while checkpoint analysis reported
|
|
147
|
+
it, because a target's version names only the base class. This includes the
|
|
148
|
+
same target further down a path, such as `"note.badge"`.
|
|
149
|
+
- An association that only an STI subclass declares can be requested on a
|
|
150
|
+
root of that subclass in every method. Without an `item_subtype` column on
|
|
151
|
+
versions, each version resolved to the base class, so every method raised
|
|
152
|
+
`UnknownAssociationError`. The subclass is now read from the type the
|
|
153
|
+
version stored, or for a `create`, from its recorded changes. A stored type
|
|
154
|
+
that names no class raises `InvalidEndpointError`.
|
|
155
|
+
- A `has_many :through` with `source_type:` holds only the records reached
|
|
156
|
+
through join records of that type, in every method. History used to ignore
|
|
157
|
+
the option, so a join record pointing at another type made its target a
|
|
158
|
+
member: a holder whose links named a tag reported the tag among its authors,
|
|
159
|
+
and the tag's changes opened activity steps and selected the holder in
|
|
160
|
+
`report`. The current record was read correctly, so a comparison with it
|
|
161
|
+
reported the tag as removed. A join record whose type changes now adds or
|
|
162
|
+
removes its target in the step of that version.
|
|
163
|
+
- A scoped or default-scoped association read from history now holds the
|
|
164
|
+
records that met the scope at each point. It used to ignore the scope's
|
|
165
|
+
conditions for history: a record that left the scope was reported as
|
|
166
|
+
changed instead of removed, one that entered it as changed instead of
|
|
167
|
+
added, and a record outside the scope could be reported as removed when it
|
|
168
|
+
was destroyed or moved. Activity reports entering or leaving the scope as
|
|
169
|
+
an addition or removal at the version that did it.
|
|
170
|
+
- `compare` and `compare_many` between two versions report a `has_many` child
|
|
171
|
+
that moved to another owner afterwards. They reported no change: the
|
|
172
|
+
comparison read associations through PT-AT, which finds children by the keys
|
|
173
|
+
their versions recorded after each change, so the child was not found at the
|
|
174
|
+
earlier version. Version pairs now read the same prepared history as a
|
|
175
|
+
comparison with the current record, including for a record that was deleted.
|
|
176
|
+
- Historical analysis through a polymorphic `belongs_to` works in every method.
|
|
177
|
+
Only comparisons between two versions used to work: timelines, analyses,
|
|
178
|
+
reports, and comparisons with the current record raised `ArgumentError`.
|
|
179
|
+
Batches load the targets with one query per target class. A target class
|
|
180
|
+
without `has_paper_trail` raises `UnversionedAssociationError` when a record
|
|
181
|
+
names it, rather than a `NoMethodError` from PT-AT, and a default-scoped
|
|
182
|
+
target class is read as its association returns it. A type that names no
|
|
183
|
+
class, such as one a renamed model left in its versions, raises
|
|
184
|
+
`IncompleteAssociationHistoryError` naming the record and the type, rather
|
|
185
|
+
than a `NameError` that names neither.
|
|
186
|
+
- Activity leaves out the `belongs_to` target a record moves to in the
|
|
187
|
+
version that closes the window. Its changes inside the window used to add
|
|
188
|
+
empty steps, attributed to a record the root never pointed at there.
|
|
189
|
+
- Activity reports a change to a `belongs_to` target, or to the source of a
|
|
190
|
+
`has_many :through`, when the record pointing at it did not change in the
|
|
191
|
+
window: an author renamed under an article nobody edited. It used to report
|
|
192
|
+
nothing. A record that changed its target in the window also reports
|
|
193
|
+
changes to the target it pointed at before.
|
|
194
|
+
- `include_deleted: true` with `activity: true` reports the association
|
|
195
|
+
activity of a deleted record that did not change itself in the window. Its
|
|
196
|
+
analysis used to be empty.
|
|
197
|
+
- History of an association to an STI subclass, such as
|
|
198
|
+
`has_many :pinned_badges, class_name: 'PinnedBadge'`, holds only that class
|
|
199
|
+
and its subclasses, as the live association does. It used to hold the
|
|
200
|
+
sibling classes' records too, so a range ending at the current record
|
|
201
|
+
reported them as removed. Activity used to report a sibling's changes under
|
|
202
|
+
the association too, and to open a step for each version of a sibling or of
|
|
203
|
+
its children.
|
|
204
|
+
- Activity reports a record whose STI type changed in the step of the version
|
|
205
|
+
that changed it, as a removal of the old type and an addition of the new,
|
|
206
|
+
and adds it to or removes it from an association to an STI subclass there.
|
|
207
|
+
It used to add the new type alongside the old one and leave the old one in
|
|
208
|
+
place until a later step, or to the end of a range ending at a version.
|
|
209
|
+
- `activity_timeline`, and `analyze` with `activity: true`, return no steps for
|
|
210
|
+
a range that ends at a version when `version_scope:` keeps none of its
|
|
211
|
+
versions, as a checkpoint timeline does. They used to raise `NoMethodError`.
|
|
212
|
+
- `analyze_scope` and `report` list `unreachable` in the same sorted order
|
|
213
|
+
whatever the type of `versions.item_id`. With a string `item_id` it used to
|
|
214
|
+
follow the database's order.
|
|
215
|
+
- On SQL Server, activity places a version saved within two milliseconds of a
|
|
216
|
+
window's start or end on the side it was saved. The window's bounds used to
|
|
217
|
+
be rounded to 1/300 second, so such a version could be read as outside the
|
|
218
|
+
window, or one just past its end as inside it.
|
|
219
|
+
- A `has_many :through` that goes through a `has_and_belongs_to_many`, as its
|
|
220
|
+
`through:`, its `source:`, or further down its chain, raises
|
|
221
|
+
`UnsupportedAssociationError` up front in every method, naming the path and
|
|
222
|
+
the HABTM. Comparing two versions used to raise `NoMethodError` from inside
|
|
223
|
+
PT-AT, and analyses `ActiveRecord::StatementInvalid`. A `has_many :through`
|
|
224
|
+
Active Record cannot follow at all, such as one through a polymorphic
|
|
225
|
+
`belongs_to`, likewise raises Active Record's own error in every method,
|
|
226
|
+
where historical work used to fail partway with `ArgumentError` or
|
|
227
|
+
`NoMethodError`.
|
|
228
|
+
- `association_paths` leaves out a `has_many :through` that goes through a
|
|
229
|
+
`has_and_belongs_to_many`, so every path it lists can be requested. It
|
|
230
|
+
decides with the same check a request makes. It used to list such a path,
|
|
231
|
+
and a settings screen built from it offered one that failed when picked. A
|
|
232
|
+
model it reaches with a `has_many :through` Active Record cannot follow,
|
|
233
|
+
such as one through a polymorphic `belongs_to`, raises Active Record's own
|
|
234
|
+
error, where discovery used to raise `ArgumentError`, `NameError`, or
|
|
235
|
+
`NoMethodError` from inside the reflection, or list a path that raised when
|
|
236
|
+
requested.
|
|
237
|
+
- A `has_many :through` whose source is a `has_many` follows records that
|
|
238
|
+
move. A comment moved to another article leaves the first article's author
|
|
239
|
+
and joins the other's, and an article that moves to another author takes its
|
|
240
|
+
comments with it. Analyses, timelines, and comparisons with the current
|
|
241
|
+
record used to report no change for such a move, and could report a record
|
|
242
|
+
that left and came back as changed. They read these associations through
|
|
243
|
+
PT-AT, which finds a record by the keys its later versions recorded.
|
|
244
|
+
- A `has_many :through` whose `through:` is itself a `has_many :through`, such
|
|
245
|
+
as `has_many :comment_reviewers, through: :comments, source: :reviewer` with
|
|
246
|
+
`comments` through `articles`, reports its changes in analyses, timelines,
|
|
247
|
+
and batches. It used to read as empty at every point, so it never reported a
|
|
248
|
+
change.
|
|
249
|
+
|
|
250
|
+
### Development
|
|
251
|
+
|
|
252
|
+
- Mutation testing also covers `ScopeCondition`, which decides the records an
|
|
253
|
+
association scope admits. The reconstruction probe has a `scoped` scenario.
|
|
254
|
+
- The consistency specs also check that activity selection includes every
|
|
255
|
+
record whose analysis reports a change.
|
|
256
|
+
- Mutation testing also covers `TimeRange`, `VersionRange`,
|
|
257
|
+
`TimeVersionRange`, `BatchedRootVersions`, and `DeletedAnalysisBatch`, each
|
|
258
|
+
with a unit spec of its own; all 5,485 mutations across the fourteen classes
|
|
259
|
+
are caught.
|
|
260
|
+
- Mutation testing also covers `RecordedChanges`; all 257 of its mutations are
|
|
261
|
+
caught.
|
|
262
|
+
- Mutation testing also covers the association code: `ScopedRootSelection`,
|
|
263
|
+
`AssociationActivitySelection`, `AssociationActivityEdge`,
|
|
264
|
+
`ActivityTargetCandidateLoader`, `PreparedHistoryLoader`,
|
|
265
|
+
`PreparedEdgeLoader`, `ActivityTimelineBuilder`,
|
|
266
|
+
`ActivityTransactionGrouper`, and `BatchSafety`, each with a spec of its
|
|
267
|
+
own. All 10,732 mutations across the 24 classes are caught.
|
|
268
|
+
- Mutation testing also covers `AssociationTraversal`, which decides which
|
|
269
|
+
association paths a request accepts, with a spec of its own; all 764 of its
|
|
270
|
+
mutations are caught.
|
|
271
|
+
- Mutation testing also covers range and boundary handling:
|
|
272
|
+
`ActivityRange`, `VersionAssociationCandidateScope`, and
|
|
273
|
+
`PreparedActivityIndex`, each with a spec of its own; all 1,507 of their
|
|
274
|
+
mutations are caught.
|
|
275
|
+
- Mutation testing also covers `HabtmTransactionOwners`, which decides whether
|
|
276
|
+
a transaction's HABTM members can be read as one record's, with a spec of
|
|
277
|
+
its own.
|
|
278
|
+
- `benchmark/investigation/reconstruction.rb` runs again: the association
|
|
279
|
+
fixtures load the monotonic test clock they use. Its `prepared-through`
|
|
280
|
+
experiment and `benchmark/investigation/prepared_through_override.rb` are
|
|
281
|
+
removed, since the gem now reads those throughs that way.
|
|
282
|
+
- `spec/association/through_membership_spec.rb` writes out the expected
|
|
283
|
+
membership of a `has_many :through` with a `has_many` source over moves,
|
|
284
|
+
deletions, transactions, and changes saved without a version, alone and in
|
|
285
|
+
a batch.
|
|
286
|
+
- `ActivityEventRouteFinder` no longer checks for a polymorphic `belongs_to`
|
|
287
|
+
before following a path below it. Validation already rejects such a path,
|
|
288
|
+
so the check could not be reached.
|
|
289
|
+
- The `PreparedHistoryLoader` spec checks that an edge into a versioned model
|
|
290
|
+
with records stays prepared. A full mutation run of the 29 classes now
|
|
291
|
+
catches all 13,284 mutations.
|
|
292
|
+
|
|
6
293
|
## [0.13.1] - 2026-10-28
|
|
7
294
|
|
|
8
295
|
### Fixed
|
data/QUICKSTART.md
CHANGED
|
@@ -203,9 +203,10 @@ report.analyses.each_value { |analysis| analysis.diff.to_h }
|
|
|
203
203
|
are selected, the call raises instead of returning a partial report. Omit
|
|
204
204
|
`include_deleted:` if you only need articles that still exist.
|
|
205
205
|
|
|
206
|
-
An article
|
|
207
|
-
|
|
208
|
-
conditions check current values; they cannot check a deleted
|
|
206
|
+
An article is selected when it has a PaperTrail version in the time window. With
|
|
207
|
+
`associations: [:comments]`, editing a comment alone also selects its article. A
|
|
208
|
+
relation's `where` conditions check current values; they cannot check a deleted
|
|
209
|
+
article's values.
|
|
209
210
|
|
|
210
211
|
For one article, use a window ending now:
|
|
211
212
|
|
|
@@ -374,9 +375,11 @@ window to select that article.
|
|
|
374
375
|
change. Pass `article` to read the current saved values.
|
|
375
376
|
- PaperTrail's creation version returns `nil` from `reify`. There was no record
|
|
376
377
|
before creation; the diff reports the record being added.
|
|
377
|
-
-
|
|
378
|
-
|
|
379
|
-
|
|
378
|
+
- Changes PaperTrail never recorded, such as `update_columns`, still show up:
|
|
379
|
+
in the step for the article's previous recorded change, credited to its
|
|
380
|
+
author. Passing a version as `to:` leaves out only the ones made after it.
|
|
381
|
+
`PaperTrailDiff.recorded_changes(version)` reads just what a version
|
|
382
|
+
recorded.
|
|
380
383
|
- `ignore:` replaces the default list. Include `updated_at` yourself when using
|
|
381
384
|
a custom list and you still want it ignored.
|
|
382
385
|
- Historical associations require PT-AT to be installed, loaded, migrated, and
|
data/README.md
CHANGED
|
@@ -121,6 +121,10 @@ responsible for that change, including its `whodunnit`, `event`, `item_type`,
|
|
|
121
121
|
and `item_id`. Its `recorded_at` is the version's `created_at`.
|
|
122
122
|
Use this source information for attribution: the version used to read the
|
|
123
123
|
resulting state, exposed as `to_boundary`, may have been saved by someone else.
|
|
124
|
+
A change saved without a version, such as with `update_columns`, appears in the
|
|
125
|
+
step for that record's previous recorded change. To read only what a version
|
|
126
|
+
recorded, use `PaperTrailDiff.recorded_changes(version)`; see
|
|
127
|
+
[what a version recorded](docs/reference.md#what-a-version-recorded).
|
|
124
128
|
|
|
125
129
|
`group: :transaction` combines changes from one database transaction. Its source
|
|
126
130
|
information comes from the first event in that group. Omit `group:` if you need
|
|
@@ -172,16 +176,24 @@ unavailable = report.deleted.select { |_identity, analysis| analysis.nil? }.keys
|
|
|
172
176
|
deleted_activity = report.deleted.transform_values do |analysis|
|
|
173
177
|
analysis&.activity_timeline
|
|
174
178
|
end
|
|
179
|
+
last_state = report.deleted.transform_values do |analysis|
|
|
180
|
+
analysis&.activity_timeline&.last&.diff&.record_presence_change&.from
|
|
181
|
+
end
|
|
175
182
|
```
|
|
176
183
|
|
|
177
184
|
The results are already computed; you do not need to query or reify each deleted
|
|
178
|
-
record.
|
|
185
|
+
record. `last_state` is each record just before its deletion, which the final
|
|
186
|
+
step removes. Use it to show what a deleted record was, such as its title:
|
|
187
|
+
`analysis.from_snapshot` is the record when the window began, so it is `nil` for
|
|
188
|
+
one created inside the window. Omit `include_deleted:` if you only need records
|
|
189
|
+
that still exist.
|
|
179
190
|
|
|
180
191
|
Three selection rules matter:
|
|
181
192
|
|
|
182
|
-
-
|
|
183
|
-
|
|
184
|
-
|
|
193
|
+
- A record is selected when it, or a record on one of the `associations:` you
|
|
194
|
+
request, has a PaperTrail version in the time window. With
|
|
195
|
+
`associations: [:comments]`, editing only a comment selects its article, and
|
|
196
|
+
the article's report shows the edit.
|
|
185
197
|
- A relation such as `Article.where(status: "open")` checks today's values.
|
|
186
198
|
It cannot establish whether deleted articles matched that condition. Deleted
|
|
187
199
|
records are therefore listed separately.
|
|
@@ -197,7 +209,64 @@ for pagination, deleted records, and selecting by past values with
|
|
|
197
209
|
`historical_filter:`.
|
|
198
210
|
|
|
199
211
|
For a fixed past window, use `analyze_scope(Article.all, within: start_time...end_time,
|
|
200
|
-
limit: 100)`.
|
|
212
|
+
activity: true, limit: 100)`. Without `activity: true`, `analyze_scope` compares
|
|
213
|
+
each record's own versions, so it selects only records with a version of their
|
|
214
|
+
own.
|
|
215
|
+
|
|
216
|
+
## 4. Turn a report into rows
|
|
217
|
+
|
|
218
|
+
For a history table or an export, flatten a report into one row per changed
|
|
219
|
+
field. Each row names the record, the change, and who made it. This example
|
|
220
|
+
requires the association setup in the quickstart:
|
|
221
|
+
|
|
222
|
+
<!-- executable:recipe-summary -->
|
|
223
|
+
```ruby
|
|
224
|
+
report = PaperTrailDiff.report(
|
|
225
|
+
Article.all, since: 1.day.ago, limit: 100,
|
|
226
|
+
associations: [:comments], include_deleted: true
|
|
227
|
+
)
|
|
228
|
+
analyses = report.analyses.merge(report.deleted.compact)
|
|
229
|
+
|
|
230
|
+
rows = analyses.flat_map do |root, analysis|
|
|
231
|
+
analysis.activity_timeline.flat_map do |step|
|
|
232
|
+
source = step.source_boundary
|
|
233
|
+
step.diff.each_entry.filter_map do |entry|
|
|
234
|
+
next unless entry.attribute # skip entries for whole records and associations
|
|
235
|
+
|
|
236
|
+
from, to, change =
|
|
237
|
+
if entry.change?
|
|
238
|
+
[entry.value.from, entry.value.to, :changed]
|
|
239
|
+
elsif entry.state == :after
|
|
240
|
+
[nil, entry.value, :added]
|
|
241
|
+
else
|
|
242
|
+
[entry.value, nil, :removed]
|
|
243
|
+
end
|
|
244
|
+
next if from.nil? && to.nil? # a blank field of an added or removed record
|
|
245
|
+
|
|
246
|
+
record = entry.record # nil for the article itself
|
|
247
|
+
|
|
248
|
+
{
|
|
249
|
+
root: root,
|
|
250
|
+
association: entry.association_path.join("."), # "" for the article itself
|
|
251
|
+
record: record ? [record.type, record.id.to_s] : root,
|
|
252
|
+
change: change, attribute: entry.attribute, from: from, to: to,
|
|
253
|
+
actor: source.whodunnit, event: source.event, recorded_at: source.recorded_at
|
|
254
|
+
}
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
`change` is `:changed` for an edited field. It is `:added` or `:removed` for
|
|
261
|
+
each field of a record that appeared or disappeared: the article was created
|
|
262
|
+
or deleted, or a comment joined or left it. A comment moved from another
|
|
263
|
+
article is also `:added`; its `event` is then `"update"` rather than
|
|
264
|
+
`"create"`. `actor`, `event`, and `recorded_at` describe the step's source
|
|
265
|
+
version. With `group: :transaction`, that is the transaction's first event.
|
|
266
|
+
|
|
267
|
+
Rows follow the report's record order, with deleted records last, then each
|
|
268
|
+
record's steps in time order. Labels, grouping, and formatting are left to your
|
|
269
|
+
application.
|
|
201
270
|
|
|
202
271
|
## Choose a method
|
|
203
272
|
|
|
@@ -225,10 +294,16 @@ method inside a loop. They share history loading across records. Ordinary
|
|
|
225
294
|
record and direct-association histories can be read without creating a model
|
|
226
295
|
instance for every historical state.
|
|
227
296
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
`
|
|
231
|
-
|
|
297
|
+
Scopes and default scopes made of plain conditions, such as
|
|
298
|
+
`-> { where(archived: false) }`, are batched too, and so is a `limit` or
|
|
299
|
+
`offset` ordered by integer, boolean, date, or datetime columns down to the
|
|
300
|
+
primary key, such as `-> { order(:position, :id).limit(3) }`, on a direct
|
|
301
|
+
association or a `has_many :through`. Some associations still need per-record
|
|
302
|
+
queries, such as a `limit` ordered by a string or scopes that take
|
|
303
|
+
the owner.
|
|
304
|
+
Batch methods reject those by default with `UnsupportedBatchPathError`. The
|
|
305
|
+
error names the association. Remove it from `associations:`, or pass
|
|
306
|
+
`allow_expensive: true` if you accept that cost.
|
|
232
307
|
Callbacks and callable historical filters can also issue queries. See the
|
|
233
308
|
[performance guide](docs/performance.md) for measured behavior and limitations.
|
|
234
309
|
|
data/docs/architecture.md
CHANGED
|
@@ -36,9 +36,26 @@ only the association paths you request:
|
|
|
36
36
|
|
|
37
37
|
From that, the gem builds each state without querying again. Plain recorded
|
|
38
38
|
attributes are read straight from the stored version, without creating a model
|
|
39
|
-
instance
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
instance, including the members of a `has_many :through` and the target of a
|
|
40
|
+
polymorphic `belongs_to`. Models with initialization callbacks, custom
|
|
41
|
+
attribute types or defaults, default scopes that assign attributes, or
|
|
42
|
+
encrypted attributes are rebuilt with PaperTrail's `reify` instead, so their
|
|
43
|
+
behavior is kept.
|
|
44
|
+
|
|
45
|
+
An association scope or a target's default scope is read once into a
|
|
46
|
+
`ScopeCondition`: the `where` conditions of the relation it builds, kept only
|
|
47
|
+
when each is an equality, `IN`, or `nil` test, or a negation of one, on the
|
|
48
|
+
target's own column of a type Ruby compares as the database does. A record
|
|
49
|
+
belongs to the association at a point when its key matches and its state
|
|
50
|
+
there meets the condition. A `limit`, `offset`, or `has_one` order is read
|
|
51
|
+
into a `ScopeRanking` when its order is total and every key compares in Ruby
|
|
52
|
+
as the database sorts it, including where each adapter puts NULL; the records
|
|
53
|
+
that meet the condition are then sorted, offset, and limited as the
|
|
54
|
+
association's query would. A `has_many :through` reads its parts' conditions
|
|
55
|
+
only, at any depth, as its query joins them, and ranks by its own order the
|
|
56
|
+
rows the join yields, a record reached twice counting twice. Activity reads a scoped branch again at each event
|
|
57
|
+
on it rather than patching the previous snapshot, since any edit can move a
|
|
58
|
+
record into or out of the scope, and with a ranking, move another record too.
|
|
42
59
|
|
|
43
60
|
Each rebuilt state is an immutable snapshot. Activity timelines carry the
|
|
44
61
|
previous snapshot forward and replace only the parts an event changed, so an
|
|
@@ -51,17 +68,23 @@ These association shapes cannot be read from the loaded history. The gem asks
|
|
|
51
68
|
PT-AT to rebuild them at each point instead, which costs queries per record and
|
|
52
69
|
resolves association membership by timestamp only:
|
|
53
70
|
|
|
54
|
-
- associations with a scope
|
|
55
|
-
-
|
|
71
|
+
- associations with a scope the gem cannot read as plain conditions and an
|
|
72
|
+
exact ranking, including owner-dependent scopes and a `limit` without a
|
|
73
|
+
total integer order;
|
|
74
|
+
- targets with such a default scope;
|
|
56
75
|
- targets that are not versioned;
|
|
57
|
-
- composite foreign keys
|
|
58
|
-
- `has_many :through` associations whose source is not a `belongs_to`.
|
|
76
|
+
- composite foreign keys.
|
|
59
77
|
|
|
60
|
-
Batch methods reject the scoped
|
|
78
|
+
Batch methods reject the scoped cases unless you pass
|
|
61
79
|
`allow_expensive: true`; see [costly associations](reference.md#costly-associations).
|
|
62
80
|
|
|
63
81
|
HABTM membership comes from the snapshots PT-AT records with each transaction,
|
|
64
|
-
so it is only known at root versions saved in a transaction.
|
|
82
|
+
so it is only known at root versions saved in a transaction. Those snapshots
|
|
83
|
+
name the transaction and the association but not the owner, so they give only
|
|
84
|
+
the root's own membership, and a HABTM below the root is rejected before any
|
|
85
|
+
history is read. Even at the root, a transaction in which another record with a
|
|
86
|
+
HABTM of the same name saved a version holds both records' members;
|
|
87
|
+
`HabtmTransactionOwners` finds those transactions, and reading one raises.
|
|
65
88
|
|
|
66
89
|
## Database indexes
|
|
67
90
|
|