paper_trail_diff 0.14.0 → 0.15.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 +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4cc17a3d2f233d6f9bcc44605687ef4cabf1611f957ad41d0471b35236b8ab8d
|
|
4
|
+
data.tar.gz: e123b779da0c52350cb05b4ed18f8585626d8cbcf45e3c737cfe43777f7bcf14
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 65f32e4a83fd05ba8cfece667be5842d41f9452ae84f49f495ff8ad0051b87c76ebddfb07c1a99c09227689eaa393286ca0737042bf6b6f7c6dffea0b77039bf
|
|
7
|
+
data.tar.gz: 217119550346fc0eed08759dd20aa1a2fb53bd71dde161e43ef1243f191159fc7c1225924a6c39059dda8e2df4c4b17f0f1abed694938a2824a89e722e88e0d7
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,349 @@
|
|
|
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.15.0] - 2026-10-31
|
|
7
|
+
|
|
8
|
+
### Breaking changes
|
|
9
|
+
|
|
10
|
+
- An association whose model keeps its versions, or its PT-AT version
|
|
11
|
+
associations, in different tables than its owner's raises
|
|
12
|
+
`UnsupportedAssociationError` in every method, and `association_paths`
|
|
13
|
+
leaves it out. History read through it was wrong: PT-AT records a child's
|
|
14
|
+
membership with the child's versions but reads it from the owner's tables,
|
|
15
|
+
so a child that moved to another owner was never reported as removed, and a
|
|
16
|
+
root with its own version table raised `ActiveRecord::StatementInvalid`.
|
|
17
|
+
|
|
18
|
+
- An association keyed on a column other than a primary key
|
|
19
|
+
(`primary_key: :legacy_id`), or a `has_many :through` with such a part,
|
|
20
|
+
raises `UnsupportedAssociationError` up front in every method, and
|
|
21
|
+
`association_paths` leaves it out. PT-AT records which record a child
|
|
22
|
+
pointed at by that record's ID, and history matched children by ID, so an
|
|
23
|
+
article's history showed another article's comments, and a comment's past
|
|
24
|
+
article was the wrong one. Comparisons against the current record were
|
|
25
|
+
right, so the same range disagreed with itself.
|
|
26
|
+
- An association path history cannot read, such as one with a scope written
|
|
27
|
+
as SQL text, a scope that takes the owner, or a condition on a string
|
|
28
|
+
column on MySQL or SQL Server, raises `UnsupportedAssociationError` in every
|
|
29
|
+
method that reads history, unless you pass the new `allow_approximate: true`.
|
|
30
|
+
The gem reads such a path with PT-AT, which rebuilds an association from its
|
|
31
|
+
current members and never checks the scope against them: a record that
|
|
32
|
+
entered the scope since was reported as a member it was not, so a comment
|
|
33
|
+
moved into `-> { where("position > 2") }` showed as changed rather than
|
|
34
|
+
added. Single-record methods did this without warning, and batch methods
|
|
35
|
+
described it only as costly.
|
|
36
|
+
- `include_deleted: true` keeps only the deleted records that met the
|
|
37
|
+
relation's conditions when they were destroyed, in both `deleted` and
|
|
38
|
+
`unreachable`. It returned every deleted record of the model that changed in
|
|
39
|
+
the window, so a report on one account's articles included other accounts'
|
|
40
|
+
deleted articles with their contents. The relation's conditions must be
|
|
41
|
+
plain conditions on the model's own columns; a join or SQL text raises
|
|
42
|
+
`ConfigurationError`.
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- `diagnose` reports every reason `compare` would refuse the same request as
|
|
47
|
+
an error, with `compare`'s message, so `ok?` is false whenever `compare`
|
|
48
|
+
would refuse. It said `ok?` for a path `compare` reads only approximately,
|
|
49
|
+
such as a scope written as SQL text, raised on an unknown or unsupported
|
|
50
|
+
association instead of reporting it, and checked only the join model a
|
|
51
|
+
`has_many :through` goes through first, not every model it reads. It
|
|
52
|
+
takes `allow_approximate:`, and with `true` reports such a path as an
|
|
53
|
+
`:approximate_association` warning. The new error codes are
|
|
54
|
+
`:unknown_association`, `:unsupported_association`, and
|
|
55
|
+
`:approximate_association`; an unversioned target's message now matches
|
|
56
|
+
`compare`'s.
|
|
57
|
+
|
|
58
|
+
- `diagnose` takes the current record as either endpoint, as `compare` does,
|
|
59
|
+
so `compare(version, record)` can be checked before it runs. Its version
|
|
60
|
+
checks then run from the version to the latest one. An endpoint that is
|
|
61
|
+
neither a version nor a saved record raises `InvalidEndpointError` naming
|
|
62
|
+
that, rather than `VersionMismatchError`.
|
|
63
|
+
|
|
64
|
+
- Activity steps list the events whose changes they show as `step.sources`,
|
|
65
|
+
oldest first. A step from `group: :transaction` lists every event in the
|
|
66
|
+
transaction, each with its own author, record, and version ID, where
|
|
67
|
+
`source_boundary` names only the first. `to_h(metadata: true)` adds a
|
|
68
|
+
`sources` list.
|
|
69
|
+
- `recorded_changes: true` on `activity_timeline`, on `analyze`, `analyze_many`,
|
|
70
|
+
and `analyze_scope` with `activity: true`, and on `report` gives each
|
|
71
|
+
boundary the changes its version recorded, as `recorded_changes(version)`
|
|
72
|
+
returns them. The versions are already loaded, so it adds no queries, and it
|
|
73
|
+
replaces the reference recipe that loaded every step's version again.
|
|
74
|
+
Without the option, `boundary.recorded_changes` raises `ConfigurationError`
|
|
75
|
+
rather than returning `nil`. Names in `ignore:` or `ignore: { all: }` are
|
|
76
|
+
left out, and so are names on the root path, `"$"`, for the root's own
|
|
77
|
+
versions. Names on an association path are not, because a child's version
|
|
78
|
+
does not say which path reached it.
|
|
79
|
+
- `change_rows` on an activity step, on an `Analysis` built with
|
|
80
|
+
`activity: true`, and on a `report` or `analyze_scope` result flattens
|
|
81
|
+
activity into `ChangeRow` objects: one per edited field, and one per record
|
|
82
|
+
that appeared or disappeared followed by its fields. Each names its record,
|
|
83
|
+
association path, and source version, and `to_h` gives a flat hash. It
|
|
84
|
+
replaces the README recipe for turning a report into rows, which had no rows
|
|
85
|
+
for whole records, so a record added or removed with only blank fields left
|
|
86
|
+
no trace.
|
|
87
|
+
|
|
88
|
+
- `associations:` takes what `includes` takes: a single name
|
|
89
|
+
(`associations: :comments`) or hashes of names to what to include below them
|
|
90
|
+
(`associations: [:author, { comments: :replies }]`), as well as the arrays of
|
|
91
|
+
names and dotted paths it took before.
|
|
92
|
+
|
|
93
|
+
- `nested_changes` compares two top-level arrays, as a `json` column can hold,
|
|
94
|
+
and reports one `ArrayChange` at the empty path, `[]`. It returned `{}`,
|
|
95
|
+
although arrays nested in an object were compared.
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- The reference describes how PT-AT records HABTM membership and how the gem
|
|
100
|
+
credits it. A change made between two root versions is credited to the
|
|
101
|
+
step after the earlier one, so a checkpoint's capture is credited to whoever
|
|
102
|
+
saved the version before it; a checkpoint just before the change as well
|
|
103
|
+
credits it to its author. Only the first version an instance saves after
|
|
104
|
+
changing a HABTM is right; PT-AT never forgets the instance's changes, not
|
|
105
|
+
even on `reload`. A later save from it records the old membership, and the
|
|
106
|
+
gem reports members removed and added again that never left.
|
|
107
|
+
|
|
108
|
+
- The performance guide describes current behavior instead of logging each
|
|
109
|
+
past change: what the batch methods cost, what still grows with a report,
|
|
110
|
+
the version indexes, what costs a query per record, historical filters,
|
|
111
|
+
large reports, and how to measure. Its measurements were taken again. The
|
|
112
|
+
earlier ones remain in `benchmark/results/` and in the changelog.
|
|
113
|
+
|
|
114
|
+
- `historical_filter:` reads the history of the relation's records only, and
|
|
115
|
+
of deleted records, which a relation cannot test. It read every record of
|
|
116
|
+
the model that changed in the window, so a report on one account's articles
|
|
117
|
+
raised `BatchLimitExceededError` when another account had more matching
|
|
118
|
+
articles than `limit:`, and spent `max_filter_versions:` on their versions.
|
|
119
|
+
Matching deleted records no longer count toward `limit:` unless
|
|
120
|
+
`include_deleted: true` is given, as without a filter.
|
|
121
|
+
|
|
122
|
+
- The reference says how a record created without a version, such as one
|
|
123
|
+
imported with `insert_all`, and history pruned with `has_paper_trail
|
|
124
|
+
limit:` read.
|
|
125
|
+
|
|
126
|
+
- `IncompleteTimeRangeError` no longer advises the deprecated
|
|
127
|
+
`close_on: :current`. The quickstart and performance guide no longer say
|
|
128
|
+
that `has_many :through` or limited associations need the opt-ins, and the
|
|
129
|
+
reference says that `association_paths` lists paths that need
|
|
130
|
+
`allow_approximate: true`.
|
|
131
|
+
|
|
132
|
+
- The quickstart sets PaperTrail's `yaml_column_permitted_classes`. In a new
|
|
133
|
+
Rails 8.1 app, versions hold timestamps as `ActiveSupport::TimeWithZone`,
|
|
134
|
+
and the quickstart's first `reify` raised `Psych::DisallowedClass` without
|
|
135
|
+
it. Its documentation spec now reads a version in that form.
|
|
136
|
+
|
|
137
|
+
- The README and architecture guide recommend a second version index,
|
|
138
|
+
`(item_type, created_at, item_id)`. Batch methods with `since:` or
|
|
139
|
+
`within:` find changed records by type and time, which the one index they
|
|
140
|
+
recommended cannot narrow, so each report read every version of the model:
|
|
141
|
+
about 230 ms instead of 2 ms on PostgreSQL with 5 million versions.
|
|
142
|
+
- The gemspec requires Active Record 7.1 to 8.1. CI tests 7.2 through 8.1,
|
|
143
|
+
and the suite passes on 7.1. It named only PaperTrail, so Bundler could
|
|
144
|
+
install the gem with Active Record 6.1 or 7.0, whose relations and Arel
|
|
145
|
+
nodes the gem reads differently.
|
|
146
|
+
- `close_on:` is deprecated and warns through the new
|
|
147
|
+
`PaperTrailDiff.deprecator`. It has had no effect since a window began
|
|
148
|
+
closing on the current record by default; remove it. In a Rails app,
|
|
149
|
+
register the deprecator with
|
|
150
|
+
`Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator`.
|
|
151
|
+
- `allow_expensive:` is deprecated and has no effect. It refused exactly the
|
|
152
|
+
paths `allow_approximate:` refuses, so batch methods needed both or
|
|
153
|
+
neither; `allow_approximate: true` alone now accepts such a path in every
|
|
154
|
+
method, and `UnsupportedBatchPathError` is no longer raised. Batch methods
|
|
155
|
+
refuse such a path before their first query, as before, now with
|
|
156
|
+
`UnsupportedAssociationError`.
|
|
157
|
+
- `reload_live_endpoints:` is deprecated and warns. It still works until it
|
|
158
|
+
is removed. Skipping the reload saves a query for the records and one per
|
|
159
|
+
association level in each call, 2 for 500 articles with their comments,
|
|
160
|
+
and reads values from objects that may be stale; it also needs every
|
|
161
|
+
requested association loaded, and reads limited ones again anyway. It was
|
|
162
|
+
on `compare`, `compare_many`, and `activity_timeline` only. Remove it.
|
|
163
|
+
|
|
164
|
+
### Fixed
|
|
165
|
+
|
|
166
|
+
- A HABTM member created in the same transaction as the checkpoint that
|
|
167
|
+
records it is a member at that checkpoint, as a member attached there
|
|
168
|
+
already was. It was left out, because it had no state before the
|
|
169
|
+
transaction, so the checkpoint missed it and a comparison from the
|
|
170
|
+
checkpoint to now reported it added.
|
|
171
|
+
|
|
172
|
+
- `association_paths` leaves out an association whose target, or a model its
|
|
173
|
+
`has_many :through` goes through, has no `has_paper_trail`, such as the
|
|
174
|
+
`file_attachment` and `file_blob` that `has_one_attached` defines. Every
|
|
175
|
+
request that reads history refused them with `UnversionedAssociationError`,
|
|
176
|
+
so a settings screen built from the list offered paths that always failed.
|
|
177
|
+
|
|
178
|
+
- `diagnose` reports `:tied_version_timestamps`, and `compare` emits
|
|
179
|
+
`ambiguous_association_boundary.paper_trail_diff`, only when the request can
|
|
180
|
+
read a path with PT-AT: one history reads only approximately, a composite
|
|
181
|
+
foreign key, or a polymorphic `belongs_to`. PT-AT places association changes
|
|
182
|
+
by timestamp alone. For every other path, since 0.14.0, versions that share a
|
|
183
|
+
timestamp are ordered by ID, so a change between them is reported, yet both
|
|
184
|
+
warned that it could not be detected. A request refused for a path PT-AT
|
|
185
|
+
would read now raises before the notification.
|
|
186
|
+
|
|
187
|
+
- `timeline`, `activity_timeline`, and `analyze` read every version of a
|
|
188
|
+
model whose versions association has a scope
|
|
189
|
+
(`has_paper_trail versions: { scope: }`), as the other methods already did.
|
|
190
|
+
They read only the versions the scope kept, so a change saved in a version
|
|
191
|
+
it left out was credited to the next one kept: with a bot's edits hidden,
|
|
192
|
+
an editor's step showed the bot's later edit too, and the same record and
|
|
193
|
+
window gave different steps in `analyze` and `analyze_many`. A version the
|
|
194
|
+
scope leaves out is also accepted as an endpoint. To report only some
|
|
195
|
+
changes, pass `version_scope:`.
|
|
196
|
+
|
|
197
|
+
- Activity steps leave out the versions a record saved while it was outside
|
|
198
|
+
the root's tree. A comment edited on another article, then moved to this
|
|
199
|
+
one, brought that edit into the article's timeline as a step, and a
|
|
200
|
+
transaction group's `sources` listed it. Saved together with a comment added
|
|
201
|
+
to the article, the edit took the credit for the addition. The same went for
|
|
202
|
+
a comment edited after it moved away or left a scope, and for an author
|
|
203
|
+
renamed after the article changed authors. A version now counts when its
|
|
204
|
+
record is in the tree before or after its transaction. The versions of a
|
|
205
|
+
`has_many :through`'s join rows are all still kept.
|
|
206
|
+
|
|
207
|
+
- `timeline`, `activity_timeline`, `analyze`, and `analyze_many` raise
|
|
208
|
+
`InvalidEndpointError` naming what they were given when it is not a record
|
|
209
|
+
of a versioned model, such as `nil`, a model class, a relation, or a
|
|
210
|
+
version. They raised a bare `NoMethodError`, or said to provide `from` and
|
|
211
|
+
`to`. `report` names `since` when it is `nil` or a range.
|
|
212
|
+
|
|
213
|
+
- A scope ordered by a datetime, such as
|
|
214
|
+
`-> { order(created_at: :desc, id: :desc).limit(3) }`, is read from history
|
|
215
|
+
under Rails' default time-zone-aware attributes. Rails wraps those
|
|
216
|
+
attributes' types, which hid the datetime, so every method raised
|
|
217
|
+
`UnsupportedAssociationError` for the documented recipe.
|
|
218
|
+
|
|
219
|
+
- A `Date` bound of `within:` or `since:` is a whole day in `Time.zone`, and
|
|
220
|
+
a string a time in `Time.zone`, or in UTC without one. They were read in
|
|
221
|
+
the server's own zone, so the same window covered different hours on
|
|
222
|
+
different servers, and a same-day `Date..Date` window covered one instant
|
|
223
|
+
and found nothing.
|
|
224
|
+
|
|
225
|
+
- A `has_many :through` whose join model is not versioned raises
|
|
226
|
+
`UnversionedAssociationError` naming the join model when its history is
|
|
227
|
+
read, and `diagnose` reports it as an error, so `ok?` is false. A
|
|
228
|
+
comparison raised a bare `KeyError` while `diagnose` reported only a
|
|
229
|
+
warning.
|
|
230
|
+
|
|
231
|
+
- `reload_live_endpoints: false` reads an association with a `limit` or
|
|
232
|
+
`offset`, directly or in a part it goes through, or a scope that takes the
|
|
233
|
+
owner, again for each record. Active Record's `preload` applies the limit across all the records
|
|
234
|
+
it loads, so with three articles preloaded, two kept one comment each and
|
|
235
|
+
the third none, and their unchanged comments were reported as removed.
|
|
236
|
+
|
|
237
|
+
- A root and its associations that share one version table other than
|
|
238
|
+
PaperTrail's `versions` can be compared. Association lookups joined PT-AT's
|
|
239
|
+
version associations to the `versions` table whatever table the versions
|
|
240
|
+
were in, so every method raised `ActiveRecord::StatementInvalid`.
|
|
241
|
+
|
|
242
|
+
- A `within:` or `since:` window whose edge falls inside a transaction that
|
|
243
|
+
saved a version of the root record reports the whole transaction in the
|
|
244
|
+
later window. A window starting between a comment edit and the `touch: true`
|
|
245
|
+
article version saved with it showed the edit at the article's next change,
|
|
246
|
+
credited to that change's author, and the window ending there left it out;
|
|
247
|
+
when the article saved first, its version was in one window and the comment
|
|
248
|
+
edit in the other. Each root's edges move back to the transaction's start,
|
|
249
|
+
read in a fixed number of queries per batch.
|
|
250
|
+
|
|
251
|
+
- `activity_timeline`, and `analyze` with `activity: true`, no longer report a
|
|
252
|
+
child version saved in the same transaction as the root version that ends
|
|
253
|
+
the range. A `touch: true` parent version is saved this way after every
|
|
254
|
+
child save. PT-AT rebuilds the parent's associations at that version as they
|
|
255
|
+
were before the transaction, so the child's step was always empty and its
|
|
256
|
+
change was reported nowhere. The range now ends before that transaction, as
|
|
257
|
+
`compare` and `timeline` already did. End at a later version or at the
|
|
258
|
+
current record to include the change, with `group: :transaction` to credit it
|
|
259
|
+
to the child's version.
|
|
260
|
+
- A range that starts at a version now starts before that version's
|
|
261
|
+
transaction, as one that ends there already ended. A child version saved
|
|
262
|
+
earlier in the same transaction, such as the update behind a `touch: true`
|
|
263
|
+
parent version, was read after its change, so `compare`, `analyze`,
|
|
264
|
+
`timeline`, and `activity_timeline` from that version left the change out.
|
|
265
|
+
It was also missing from the range ending there, so a history split at that
|
|
266
|
+
version reported it nowhere. It is now inside the range that starts there.
|
|
267
|
+
- A past `within:` window in `activity_timeline`, and in `analyze`,
|
|
268
|
+
`analyze_many`, `analyze_scope`, and `report` with `activity: true`, no
|
|
269
|
+
longer includes association changes made after it. When the root had no
|
|
270
|
+
version after the window, the window closed on current state, so a comment
|
|
271
|
+
edited in August appeared in a July window, in its last step, `to_snapshot`,
|
|
272
|
+
and `diff`; with a later root version the same window was right. It now
|
|
273
|
+
closes on current state only when nothing on a requested path was recorded
|
|
274
|
+
after it. The reasoning in 0.8.0, that nothing had been recorded since,
|
|
275
|
+
checked only the root's own versions. `timeline`, and `analyze` without
|
|
276
|
+
`activity:`, still run their last step to the root's next version or the
|
|
277
|
+
current record; the reference now says so.
|
|
278
|
+
- Association scopes read from history now cover `IS NULL` and `IS NOT NULL`
|
|
279
|
+
on a column of any type, such as a soft-delete scope's
|
|
280
|
+
`where(deleted_at: nil)`, and `NOT` with SQL's rules for NULL, such as
|
|
281
|
+
`where.not(position: [1, nil])`. These were read with PT-AT before, so a
|
|
282
|
+
record restored from a soft delete showed as changed rather than added, and
|
|
283
|
+
batch methods refused them without `allow_expensive: true`. A condition on a
|
|
284
|
+
PostgreSQL `citext` column, which compares without regard to case, is no
|
|
285
|
+
longer read as exact text.
|
|
286
|
+
- A `historical_filter:` hash casts its values, and the recorded ones, to each
|
|
287
|
+
attribute's type before comparing them, as `where` does. An enum given as a
|
|
288
|
+
symbol (`{ status: :published }`), and a date or time read from a `json` or
|
|
289
|
+
`jsonb` version column, which PaperTrail returns as a string, never matched,
|
|
290
|
+
so the report silently selected nothing.
|
|
291
|
+
- Versions from two version tables saved in the same instant raise
|
|
292
|
+
`AmbiguousVersionOrderError`, as the reference promises, instead of being
|
|
293
|
+
ordered by their ids. Each table counts its ids separately, so their order
|
|
294
|
+
said nothing about which change came first, and an activity timeline built
|
|
295
|
+
from them could credit a change to the wrong step.
|
|
296
|
+
- `compare_many`, `analyze_many`, `analyze_scope`, and `report` load a
|
|
297
|
+
`has_one` of every current record in one query again, as before 0.14.0.
|
|
298
|
+
0.14.0 meant to load only a `has_one` with a declared limit or offset per
|
|
299
|
+
record, but checked the association's scope, to which Active Record adds
|
|
300
|
+
`limit(1)` for every `has_one`, so every `has_one` cost one query per
|
|
301
|
+
record.
|
|
302
|
+
- `report`, `analyze_scope`, and `analyze_many` with `activity: true` no
|
|
303
|
+
longer read a root's current row once more for activity through a
|
|
304
|
+
`has_many :through` after its last version. The batch has already loaded
|
|
305
|
+
it, so the query count no longer grows with the number of roots.
|
|
306
|
+
- A snapshot's collections list their records by type, then ID, as the
|
|
307
|
+
reference says, wherever the snapshot was read from. Snapshots rebuilt from
|
|
308
|
+
history or carried through an activity timeline listed them in the order
|
|
309
|
+
they were found, and a ranked association in its query's order, so `to_h` of
|
|
310
|
+
the same state differed between methods. Diffs were already ordered.
|
|
311
|
+
- With a `json` or `jsonb` `object` column, an activity step no longer
|
|
312
|
+
reports an unchanged timestamp as changed. PaperTrail returns such a
|
|
313
|
+
column's times as strings, and a step after a child's update carried them
|
|
314
|
+
into the snapshot as they were, so the next step that read the child again
|
|
315
|
+
reported `created_at` changing from a string to the same time. Activity now
|
|
316
|
+
casts them as it does every other recorded state. The specs had covered the
|
|
317
|
+
JSON serializer on a text column only, which PaperTrail casts.
|
|
318
|
+
|
|
319
|
+
### Development
|
|
320
|
+
|
|
321
|
+
- A new oracle suite, `rake spec_oracle`, checks rebuilt history against the
|
|
322
|
+
truth rather than against other gem methods. It makes seeded random edits to
|
|
323
|
+
an article graph one transaction at a time, records the live state after
|
|
324
|
+
each, and compares every endpoint, timeline step, activity step, window, and
|
|
325
|
+
batch result with those states. It also checks that two windows meeting
|
|
326
|
+
between transactions or inside one report each step once, scoped membership
|
|
327
|
+
against Active Record, and a tree of one model whose children have
|
|
328
|
+
encrypted, serialized, enum, custom-type, decimal, and JSON attributes.
|
|
329
|
+
`rake spec` runs it, so CI runs it on every database. `ORACLE_SEEDS` and
|
|
330
|
+
`ORACLE_STEPS` widen a run. See `docs/testing.md`.
|
|
331
|
+
- `PTD_PROBE_ALLOW_APPROXIMATE=1` makes the reconstruction probe pass
|
|
332
|
+
`allow_approximate: true`, so its PT-AT-read scenario runs rather than
|
|
333
|
+
raising, and neither benchmark passes the deprecated `close_on:` any more.
|
|
334
|
+
- On SQL Server the association specs keep their versions 5 ms apart. Specs
|
|
335
|
+
that compare the gem with PT-AT failed at random there: PT-AT compares
|
|
336
|
+
version times through SQL text, which the SQL Server adapter writes as
|
|
337
|
+
`datetime` values up to 1/300 s off, and after the host clock stepped back
|
|
338
|
+
the test clock packed versions microseconds apart until it caught up.
|
|
339
|
+
- The reporting benchmark loads the monotonic test clock, so its results no
|
|
340
|
+
longer depend on the host clock. A clock that stepped backwards, as WSL2's
|
|
341
|
+
does, could date a root's destroy before its last update, and
|
|
342
|
+
`deleted_roots` then raised `IncompleteTimeRangeError`. The seeded history
|
|
343
|
+
and query counts are unchanged. Allocations rise by under 1% in
|
|
344
|
+
`many_roots` and `retained_activity`, where the gem reads the clock once
|
|
345
|
+
per root. The investigation scripts that seed through the same workload
|
|
346
|
+
load the clock too; `scalar_cost.rb` freezes time in front of it, so its
|
|
347
|
+
parity check still sees one instant.
|
|
348
|
+
|
|
6
349
|
## [0.14.0] - 2026-10-29
|
|
7
350
|
|
|
8
351
|
### Breaking changes
|
data/QUICKSTART.md
CHANGED
|
@@ -37,6 +37,19 @@ table. `--with-changes` adds `object_changes`, which PaperTrail uses for
|
|
|
37
37
|
for each event. This gem can also compare the full earlier records saved in
|
|
38
38
|
PaperTrail's `object` column.
|
|
39
39
|
|
|
40
|
+
PaperTrail stores earlier records as YAML, and Rails reads back only the YAML
|
|
41
|
+
classes you allow. Without this, reading the first version raises
|
|
42
|
+
`Psych::DisallowedClass`. Add PaperTrail's list to `config/application.rb`,
|
|
43
|
+
inside `class Application`:
|
|
44
|
+
|
|
45
|
+
<!-- executable:quickstart-config -->
|
|
46
|
+
```ruby
|
|
47
|
+
config.active_record.yaml_column_permitted_classes = [
|
|
48
|
+
ActiveRecord::Type::Time::Value, ActiveSupport::TimeWithZone, ActiveSupport::TimeZone,
|
|
49
|
+
BigDecimal, Date, Symbol, Time
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
40
53
|
## 2. Version a model
|
|
41
54
|
|
|
42
55
|
Create an article model and tell PaperTrail to track its changes:
|
|
@@ -233,10 +246,11 @@ The same `within:` option works on `activity_timeline`, `analyze`,
|
|
|
233
246
|
| Recent activity through now, for the records a relation selects | `report(scope, since:, limit:)` |
|
|
234
247
|
|
|
235
248
|
Use the batch methods for many records so they can share database queries.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
249
|
+
A few scoped associations, such as one whose scope is SQL text, can be read
|
|
250
|
+
only with PT-AT, approximately and one record at a time. Every method rejects
|
|
251
|
+
them with `UnsupportedAssociationError`, naming the association; passing
|
|
252
|
+
`allow_approximate: true` accepts both costs. See
|
|
253
|
+
[costly associations](docs/reference.md#costly-associations).
|
|
240
254
|
|
|
241
255
|
When associations are included, `timeline` compares their values at the article's
|
|
242
256
|
versions. `activity_timeline` also reports the comments' own version events, even
|
|
@@ -388,6 +402,9 @@ window to select that article.
|
|
|
388
402
|
comments does not automatically include every association on each comment.
|
|
389
403
|
- Historical output cannot contain data that PaperTrail or PT-AT did not record
|
|
390
404
|
or can no longer reconstruct.
|
|
405
|
+
- A report with `since:` or `within:` gets slow as the version table grows
|
|
406
|
+
unless you add the
|
|
407
|
+
[two version indexes](docs/architecture.md#database-indexes).
|
|
391
408
|
|
|
392
409
|
For association support, performance, error handling, ignore rules, and all
|
|
393
410
|
available result methods, see the
|
data/README.md
CHANGED
|
@@ -122,13 +122,14 @@ 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
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
|
|
126
|
-
recorded,
|
|
127
|
-
|
|
125
|
+
step for that record's previous recorded change. To also read only what each
|
|
126
|
+
version recorded, pass `recorded_changes: true` and read
|
|
127
|
+
`step.source_boundary.recorded_changes`; see
|
|
128
|
+
[what each event recorded](docs/reference.md#what-each-event-recorded-recorded_changes-true).
|
|
128
129
|
|
|
129
130
|
`group: :transaction` combines changes from one database transaction. Its source
|
|
130
|
-
information comes from the first event in that group
|
|
131
|
-
|
|
131
|
+
information comes from the first event in that group, and `step.sources` lists
|
|
132
|
+
every event in it, each with its own author.
|
|
132
133
|
|
|
133
134
|
`snapshots: true` also keeps copies of the compared records' data, including
|
|
134
135
|
unchanged fields. These read-only copies are called snapshots. Access them as
|
|
@@ -195,8 +196,11 @@ Three selection rules matter:
|
|
|
195
196
|
`associations: [:comments]`, editing only a comment selects its article, and
|
|
196
197
|
the article's report shows the edit.
|
|
197
198
|
- A relation such as `Article.where(status: "open")` checks today's values.
|
|
198
|
-
|
|
199
|
-
|
|
199
|
+
Deleted records have none, so they are listed separately. With
|
|
200
|
+
`include_deleted: true`, the gem tests each against the relation as it was
|
|
201
|
+
when destroyed and keeps only those that match, so a report on one account's
|
|
202
|
+
records holds only that account's deletions. That needs plain conditions on
|
|
203
|
+
the model's own columns; a join or SQL text raises `ConfigurationError`.
|
|
200
204
|
- `limit: 100` is a safety ceiling, not pagination. Selecting too many records
|
|
201
205
|
raises `BatchLimitExceededError`. With `include_deleted: true`, both existing
|
|
202
206
|
and deleted records count toward it. Use a narrower relation or time window,
|
|
@@ -215,9 +219,9 @@ own.
|
|
|
215
219
|
|
|
216
220
|
## 4. Turn a report into rows
|
|
217
221
|
|
|
218
|
-
For a history table or an export,
|
|
219
|
-
|
|
220
|
-
requires the association setup in the quickstart:
|
|
222
|
+
For a history table or an export, `change_rows` flattens a report into one row
|
|
223
|
+
per change. Each row names the record, the change, and the version it came
|
|
224
|
+
from. This example requires the association setup in the quickstart:
|
|
221
225
|
|
|
222
226
|
<!-- executable:recipe-summary -->
|
|
223
227
|
```ruby
|
|
@@ -225,48 +229,35 @@ report = PaperTrailDiff.report(
|
|
|
225
229
|
Article.all, since: 1.day.ago, limit: 100,
|
|
226
230
|
associations: [:comments], include_deleted: true
|
|
227
231
|
)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
232
|
+
|
|
233
|
+
rows = report.change_rows.flat_map do |root, changes|
|
|
234
|
+
changes.map { |row| row.to_h.merge(root: root) }
|
|
257
235
|
end
|
|
258
236
|
```
|
|
259
237
|
|
|
260
|
-
`
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
238
|
+
`report.change_rows` is a hash keyed like `report.analyses`, with deleted
|
|
239
|
+
records last. Each value lists that record's rows in time order. Each row
|
|
240
|
+
covers one of two things:
|
|
241
|
+
|
|
242
|
+
- **A field**: `attribute` names it, and `change` is `:changed` for an edit.
|
|
243
|
+
For a record that appeared or disappeared, each of its fields gets an
|
|
244
|
+
`:added` or `:removed` row, holding its value in `to` or `from`. Blank
|
|
245
|
+
fields of such a record get no row.
|
|
246
|
+
- **A whole record**: `attribute` is `nil`, and `change` is `:added` or
|
|
247
|
+
`:removed`. The record was created or deleted, a comment joined or left the
|
|
248
|
+
article, or a `belongs_to` pointed somewhere new, which gives a `:removed`
|
|
249
|
+
row for the old record and an `:added` row for the new one. A comment moved
|
|
250
|
+
from another article is `:added` too; its `event` is then `"update"`.
|
|
251
|
+
|
|
252
|
+
`record` is the record the row is about, or `nil` for the article itself, and
|
|
253
|
+
`association_path` is how it was reached, such as `["comments"]`. `to_h` adds
|
|
254
|
+
the step's source version: `version_id`, `event`, `whodunnit`, `recorded_at`,
|
|
255
|
+
and `transaction_id`. With `group: :transaction`, that is the transaction's
|
|
256
|
+
first event; `step.sources` lists every event. `row.source` is the
|
|
257
|
+
`ActivityBoundary` itself.
|
|
258
|
+
|
|
259
|
+
`change_rows` is also on an `Analysis` built with `activity: true` and on each
|
|
260
|
+
activity step. Labels, grouping, and formatting are left to your application.
|
|
270
261
|
|
|
271
262
|
## Choose a method
|
|
272
263
|
|
|
@@ -298,15 +289,27 @@ Scopes and default scopes made of plain conditions, such as
|
|
|
298
289
|
`-> { where(archived: false) }`, are batched too, and so is a `limit` or
|
|
299
290
|
`offset` ordered by integer, boolean, date, or datetime columns down to the
|
|
300
291
|
primary key, such as `-> { order(:position, :id).limit(3) }`, on a direct
|
|
301
|
-
association or a `has_many :through`.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
292
|
+
association or a `has_many :through`. History cannot read a few scopes, such
|
|
293
|
+
as a `limit` ordered by a string, a scope that takes the owner, or SQL text.
|
|
294
|
+
PT-AT reads those instead, one record at a time and only approximately, so
|
|
295
|
+
every method raises `UnsupportedAssociationError` naming the association.
|
|
296
|
+
Remove it from `associations:`, or pass `allow_approximate: true` to accept
|
|
297
|
+
PT-AT's answer and its cost. See
|
|
298
|
+
[costly associations](docs/reference.md#costly-associations).
|
|
307
299
|
Callbacks and callable historical filters can also issue queries. See the
|
|
308
300
|
[performance guide](docs/performance.md) for measured behavior and limitations.
|
|
309
301
|
|
|
302
|
+
On a large version table, add these indexes, or every `since:` or `within:`
|
|
303
|
+
report reads all of the model's versions. See
|
|
304
|
+
[database indexes](docs/architecture.md#database-indexes) for why.
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
add_index :versions, %i[item_type item_id created_at id],
|
|
308
|
+
name: "index_versions_on_item_and_timeline"
|
|
309
|
+
add_index :versions, %i[item_type created_at item_id],
|
|
310
|
+
name: "index_versions_on_type_and_time"
|
|
311
|
+
```
|
|
312
|
+
|
|
310
313
|
- `updated_at` is ignored. Passing `ignore:` replaces the default list.
|
|
311
314
|
- Records passed as current state must be saved and have no unsaved changes.
|
|
312
315
|
They are reloaded from the database.
|
data/docs/architecture.md
CHANGED
|
@@ -66,17 +66,19 @@ PT-AT transaction are applied together.
|
|
|
66
66
|
|
|
67
67
|
These association shapes cannot be read from the loaded history. The gem asks
|
|
68
68
|
PT-AT to rebuild them at each point instead, which costs queries per record and
|
|
69
|
-
resolves association membership by timestamp only
|
|
69
|
+
resolves association membership by timestamp only. PT-AT starts from the
|
|
70
|
+
association's current members and swaps in their older versions, without
|
|
71
|
+
checking a scope against them, so its membership is approximate:
|
|
70
72
|
|
|
71
73
|
- associations with a scope the gem cannot read as plain conditions and an
|
|
72
74
|
exact ranking, including owner-dependent scopes and a `limit` without a
|
|
73
75
|
total integer order;
|
|
74
|
-
- targets with such a default scope
|
|
75
|
-
|
|
76
|
+
- targets with such a default scope, including a polymorphic `belongs_to`
|
|
77
|
+
target, whose class and scope are known only from each record;
|
|
76
78
|
- composite foreign keys.
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
`
|
|
80
|
+
Every method that reads history rejects the scoped cases unless you pass
|
|
81
|
+
`allow_approximate: true`; see [costly associations](reference.md#costly-associations).
|
|
80
82
|
|
|
81
83
|
HABTM membership comes from the snapshots PT-AT records with each transaction,
|
|
82
84
|
so it is only known at root versions saved in a transaction. Those snapshots
|
|
@@ -88,18 +90,32 @@ HABTM of the same name saved a version holds both records' members;
|
|
|
88
90
|
|
|
89
91
|
## Database indexes
|
|
90
92
|
|
|
91
|
-
For large histories, add
|
|
92
|
-
versions:
|
|
93
|
+
For large histories, add two indexes to each version table, one for each way
|
|
94
|
+
the gem reads versions:
|
|
93
95
|
|
|
94
96
|
```ruby
|
|
95
97
|
add_index :versions, %i[item_type item_id created_at id],
|
|
96
98
|
name: "index_versions_on_item_and_timeline"
|
|
99
|
+
add_index :versions, %i[item_type created_at item_id],
|
|
100
|
+
name: "index_versions_on_type_and_time"
|
|
97
101
|
```
|
|
98
102
|
|
|
99
|
-
|
|
100
|
-
`item_id`,
|
|
101
|
-
|
|
102
|
-
|
|
103
|
+
The first serves reading one record's history: it filters by `item_type` and
|
|
104
|
+
`item_id`, then by time, in time and ID order. It covers PaperTrail's own
|
|
105
|
+
`(item_type, item_id)` index, which you can then drop.
|
|
106
|
+
|
|
107
|
+
The second serves finding which records changed in a window, as `report`,
|
|
108
|
+
`analyze_scope`, and the other batch methods do with `since:` or `within:`.
|
|
109
|
+
They filter by `item_type` and `created_at` alone, which the first index cannot
|
|
110
|
+
narrow, so without the second the database reads every version of the model.
|
|
111
|
+
On PostgreSQL with 5 million article versions, a report on five articles
|
|
112
|
+
changed in the last hour took about 230 ms without it and 2 ms with it.
|
|
113
|
+
Ending the index with `item_id` lets the database answer from the index alone,
|
|
114
|
+
and SQLite, which keeps no table statistics unless you run `ANALYZE`, picks it
|
|
115
|
+
only then.
|
|
116
|
+
|
|
117
|
+
Keep PT-AT's own index on `version_associations` (`foreign_key_name`,
|
|
118
|
+
`foreign_key_id`, `foreign_type`).
|
|
103
119
|
|
|
104
120
|
## Remaining costs
|
|
105
121
|
|
|
@@ -109,3 +125,8 @@ on `version_associations` (`foreign_key_name`, `foreign_key_id`,
|
|
|
109
125
|
- Repeated events in one very wide collection still copy that collection's
|
|
110
126
|
list of records for each step. The comparison work stays small, but memory
|
|
111
127
|
traffic grows with *events × collection size*.
|
|
128
|
+
- With PT-AT installed, a `within:` or `since:` window looks up each root's
|
|
129
|
+
versions on either side of both edges, to move an edge that falls inside a
|
|
130
|
+
transaction (see [time windows](reference.md#time-windows)). These are
|
|
131
|
+
four indexed lookups per root, sent 200 roots per query, whatever the
|
|
132
|
+
length of the root's history: about 0.2 ms per root on SQLite.
|