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
data/docs/testing.md
CHANGED
|
@@ -8,7 +8,7 @@ mise exec -- bundle exec rake
|
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
The default task runs the core specs without PT-AT loaded, the association specs
|
|
11
|
-
in a separate process, RuboCop (with the rubocop-performance and rubocop-rspec
|
|
11
|
+
and the [oracle checks](#oracle-checks) each in a separate process, RuboCop (with the rubocop-performance and rubocop-rspec
|
|
12
12
|
plugins), generated-signature verification, and Steep.
|
|
13
13
|
Ruby blocks in the README, quickstart, and API reference marked with
|
|
14
14
|
`<!-- executable:... -->` run inside those specs, so documented examples stay
|
|
@@ -37,6 +37,49 @@ history, with integer and string `versions.item_id`, YAML and JSON history, and
|
|
|
37
37
|
a `has_paper_trail skip:` column. When one fails, its seed reproduces it. Add a
|
|
38
38
|
variant here when a bug turns out to depend on a schema or configuration.
|
|
39
39
|
|
|
40
|
+
## Oracle checks
|
|
41
|
+
|
|
42
|
+
The consistency checks compare gem methods with each other, so a bug they
|
|
43
|
+
share passes. `spec/oracle` compares them with the truth instead:
|
|
44
|
+
|
|
45
|
+
1. A seeded generator (`spec/support/oracle.rb`) makes random edits to an
|
|
46
|
+
article graph, one transaction each: root edits and checkpoints, comments
|
|
47
|
+
and replies created, edited, moved, and deleted, an author, a profile, and
|
|
48
|
+
authorships with their authors. Some transactions write several versions.
|
|
49
|
+
2. After each transaction it records the live state, keyed by the versions
|
|
50
|
+
that transaction wrote, so every version names the state just before it.
|
|
51
|
+
The state comes from the gem's current-record path, which is plain Active
|
|
52
|
+
Record, and is checked against an independent read of each association.
|
|
53
|
+
3. Every endpoint pair, every timeline step, every activity step (grouped and
|
|
54
|
+
not), and windows through `analyze`, `analyze_many`, and `analyze_scope`
|
|
55
|
+
are compared with the recorded states. Two windows that meet between
|
|
56
|
+
transactions, or inside one, must report each step once, as the window
|
|
57
|
+
spanning both does, through both `activity_timeline` and `analyze_many`.
|
|
58
|
+
|
|
59
|
+
Two more generators cover other shapes. `scoped_membership_spec.rb` moves
|
|
60
|
+
notes in and out of scoped associations (conditions, order, limit, and
|
|
61
|
+
offset) and compares the members at every version with Active Record's own
|
|
62
|
+
answer. `tree_history_spec.rb` edits a tree of one model, so a root can sit
|
|
63
|
+
inside another root's tree, with children whose attributes are encrypted,
|
|
64
|
+
serialized, enums stored as strings, custom types, decimals, and JSON. It reads
|
|
65
|
+
the expected states with plain Active Record, not through the gem.
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
mise exec -- bundle exec rake spec_oracle
|
|
69
|
+
ORACLE_SEEDS=1..200 ORACLE_STEPS=40 mise exec -- bundle exec rake spec_oracle
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The default seeds keep the run to a few seconds; `rake spec` includes it, so
|
|
73
|
+
the database jobs run it too. Mutant does not, because it would slow every
|
|
74
|
+
mutation. A failure prints its seed and the transactions it made. To fix one,
|
|
75
|
+
reproduce it with that seed, reduce it to the few edits that matter, and add
|
|
76
|
+
that as a named spec beside the code it covers, so the cause is readable
|
|
77
|
+
without the generator.
|
|
78
|
+
|
|
79
|
+
When a bug turns out to depend on a schema, configuration, or kind of edit,
|
|
80
|
+
add it to the generator or as a new context, so the whole class of bug stays
|
|
81
|
+
covered.
|
|
82
|
+
|
|
40
83
|
## Mutation testing
|
|
41
84
|
|
|
42
85
|
[Mutant](https://github.com/mbj/mutant) changes the library one expression at
|
|
@@ -51,8 +94,10 @@ passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
|
|
|
51
94
|
- which versions a batch or filter reads: `BatchedRootVersions`,
|
|
52
95
|
`DeletedAnalysisBatch`, and `HistoricalFilter`;
|
|
53
96
|
- what a version recorded: `RecordedChanges`;
|
|
97
|
+
- how a step flattens into rows: `ChangeRow` and `ChangeRows`;
|
|
54
98
|
- which records a scope or report selects: `ScopedRootSelection`,
|
|
55
|
-
`AssociationActivitySelection`,
|
|
99
|
+
`AssociationActivitySelection`, `AssociationActivityEdge`, and
|
|
100
|
+
`DeletedRootFilter`;
|
|
56
101
|
- which children and targets a record had at each point:
|
|
57
102
|
`PreparedHistoryLoader`, `PreparedEdgeLoader`,
|
|
58
103
|
`ActivityTargetCandidateLoader`, and `PreparedActivityIndex`;
|
|
@@ -60,10 +105,11 @@ passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
|
|
|
60
105
|
query reads: `ActivityRange` and `VersionAssociationCandidateScope`;
|
|
61
106
|
- what activity a record shows: `ActivityTimelineBuilder` and
|
|
62
107
|
`ActivityTransactionGrouper`;
|
|
63
|
-
- which association paths a request accepts: `AssociationTraversal
|
|
108
|
+
- which association paths a request accepts: `AssociationTraversal` and
|
|
109
|
+
`AssociationKey`;
|
|
64
110
|
- whether a transaction's HABTM members are one record's:
|
|
65
111
|
`HabtmTransactionOwners`;
|
|
66
|
-
- which association paths
|
|
112
|
+
- which association paths history cannot read exactly: `ApproximatePaths`;
|
|
67
113
|
- which records an association scope admits, and how it ranks them:
|
|
68
114
|
`ScopeCondition` and `ScopeRanking`.
|
|
69
115
|
|
|
@@ -79,7 +125,10 @@ description names the mutated class (`RSpec.describe PaperTrailDiff::Engine`),
|
|
|
79
125
|
falling back to wider names such as `PaperTrailDiff` when none does. A spec
|
|
80
126
|
named after a behavior, as most integration specs are, never runs. So each
|
|
81
127
|
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.
|
|
128
|
+
and a class added to `mutant.yml` needs one too. A spec named after a
|
|
129
|
+
behavior can still name the class it checks with metadata, as the activity
|
|
130
|
+
window specs do: `RSpec.describe 'Activity over a window', mutant_expression:
|
|
131
|
+
'PaperTrailDiff::ActivityTimelineBuilder'`.
|
|
83
132
|
|
|
84
133
|
Mutant counts a mutation that runs past its time limit as caught, so
|
|
85
134
|
`mutant.yml` sets the limit well above the time the whole suite takes. With
|
|
@@ -122,8 +171,10 @@ they resynchronize, which made window-based specs fail at random.
|
|
|
122
171
|
`spec/support/monotonic_clock.rb` therefore makes `Time.now` strictly
|
|
123
172
|
increasing, in whole microseconds, for the test process. ActiveRecord
|
|
124
173
|
timestamps, PaperTrail's `created_at`, and windows built from `Time.now` all
|
|
125
|
-
read it, so they stay consistent however the host clock moves.
|
|
126
|
-
|
|
174
|
+
read it, so they stay consistent however the host clock moves. The benchmark
|
|
175
|
+
workload, `benchmark/support/workload.rb`, loads it too, because a step back
|
|
176
|
+
could date a root's destroy before its last update. It affects only the specs
|
|
177
|
+
and the benchmark; the gem itself reads the ordinary clock.
|
|
127
178
|
|
|
128
179
|
## Other databases locally
|
|
129
180
|
|
|
@@ -158,6 +209,11 @@ PaperTrail's default YAML serializer, the fixtures explicitly permit
|
|
|
158
209
|
`ActiveRecord.yaml_column_permitted_classes` configuration. The gem does not
|
|
159
210
|
change an application's YAML configuration.
|
|
160
211
|
|
|
212
|
+
PT-AT compares version times through SQL text, which SQL Server's adapter
|
|
213
|
+
writes as `datetime` values, up to 1/300 s off. So on SQL Server the
|
|
214
|
+
association specs keep their versions 5 ms apart, or the specs that compare
|
|
215
|
+
the gem with PT-AT would fail whenever versions were saved closer together.
|
|
216
|
+
|
|
161
217
|
The CI service configuration is in `.github/workflows/ci.yml`. PostgreSQL and
|
|
162
218
|
MySQL create the named database during container initialization. The SQL Server
|
|
163
219
|
jobs create it explicitly after the service passes its health check.
|
|
@@ -17,9 +17,20 @@ module PaperTrailDiff
|
|
|
17
17
|
# report one save as one step rather than as its parts.
|
|
18
18
|
attr_reader :transaction_id #: untyped
|
|
19
19
|
|
|
20
|
+
# @rbs @recorded_changes: untyped
|
|
21
|
+
|
|
22
|
+
# Stands for recorded changes nobody asked to read, which differs from a
|
|
23
|
+
# version that recorded nothing (nil).
|
|
24
|
+
NOT_READ = Object.new.freeze
|
|
25
|
+
NOT_READ_MESSAGE = 'recorded changes were not read; pass recorded_changes: true'
|
|
26
|
+
private_constant :NOT_READ, :NOT_READ_MESSAGE
|
|
27
|
+
|
|
20
28
|
class << self
|
|
21
|
-
|
|
22
|
-
|
|
29
|
+
# `recorded:` names the attributes to leave out of the version's recorded
|
|
30
|
+
# changes, or is nil when they were not asked for. Reading them decodes
|
|
31
|
+
# `object_changes`, which a caller who does not want them should not pay.
|
|
32
|
+
#: (untyped, ?recorded: Array[String]?) -> ActivityBoundary
|
|
33
|
+
def from_version(version, recorded: nil)
|
|
23
34
|
new(
|
|
24
35
|
kind: :version,
|
|
25
36
|
version_id: version.id,
|
|
@@ -28,26 +39,29 @@ module PaperTrailDiff
|
|
|
28
39
|
recorded_at: version.created_at,
|
|
29
40
|
event: version.event,
|
|
30
41
|
whodunnit: version.whodunnit,
|
|
31
|
-
transaction_id: transaction_id(version)
|
|
42
|
+
transaction_id: VersionBoundary.transaction_id(version),
|
|
43
|
+
recorded_changes: recorded_changes(version, recorded)
|
|
32
44
|
)
|
|
33
45
|
end
|
|
34
46
|
|
|
35
|
-
|
|
36
|
-
|
|
47
|
+
# The current record has no version, so it recorded nothing.
|
|
48
|
+
#: (untyped, captured_at: untyped, ?recorded: Array[String]?) -> ActivityBoundary
|
|
49
|
+
def current(record, captured_at:, recorded: nil)
|
|
37
50
|
new(
|
|
38
51
|
kind: :current,
|
|
39
52
|
version_id: nil,
|
|
40
53
|
item_type: record.class.base_class.name,
|
|
41
54
|
item_id: record.id,
|
|
42
|
-
recorded_at: captured_at
|
|
55
|
+
recorded_at: captured_at,
|
|
56
|
+
recorded_changes: recorded.nil? ? NOT_READ : nil
|
|
43
57
|
)
|
|
44
58
|
end
|
|
45
59
|
|
|
46
60
|
# The state a `destroy` version leaves behind. A version records the state
|
|
47
61
|
# before its own event, so the boundary built from a destroy version still
|
|
48
62
|
# holds the record; this one is the absence that follows it.
|
|
49
|
-
#: (untyped) -> ActivityBoundary
|
|
50
|
-
def destroyed(version)
|
|
63
|
+
#: (untyped, ?recorded: Array[String]?) -> ActivityBoundary
|
|
64
|
+
def destroyed(version, recorded: nil)
|
|
51
65
|
new(
|
|
52
66
|
kind: :destroyed,
|
|
53
67
|
version_id: version.id,
|
|
@@ -56,20 +70,24 @@ module PaperTrailDiff
|
|
|
56
70
|
recorded_at: version.created_at,
|
|
57
71
|
event: version.event,
|
|
58
72
|
whodunnit: version.whodunnit,
|
|
59
|
-
transaction_id: transaction_id(version)
|
|
73
|
+
transaction_id: VersionBoundary.transaction_id(version),
|
|
74
|
+
recorded_changes: recorded_changes(version, recorded)
|
|
60
75
|
)
|
|
61
76
|
end
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
#: (untyped, Array[String]?) -> untyped
|
|
81
|
+
def recorded_changes(version, ignore)
|
|
82
|
+
return NOT_READ if ignore.nil?
|
|
83
|
+
|
|
84
|
+
RecordedChanges.call(version, ignore: ignore)
|
|
69
85
|
end
|
|
70
86
|
end
|
|
71
87
|
|
|
72
|
-
|
|
88
|
+
# `recorded_changes:` takes what `PaperTrailDiff.recorded_changes` returns
|
|
89
|
+
# for the version; leave it out when they were not read.
|
|
90
|
+
#: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped, ?recorded_changes: untyped) -> void
|
|
73
91
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
74
92
|
kind:,
|
|
75
93
|
version_id:,
|
|
@@ -78,7 +96,8 @@ module PaperTrailDiff
|
|
|
78
96
|
recorded_at:,
|
|
79
97
|
event: nil,
|
|
80
98
|
whodunnit: nil,
|
|
81
|
-
transaction_id: nil
|
|
99
|
+
transaction_id: nil,
|
|
100
|
+
recorded_changes: NOT_READ
|
|
82
101
|
)
|
|
83
102
|
@kind = kind
|
|
84
103
|
@version_id = Support.immutable_copy(version_id)
|
|
@@ -88,10 +107,29 @@ module PaperTrailDiff
|
|
|
88
107
|
@event = Support.immutable_copy(event&.to_s)
|
|
89
108
|
@whodunnit = Support.immutable_copy(whodunnit)
|
|
90
109
|
@transaction_id = Support.immutable_copy(transaction_id)
|
|
110
|
+
@recorded_changes = recorded_changes
|
|
91
111
|
@record = RecordReference.new(type: @item_type, id: @item_id)
|
|
92
112
|
freeze
|
|
93
113
|
end
|
|
94
114
|
|
|
115
|
+
# What PaperTrail recorded with this boundary's version, as
|
|
116
|
+
# `PaperTrailDiff.recorded_changes` returns it: nil when nothing was
|
|
117
|
+
# recorded, and always nil for the current record, which has no version.
|
|
118
|
+
# Raises unless the timeline was built with `recorded_changes: true`, so a
|
|
119
|
+
# forgotten option cannot pass for a version that recorded nothing.
|
|
120
|
+
#: () -> Hash[String, ValueChange]?
|
|
121
|
+
def recorded_changes
|
|
122
|
+
raise ConfigurationError, NOT_READ_MESSAGE unless recorded_changes?
|
|
123
|
+
|
|
124
|
+
@recorded_changes
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Whether this boundary's recorded changes were read.
|
|
128
|
+
#: () -> bool
|
|
129
|
+
def recorded_changes?
|
|
130
|
+
!NOT_READ.equal?(@recorded_changes)
|
|
131
|
+
end
|
|
132
|
+
|
|
95
133
|
#: () -> bool
|
|
96
134
|
def version?
|
|
97
135
|
kind == :version
|
|
@@ -115,11 +153,14 @@ module PaperTrailDiff
|
|
|
115
153
|
item_type: item_type,
|
|
116
154
|
item_id: item_id,
|
|
117
155
|
recorded_at: recorded_at
|
|
118
|
-
}
|
|
156
|
+
} #: Hash[Symbol, untyped]
|
|
119
157
|
return value unless metadata
|
|
120
158
|
|
|
121
|
-
value.merge(event: event, whodunnit: whodunnit, transaction_id: transaction_id,
|
|
122
|
-
|
|
159
|
+
value.merge!(event: event, whodunnit: whodunnit, transaction_id: transaction_id,
|
|
160
|
+
record: record.to_h)
|
|
161
|
+
return value unless recorded_changes?
|
|
162
|
+
|
|
163
|
+
value.merge(recorded_changes: @recorded_changes&.transform_values(&:to_h))
|
|
123
164
|
end
|
|
124
165
|
end
|
|
125
166
|
|
|
@@ -135,28 +176,35 @@ module PaperTrailDiff
|
|
|
135
176
|
# get wrong.
|
|
136
177
|
attr_reader :from_snapshot #: RecordSnapshot?
|
|
137
178
|
attr_reader :to_snapshot #: RecordSnapshot?
|
|
179
|
+
# The events whose changes this step reports, in the order they happened.
|
|
180
|
+
# That is `source_boundary` alone, unless the step is a transaction group,
|
|
181
|
+
# which holds every event in it: an attribution that names only the first
|
|
182
|
+
# would credit the rest of the save to whoever opened it.
|
|
183
|
+
attr_reader :sources #: Array[ActivityBoundary]
|
|
138
184
|
|
|
139
185
|
# Compares two reconstructed states and keeps them only when asked, which is
|
|
140
186
|
# every caller's shape: the diff always comes from the pair, the pair itself
|
|
141
187
|
# is retained on request.
|
|
142
|
-
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
|
|
143
|
-
def self.between(from_boundary:, to_boundary:, from_snapshot:, to_snapshot:, retain:)
|
|
188
|
+
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool, ?sources: Array[ActivityBoundary]?) -> ActivityStep
|
|
189
|
+
def self.between(from_boundary:, to_boundary:, from_snapshot:, to_snapshot:, retain:, sources: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
144
190
|
new(
|
|
145
191
|
from_boundary: from_boundary,
|
|
146
192
|
to_boundary: to_boundary,
|
|
147
193
|
diff: Engine.compare(from_snapshot, to_snapshot),
|
|
148
194
|
from_snapshot: (from_snapshot if retain),
|
|
149
|
-
to_snapshot: (to_snapshot if retain)
|
|
195
|
+
to_snapshot: (to_snapshot if retain),
|
|
196
|
+
sources: sources
|
|
150
197
|
)
|
|
151
198
|
end
|
|
152
199
|
|
|
153
|
-
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
|
|
154
|
-
def initialize(from_boundary:, to_boundary:, diff:, from_snapshot: nil, to_snapshot: nil)
|
|
200
|
+
#: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?, ?sources: Array[ActivityBoundary]?) -> void
|
|
201
|
+
def initialize(from_boundary:, to_boundary:, diff:, from_snapshot: nil, to_snapshot: nil, sources: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
155
202
|
@from_boundary = from_boundary
|
|
156
203
|
@to_boundary = to_boundary
|
|
157
204
|
@diff = diff
|
|
158
205
|
@from_snapshot = from_snapshot
|
|
159
206
|
@to_snapshot = to_snapshot
|
|
207
|
+
@sources = (sources || [from_boundary]).dup.freeze
|
|
160
208
|
freeze
|
|
161
209
|
end
|
|
162
210
|
|
|
@@ -170,13 +218,22 @@ module PaperTrailDiff
|
|
|
170
218
|
#: () -> ActivityBoundary
|
|
171
219
|
def source_boundary = from_boundary
|
|
172
220
|
|
|
221
|
+
# The diff as flat rows, each attributed to `source_boundary`.
|
|
222
|
+
#: () -> Array[ChangeRow]
|
|
223
|
+
def change_rows
|
|
224
|
+
ChangeRows.call(diff, source: source_boundary)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# `metadata: true` also lists `sources`, since who made a change is
|
|
228
|
+
# metadata as much as the source boundary's own author is.
|
|
173
229
|
#: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
|
|
174
230
|
def to_h(metadata: false, snapshots: false)
|
|
175
231
|
value = {
|
|
176
232
|
from: from_boundary.to_h(metadata: metadata),
|
|
177
233
|
to: to_boundary.to_h(metadata: metadata),
|
|
178
234
|
diff: diff.to_h
|
|
179
|
-
}
|
|
235
|
+
} #: Hash[Symbol, untyped]
|
|
236
|
+
value[:sources] = sources.map { |source| source.to_h(metadata: true) } if metadata
|
|
180
237
|
return value unless snapshots
|
|
181
238
|
|
|
182
239
|
value.merge(from_snapshot: from_snapshot&.to_h, to_snapshot: to_snapshot&.to_h)
|
|
@@ -62,7 +62,8 @@ module PaperTrailDiff
|
|
|
62
62
|
|
|
63
63
|
#: (untyped) -> untyped
|
|
64
64
|
def historical_relation(version_class)
|
|
65
|
-
@parent_class.paper_trail.version_association_class
|
|
65
|
+
associations = @parent_class.paper_trail.version_association_class
|
|
66
|
+
VersionTable.joined(associations, version_class).where(
|
|
66
67
|
foreign_key_name: @reflection.foreign_key.to_s,
|
|
67
68
|
foreign_key_id: @parent_ids,
|
|
68
69
|
foreign_type: parent_types
|
|
@@ -7,10 +7,11 @@ module PaperTrailDiff
|
|
|
7
7
|
attr_reader :version #: untyped
|
|
8
8
|
attr_reader :branches #: Array[String]?
|
|
9
9
|
|
|
10
|
-
#: (version: untyped, branches: Array[String]?) -> void
|
|
11
|
-
def initialize(version:, branches:)
|
|
10
|
+
#: (version: untyped, branches: Array[String]?, ?join: bool) -> void
|
|
11
|
+
def initialize(version:, branches:, join: false)
|
|
12
12
|
@version = version
|
|
13
13
|
@branches = branches ? branches.map(&:to_s).uniq.sort.freeze : nil
|
|
14
|
+
@join = join
|
|
14
15
|
freeze
|
|
15
16
|
end
|
|
16
17
|
|
|
@@ -18,6 +19,15 @@ module PaperTrailDiff
|
|
|
18
19
|
def root?
|
|
19
20
|
branches.nil?
|
|
20
21
|
end
|
|
22
|
+
|
|
23
|
+
# Whether the version is of a through's join row, which links the root to
|
|
24
|
+
# a target without being one.
|
|
25
|
+
#: () -> bool
|
|
26
|
+
def join?
|
|
27
|
+
@join
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs @join: bool
|
|
21
31
|
end
|
|
22
32
|
|
|
23
33
|
# Accumulates each version once while retaining every selected root branch.
|
|
@@ -26,14 +36,15 @@ module PaperTrailDiff
|
|
|
26
36
|
@events = {} #: Hash[Array[untyped], Hash[Symbol, untyped]]
|
|
27
37
|
end
|
|
28
38
|
|
|
29
|
-
#: (Array[untyped], ?branch: String?) -> void
|
|
30
|
-
def add(versions, branch: nil)
|
|
39
|
+
#: (Array[untyped], ?branch: String?, ?join: bool) -> void
|
|
40
|
+
def add(versions, branch: nil, join: false)
|
|
31
41
|
versions.each do |version|
|
|
32
42
|
key = [version.class.name, version.id]
|
|
33
43
|
existing = @events[key]
|
|
34
44
|
@events[key] = {
|
|
35
45
|
version: version,
|
|
36
|
-
branches: merged_branches(existing, branch)
|
|
46
|
+
branches: merged_branches(existing, branch),
|
|
47
|
+
join: join || existing&.fetch(:join) || false
|
|
37
48
|
}
|
|
38
49
|
end
|
|
39
50
|
end
|
|
@@ -43,7 +54,8 @@ module PaperTrailDiff
|
|
|
43
54
|
@events.values.map do |event|
|
|
44
55
|
ActivityEvent.new(
|
|
45
56
|
version: event.fetch(:version),
|
|
46
|
-
branches: event.fetch(:branches)
|
|
57
|
+
branches: event.fetch(:branches),
|
|
58
|
+
join: event.fetch(:join)
|
|
47
59
|
)
|
|
48
60
|
end
|
|
49
61
|
end
|
|
@@ -64,6 +76,28 @@ module PaperTrailDiff
|
|
|
64
76
|
|
|
65
77
|
# Resolves the branch union that PT-AT exposes atomically for one transaction.
|
|
66
78
|
class ActivityTransactionGroups
|
|
79
|
+
# A range ending at a version ends at the state before that version's
|
|
80
|
+
# transaction: PT-AT rebuilds its associations without the changes saved
|
|
81
|
+
# alongside it. A descendant version from that transaction, such as the
|
|
82
|
+
# child update behind a `touch: true` parent version, is therefore past the
|
|
83
|
+
# end. Kept, it would open a step whose change the closing state cannot
|
|
84
|
+
# show, and that change would be reported nowhere. A range ending at a time
|
|
85
|
+
# has no transaction to stop before.
|
|
86
|
+
#: (Array[ActivityEvent], untyped) -> Array[ActivityEvent]
|
|
87
|
+
def self.before_closing(events, range_end)
|
|
88
|
+
key = transaction_key(range_end)
|
|
89
|
+
return events unless key
|
|
90
|
+
|
|
91
|
+
events.reject { |event| !event.root? && transaction_key(event.version) == key }.freeze
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# A version without a transaction shares one with nothing.
|
|
95
|
+
#: (untyped) -> Array[untyped]?
|
|
96
|
+
def self.transaction_key(version)
|
|
97
|
+
transaction_id = VersionBoundary.transaction_id(version)
|
|
98
|
+
[version.class.name, transaction_id] if transaction_id
|
|
99
|
+
end
|
|
100
|
+
|
|
67
101
|
#: (Array[ActivityEvent]) -> void
|
|
68
102
|
def initialize(events)
|
|
69
103
|
groups = grouped_events(events)
|
|
@@ -119,10 +153,7 @@ module PaperTrailDiff
|
|
|
119
153
|
|
|
120
154
|
#: (ActivityEvent) -> Array[untyped]?
|
|
121
155
|
def transaction_key(event)
|
|
122
|
-
|
|
123
|
-
return unless version.respond_to?(:transaction_id) && version.transaction_id
|
|
124
|
-
|
|
125
|
-
[version.class.name, version.transaction_id]
|
|
156
|
+
self.class.transaction_key(event.version)
|
|
126
157
|
end
|
|
127
158
|
end
|
|
128
159
|
end
|
|
@@ -30,22 +30,26 @@ module PaperTrailDiff
|
|
|
30
30
|
|
|
31
31
|
# Builds activity steps while retaining root snapshots for combined analysis.
|
|
32
32
|
class ActivityHistoryBuilder
|
|
33
|
-
|
|
33
|
+
# `closing` is the snapshot after the last event when the range closes on
|
|
34
|
+
# the current record. Any other range ends at a root event, or at events
|
|
35
|
+
# after a window whose steps are not reported, so nothing reads it.
|
|
36
|
+
#: (Array[untyped], Array[ActivityEvent], untyped, view: ActivityView, ?current: untyped, ?include_step: untyped, ?closing: RecordSnapshot?) -> void
|
|
34
37
|
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
35
|
-
root_versions, events, snapshotter, current: nil, include_step: nil,
|
|
38
|
+
root_versions, events, snapshotter, view:, current: nil, include_step: nil, closing: nil
|
|
36
39
|
)
|
|
37
40
|
@root_versions = root_versions
|
|
38
41
|
@events = events
|
|
39
42
|
@snapshotter = snapshotter
|
|
43
|
+
@view = view
|
|
40
44
|
@current = current
|
|
41
45
|
@include_step = include_step
|
|
42
|
-
@
|
|
46
|
+
@closing = closing
|
|
43
47
|
end
|
|
44
48
|
|
|
45
49
|
#: () -> ActivityHistory
|
|
46
50
|
def call
|
|
47
51
|
reset
|
|
48
|
-
sequence
|
|
52
|
+
filter.call(sequence) { |event, snapshot, kept| consume(event, snapshot, kept) }
|
|
49
53
|
ActivityHistory.new(
|
|
50
54
|
steps: @steps,
|
|
51
55
|
root_snapshots: @root_snapshots,
|
|
@@ -63,7 +67,8 @@ module PaperTrailDiff
|
|
|
63
67
|
# @rbs @snapshotter: untyped
|
|
64
68
|
# @rbs @current: untyped
|
|
65
69
|
# @rbs @include_step: untyped
|
|
66
|
-
# @rbs @
|
|
70
|
+
# @rbs @view: ActivityView
|
|
71
|
+
# @rbs @closing: RecordSnapshot?
|
|
67
72
|
# @rbs @steps: Array[ActivityStep]
|
|
68
73
|
# @rbs @root_snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
69
74
|
# @rbs @first_snapshot: RecordSnapshot?
|
|
@@ -71,6 +76,7 @@ module PaperTrailDiff
|
|
|
71
76
|
# @rbs @selected_last_snapshot: RecordSnapshot?
|
|
72
77
|
# @rbs @previous_boundary: ActivityBoundary?
|
|
73
78
|
# @rbs @previous_event: ActivityEvent?
|
|
79
|
+
# @rbs @passed_reported: bool
|
|
74
80
|
|
|
75
81
|
#: () -> void
|
|
76
82
|
def reset
|
|
@@ -82,6 +88,12 @@ module PaperTrailDiff
|
|
|
82
88
|
@selected_last_snapshot = nil
|
|
83
89
|
@previous_boundary = nil
|
|
84
90
|
@previous_event = nil
|
|
91
|
+
@passed_reported = false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
#: () -> ActivityTreeFilter
|
|
95
|
+
def filter
|
|
96
|
+
ActivityTreeFilter.new(@events, reported: @include_step, closing: @closing)
|
|
85
97
|
end
|
|
86
98
|
|
|
87
99
|
#: () -> ActivitySnapshotSequence
|
|
@@ -94,17 +106,30 @@ module PaperTrailDiff
|
|
|
94
106
|
)
|
|
95
107
|
end
|
|
96
108
|
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
# A left-out event bounds nothing. A reported step it would have opened
|
|
110
|
+
# still moves the reported range's end past it, to the next boundary.
|
|
111
|
+
#: (ActivityEvent, RecordSnapshot?, bool) -> void
|
|
112
|
+
def consume(event, snapshot, kept)
|
|
99
113
|
capture_first(snapshot)
|
|
100
|
-
|
|
114
|
+
return pass(event) unless kept
|
|
115
|
+
|
|
116
|
+
boundary = ActivityBoundary.from_version(
|
|
117
|
+
event.version, recorded: @view.recorded_attributes(root: event.root?)
|
|
118
|
+
)
|
|
101
119
|
@root_snapshots[version_key(event.version)] = snapshot if event.root?
|
|
102
120
|
append_step(boundary, snapshot) if @previous_boundary
|
|
121
|
+
@selected_last_snapshot = snapshot if @passed_reported
|
|
122
|
+
@passed_reported = false
|
|
103
123
|
@previous_boundary = boundary
|
|
104
124
|
@previous_event = event
|
|
105
125
|
@previous_snapshot = snapshot
|
|
106
126
|
end
|
|
107
127
|
|
|
128
|
+
#: (ActivityEvent) -> void
|
|
129
|
+
def pass(event)
|
|
130
|
+
@passed_reported = true if !@include_step || @include_step.call(event)
|
|
131
|
+
end
|
|
132
|
+
|
|
108
133
|
#: (RecordSnapshot?) -> void
|
|
109
134
|
def capture_first(snapshot)
|
|
110
135
|
return if @first_snapshot_seen
|
|
@@ -122,7 +147,7 @@ module PaperTrailDiff
|
|
|
122
147
|
|
|
123
148
|
@steps << ActivityStep.between(
|
|
124
149
|
from_boundary: previous_boundary, to_boundary: boundary,
|
|
125
|
-
from_snapshot: @previous_snapshot, to_snapshot: snapshot, retain: @
|
|
150
|
+
from_snapshot: @previous_snapshot, to_snapshot: snapshot, retain: @view.retain?
|
|
126
151
|
)
|
|
127
152
|
@selected_last_snapshot = snapshot
|
|
128
153
|
end
|
|
@@ -14,6 +14,8 @@ module PaperTrailDiff
|
|
|
14
14
|
Support.chronological_version_key(start_boundary)
|
|
15
15
|
end
|
|
16
16
|
@start_time = Endpoint.version?(start_boundary) ? start_boundary.created_at : start_boundary
|
|
17
|
+
# A time has no transaction.
|
|
18
|
+
@opening_transaction = ActivityTransactionGroups.transaction_key(start_boundary)
|
|
17
19
|
@end_time = Endpoint.version?(end_boundary) ? end_boundary.created_at : end_boundary
|
|
18
20
|
@end_key = if Endpoint.version?(end_boundary)
|
|
19
21
|
Support.chronological_version_key(end_boundary)
|
|
@@ -26,11 +28,16 @@ module PaperTrailDiff
|
|
|
26
28
|
after_start?(version, key) && before_end?(version, key)
|
|
27
29
|
end
|
|
28
30
|
|
|
29
|
-
# Narrows an Active Record version relation by timestamp
|
|
30
|
-
# remains in #include? so this
|
|
31
|
+
# Narrows an Active Record version relation by timestamp, and to the
|
|
32
|
+
# start's transaction. Exact tie-breaking remains in #include? so this
|
|
33
|
+
# works for integer, UUID, and custom version IDs.
|
|
31
34
|
#: (untyped) -> untyped
|
|
32
35
|
def scope(relation)
|
|
33
|
-
relation.where(created_at: @start_time..end_time)
|
|
36
|
+
in_time = relation.where(created_at: @start_time..end_time)
|
|
37
|
+
opening = @opening_transaction
|
|
38
|
+
return in_time unless opening
|
|
39
|
+
|
|
40
|
+
in_time.or(relation.where(transaction_id: opening.last))
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
#: () -> bool
|
|
@@ -58,9 +65,17 @@ module PaperTrailDiff
|
|
|
58
65
|
# @rbs @start_key: Array[untyped]?
|
|
59
66
|
# @rbs @start_time: untyped
|
|
60
67
|
# @rbs @end_key: Array[untyped]?
|
|
68
|
+
# @rbs @opening_transaction: Array[untyped]?
|
|
61
69
|
|
|
70
|
+
# A range starting at a version starts before that version's transaction,
|
|
71
|
+
# where PT-AT reads its associations. A version saved earlier in it, such
|
|
72
|
+
# as the child update behind a `touch: true` parent version, is a change
|
|
73
|
+
# inside the range.
|
|
62
74
|
#: (untyped, Array[untyped]) -> bool
|
|
63
75
|
def after_start?(version, key)
|
|
76
|
+
opening = @opening_transaction
|
|
77
|
+
return true if opening && ActivityTransactionGroups.transaction_key(version) == opening
|
|
78
|
+
|
|
64
79
|
start_key = @start_key
|
|
65
80
|
return key_before_or_equal?(start_key, key) if start_key
|
|
66
81
|
|