paper_trail_diff 0.12.0 → 0.13.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 +127 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +181 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +25 -2
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +15 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +13 -0
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
data/docs/testing.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
The default suite uses an in-memory SQLite database:
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
mise exec -- bundle install
|
|
7
|
+
mise exec -- bundle exec rake
|
|
8
|
+
```
|
|
9
|
+
|
|
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
|
|
12
|
+
plugins), generated-signature verification, and Steep.
|
|
13
|
+
Ruby blocks in the README, quickstart, and API reference marked with
|
|
14
|
+
`<!-- executable:... -->` run inside those specs, so documented examples stay
|
|
15
|
+
correct.
|
|
16
|
+
|
|
17
|
+
For reporting workloads, query counts, reconstruction cost, and memory readings,
|
|
18
|
+
see [Reporting performance](performance.md). Those measurements run separately
|
|
19
|
+
from the correctness suite and have no timing-based CI gate.
|
|
20
|
+
|
|
21
|
+
CI keeps the Ruby 3.1–4.0 and PaperTrail 16/17 compatibility matrix on SQLite,
|
|
22
|
+
including Windows runs. Separate Linux jobs run both core and association suites
|
|
23
|
+
on PostgreSQL 17, MySQL 8.4, and SQL Server 2019 and 2022 with Ruby 4.0 and
|
|
24
|
+
PaperTrail 17.
|
|
25
|
+
The SQL jobs exercise the same regressions, including joined-column ordering,
|
|
26
|
+
duplicate roots, pagination, and historical association reconstruction.
|
|
27
|
+
|
|
28
|
+
## Consistency checks
|
|
29
|
+
|
|
30
|
+
`spec/association/consistency_spec.rb` and `spec/integration/consistency_spec.rb`
|
|
31
|
+
build seeded, generated histories: root edits, checkpoints, and child creates,
|
|
32
|
+
edits, and deletes. For each one they check that every route to the same answer
|
|
33
|
+
agrees: `analyze`, `analyze_many`, and `analyze_scope`; checkpoint and activity
|
|
34
|
+
modes; `compare` and `timeline` against `analyze`; and that activity steps
|
|
35
|
+
chain into the final state. They run over random windows as well as the whole
|
|
36
|
+
history, with integer and string `versions.item_id`, YAML and JSON history, and
|
|
37
|
+
a `has_paper_trail skip:` column. When one fails, its seed reproduces it. Add a
|
|
38
|
+
variant here when a bug turns out to depend on a schema or configuration.
|
|
39
|
+
|
|
40
|
+
## Mutation testing
|
|
41
|
+
|
|
42
|
+
[Mutant](https://github.com/mbj/mutant) changes the comparison code one
|
|
43
|
+
expression at a time and expects a spec to fail each time. A change that every
|
|
44
|
+
spec still passes is behavior nothing checks. `mutant.yml` lists the classes it
|
|
45
|
+
mutates: `Engine`, `CollectionComparator`, `CollectionIdentityIndex`, and
|
|
46
|
+
`NestedComparator`, which decide what counts as a change. It runs the
|
|
47
|
+
association suite, which reaches all of their paths.
|
|
48
|
+
|
|
49
|
+
Mutant needs Ruby 3.3 or newer, so it has its own bundle:
|
|
50
|
+
|
|
51
|
+
```console
|
|
52
|
+
BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle install
|
|
53
|
+
mise exec -- bundle exec rake mutant
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Pass a subject to mutate one class, and `--since main` to mutate only what a
|
|
57
|
+
branch changed:
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run 'PaperTrailDiff::Engine*'
|
|
61
|
+
BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --since main
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When a mutation survives, either add the spec that would catch it, or, if the
|
|
65
|
+
mutated code means the same thing, simplify the code to that form. Reserve a
|
|
66
|
+
`mutant:disable` comment for a method whose mutations all mean the same thing,
|
|
67
|
+
and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
|
|
68
|
+
gives each worker its own copy of the SQLite fixture database.
|
|
69
|
+
|
|
70
|
+
## Test clock
|
|
71
|
+
|
|
72
|
+
The specs build time windows from `Time.now` around versions they have just
|
|
73
|
+
written, so they need a clock that never runs backwards. Virtualized hosts,
|
|
74
|
+
WSL2 in particular, step the wall clock back by hundreds of milliseconds when
|
|
75
|
+
they resynchronize, which made window-based specs fail at random.
|
|
76
|
+
`spec/support/monotonic_clock.rb` therefore makes `Time.now` strictly
|
|
77
|
+
increasing, in whole microseconds, for the test process. ActiveRecord
|
|
78
|
+
timestamps, PaperTrail's `created_at`, and windows built from `Time.now` all
|
|
79
|
+
read it, so they stay consistent however the host clock moves. It affects only
|
|
80
|
+
the test process; the gem itself reads the ordinary clock.
|
|
81
|
+
|
|
82
|
+
## Other databases locally
|
|
83
|
+
|
|
84
|
+
Install the adapter's client development libraries first. On Debian/Ubuntu,
|
|
85
|
+
the packages are `libpq-dev` for PostgreSQL, `default-libmysqlclient-dev` for
|
|
86
|
+
MySQL, and `freetds-dev` for SQL Server. The optional development group includes
|
|
87
|
+
all three adapters:
|
|
88
|
+
|
|
89
|
+
```console
|
|
90
|
+
BUNDLE_WITH=database_adapters mise exec -- bundle install
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Create a dedicated disposable database named `paper_trail_diff_test`. The suites
|
|
94
|
+
recreate their fixture tables. Set `PAPER_TRAIL_DIFF_DATABASE_URL` to its
|
|
95
|
+
connection URL and run both suites:
|
|
96
|
+
|
|
97
|
+
```console
|
|
98
|
+
BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=postgresql://paper_trail_diff:test@127.0.0.1:5432/paper_trail_diff_test mise exec -- bundle exec rake spec
|
|
99
|
+
BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=mysql2://paper_trail_diff:test@127.0.0.1:3306/paper_trail_diff_test mise exec -- bundle exec rake spec
|
|
100
|
+
BUNDLE_WITH=database_adapters PAPER_TRAIL_DIFF_DATABASE_URL=sqlserver://sa:PaperTrail_Test2026@127.0.0.1:1433/paper_trail_diff_test mise exec -- bundle exec rake spec
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
These credentials are examples for disposable local instances. SQL Server's
|
|
104
|
+
adapter version follows ActiveRecord's version; Bundler selects a compatible
|
|
105
|
+
adapter. No database driver is a runtime dependency of the gem.
|
|
106
|
+
|
|
107
|
+
SQL Server's adapter wraps datetime2 values during serialization. With
|
|
108
|
+
PaperTrail's default YAML serializer, the fixtures explicitly permit
|
|
109
|
+
`ActiveRecord::ConnectionAdapters::SQLServer::Type::Data` and
|
|
110
|
+
`ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTime2`, in addition to
|
|
111
|
+
`Symbol` and `Time`. Applications using that serializer need the corresponding
|
|
112
|
+
`ActiveRecord.yaml_column_permitted_classes` configuration. The gem does not
|
|
113
|
+
change an application's YAML configuration.
|
|
114
|
+
|
|
115
|
+
The CI service configuration is in `.github/workflows/ci.yml`. PostgreSQL and
|
|
116
|
+
MySQL create the named database during container initialization. The SQL Server
|
|
117
|
+
jobs create it explicitly after the service passes its health check.
|
|
118
|
+
|
|
119
|
+
## Generated signatures
|
|
120
|
+
|
|
121
|
+
Inline RBS comments in `lib/` are the source of truth:
|
|
122
|
+
|
|
123
|
+
```console
|
|
124
|
+
mise exec -- bundle exec rake rbs
|
|
125
|
+
mise exec -- bundle exec rake typecheck
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The typecheck task regenerates signatures and compares them with Git's index
|
|
129
|
+
before running Steep. Unstaged generated changes therefore fail the gate even
|
|
130
|
+
when they are correct. Review and stage matching source/signature changes before
|
|
131
|
+
running the complete gate. The Steep wrapper also fails on internal errors even
|
|
132
|
+
if Steep returns exit status zero.
|
|
133
|
+
|
|
134
|
+
## Releasing
|
|
135
|
+
|
|
136
|
+
Tags matching `v*` publish through RubyGems trusted publishing. Never add a
|
|
137
|
+
long-lived RubyGems API key. Before tagging:
|
|
138
|
+
|
|
139
|
+
1. Update `PaperTrailDiff::VERSION` and replace `Unreleased` in the changelog
|
|
140
|
+
with that version and the release date.
|
|
141
|
+
2. Run `mise exec -- bundle exec rake release:preflight`. It runs the full gate,
|
|
142
|
+
checks that the tree is clean and pushed, that the Appraisal gemfiles are
|
|
143
|
+
current, and that the changelog names the version, then builds the gem. It
|
|
144
|
+
does not tag, push, or publish.
|
|
145
|
+
3. Configure the RubyGems trusted publisher for `release.yml` and the `release`
|
|
146
|
+
environment.
|
|
@@ -45,7 +45,7 @@ module PaperTrailDiff
|
|
|
45
45
|
|
|
46
46
|
#: (Array[untyped], untyped, SnapshotNormalizer, untyped, untyped) -> RecordSnapshot?
|
|
47
47
|
def normalized_route_record(route, version, normalizer, root_endpoint, context_endpoint)
|
|
48
|
-
record = @record_resolver.record_after(version)
|
|
48
|
+
record = @record_resolver.record_after(version, direct: route.last.fetch(2).empty?)
|
|
49
49
|
return unless record
|
|
50
50
|
|
|
51
51
|
_name, reflection, subtree, path = route.last
|
|
@@ -70,7 +70,7 @@ module PaperTrailDiff
|
|
|
70
70
|
return [snapshot, false] unless changed
|
|
71
71
|
|
|
72
72
|
updated_association = AssociationSnapshot.new(kind: association.kind, records: records)
|
|
73
|
-
[
|
|
73
|
+
[snapshot.with_association(name, updated_association), true]
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
#: (Array[RecordSnapshot], Array[untyped], untyped, RecordSnapshot?, Integer) -> [Array[RecordSnapshot], bool]
|
|
@@ -101,16 +101,6 @@ module PaperTrailDiff
|
|
|
101
101
|
[updated.freeze, true]
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
-
#: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
105
|
-
def replace_association(snapshot, name, association)
|
|
106
|
-
RecordSnapshot.new(
|
|
107
|
-
type: snapshot.type,
|
|
108
|
-
id: snapshot.id,
|
|
109
|
-
attributes: snapshot.attributes,
|
|
110
|
-
associations: snapshot.associations.merge(name => association)
|
|
111
|
-
)
|
|
112
|
-
end
|
|
113
|
-
|
|
114
104
|
#: (untyped, RecordSnapshot?) -> String
|
|
115
105
|
def replacement_type(version, replacement)
|
|
116
106
|
return replacement.type.to_s if replacement
|
|
@@ -107,15 +107,19 @@ module PaperTrailDiff
|
|
|
107
107
|
kind == :destroyed
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
-
#: () -> Hash[Symbol, untyped]
|
|
111
|
-
def to_h
|
|
112
|
-
{
|
|
110
|
+
#: (?metadata: bool) -> Hash[Symbol, untyped]
|
|
111
|
+
def to_h(metadata: false)
|
|
112
|
+
value = {
|
|
113
113
|
kind: kind,
|
|
114
114
|
version_id: version_id,
|
|
115
115
|
item_type: item_type,
|
|
116
116
|
item_id: item_id,
|
|
117
117
|
recorded_at: recorded_at
|
|
118
118
|
}
|
|
119
|
+
return value unless metadata
|
|
120
|
+
|
|
121
|
+
value.merge(event: event, whodunnit: whodunnit, transaction_id: transaction_id,
|
|
122
|
+
record: record.to_h)
|
|
119
123
|
end
|
|
120
124
|
end
|
|
121
125
|
|
|
@@ -161,13 +165,21 @@ module PaperTrailDiff
|
|
|
161
165
|
diff.empty?
|
|
162
166
|
end
|
|
163
167
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
168
|
+
# The event whose change this step reports. For a transaction group, this
|
|
169
|
+
# is the first event in the group, not necessarily its only actor or record.
|
|
170
|
+
#: () -> ActivityBoundary
|
|
171
|
+
def source_boundary = from_boundary
|
|
172
|
+
|
|
173
|
+
#: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
|
|
174
|
+
def to_h(metadata: false, snapshots: false)
|
|
175
|
+
value = {
|
|
176
|
+
from: from_boundary.to_h(metadata: metadata),
|
|
177
|
+
to: to_boundary.to_h(metadata: metadata),
|
|
169
178
|
diff: diff.to_h
|
|
170
179
|
}
|
|
180
|
+
return value unless snapshots
|
|
181
|
+
|
|
182
|
+
value.merge(from_snapshot: from_snapshot&.to_h, to_snapshot: to_snapshot&.to_h)
|
|
171
183
|
end
|
|
172
184
|
end
|
|
173
185
|
end
|
|
@@ -17,6 +17,27 @@ module PaperTrailDiff
|
|
|
17
17
|
historical_ids | current_ids
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# One shared query retains the owner and timestamp needed to partition a
|
|
21
|
+
# batch's candidates back into each root's exact activity range.
|
|
22
|
+
#: () -> Array[Array[untyped]]
|
|
23
|
+
def historical_rows
|
|
24
|
+
version_class = @parent_class.paper_trail.version_class
|
|
25
|
+
versions = version_class.arel_table
|
|
26
|
+
associations = @parent_class.paper_trail.version_association_class.arel_table
|
|
27
|
+
typed_rows(
|
|
28
|
+
candidate_scope(version_class).call(historical_relation(version_class)).distinct.pluck(
|
|
29
|
+
associations[:foreign_key_id], versions[:item_id], versions[:created_at], versions[:event]
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: () -> Array[Array[untyped]]
|
|
35
|
+
def current_pairs
|
|
36
|
+
relation = @reflection.klass.base_class.unscoped.where(@reflection.foreign_key => @parent_ids)
|
|
37
|
+
relation = relation.where(@reflection.type => parent_types) if @reflection.options[:as]
|
|
38
|
+
relation.distinct.pluck(@reflection.foreign_key, @reflection.klass.primary_key)
|
|
39
|
+
end
|
|
40
|
+
|
|
20
41
|
private
|
|
21
42
|
|
|
22
43
|
# @rbs @parent_class: untyped
|
|
@@ -24,11 +45,19 @@ module PaperTrailDiff
|
|
|
24
45
|
# @rbs @reflection: untyped
|
|
25
46
|
# @rbs @range: ActivityRange
|
|
26
47
|
|
|
48
|
+
#: (Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
49
|
+
def typed_rows(rows)
|
|
50
|
+
rows.map do |owner_id, item_id, created_at, event|
|
|
51
|
+
[owner_id, Support.record_id(@reflection.klass, item_id), created_at, event]
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
27
55
|
#: () -> Array[untyped]
|
|
28
56
|
def historical_ids
|
|
29
57
|
version_class = @parent_class.paper_trail.version_class
|
|
30
58
|
candidate_scope(version_class).call(historical_relation(version_class))
|
|
31
59
|
.distinct.pluck(version_class.arel_table[:item_id])
|
|
60
|
+
.map { |id| Support.record_id(@reflection.klass, id) }
|
|
32
61
|
end
|
|
33
62
|
|
|
34
63
|
#: (untyped) -> untyped
|
|
@@ -30,9 +30,10 @@ module PaperTrailDiff
|
|
|
30
30
|
)
|
|
31
31
|
fallback_record = [] #: Array[untyped]
|
|
32
32
|
endpoints = [root_endpoint, context_endpoint]
|
|
33
|
+
direct = routes.all? { |route| route.last.fetch(2).empty? }
|
|
33
34
|
snapshot = routes.reduce(previous) do |current, route|
|
|
34
35
|
apply_route(
|
|
35
|
-
current, route, version, delta, fallback_record, normalizer, endpoints
|
|
36
|
+
current, route, version, delta, fallback_record, normalizer, endpoints, direct: direct
|
|
36
37
|
)
|
|
37
38
|
end
|
|
38
39
|
[true, snapshot]
|
|
@@ -45,12 +46,12 @@ module PaperTrailDiff
|
|
|
45
46
|
# @rbs @route_updater: ActivityCollectionRouteUpdater
|
|
46
47
|
# @rbs @relationship: ActivityRelationship
|
|
47
48
|
|
|
48
|
-
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> RecordSnapshot
|
|
49
|
+
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> RecordSnapshot
|
|
49
50
|
def apply_route( # rubocop:disable Metrics/ParameterLists
|
|
50
|
-
snapshot, route, version, delta, fallback_record, normalizer, endpoints
|
|
51
|
+
snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: false
|
|
51
52
|
)
|
|
52
53
|
record, replacement = route_event_state(
|
|
53
|
-
snapshot, route, version, delta, fallback_record, normalizer, endpoints
|
|
54
|
+
snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: direct
|
|
54
55
|
)
|
|
55
56
|
change = ActivityCollectionRouteChange.new(
|
|
56
57
|
route: route,
|
|
@@ -81,14 +82,16 @@ module PaperTrailDiff
|
|
|
81
82
|
end
|
|
82
83
|
end
|
|
83
84
|
|
|
84
|
-
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped]) -> [untyped, RecordSnapshot?]
|
|
85
|
+
#: (RecordSnapshot, Array[untyped], untyped, ActivitySnapshotDelta?, Array[untyped], SnapshotNormalizer, Array[untyped], ?direct: bool) -> [untyped, RecordSnapshot?]
|
|
85
86
|
def route_event_state( # rubocop:disable Metrics/ParameterLists
|
|
86
|
-
snapshot, route, version, delta, fallback_record, normalizer, endpoints
|
|
87
|
+
snapshot, route, version, delta, fallback_record, normalizer, endpoints, direct: false
|
|
87
88
|
)
|
|
88
89
|
existing = existing_delta_record(snapshot, route, version, delta) if delta
|
|
89
90
|
return [delta, delta.apply(existing)] if delta && existing
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
if fallback_record.empty?
|
|
93
|
+
fallback_record << @record_resolver.record_after(version, direct: direct)
|
|
94
|
+
end
|
|
92
95
|
record = fallback_record.first
|
|
93
96
|
return [record, nil] unless record
|
|
94
97
|
|
|
@@ -28,7 +28,7 @@ module PaperTrailDiff
|
|
|
28
28
|
membership_preserved: membership_preserved
|
|
29
29
|
)
|
|
30
30
|
)
|
|
31
|
-
updated_snapshot =
|
|
31
|
+
updated_snapshot = snapshot.with_association(name, updated_association)
|
|
32
32
|
parent_path = depth.zero? ? '' : change.route.fetch(depth - 1).fetch(3)
|
|
33
33
|
updated_snapshot = @pool.record(parent_path, updated_snapshot) unless parent_path.empty?
|
|
34
34
|
[updated_snapshot, true]
|
|
@@ -114,15 +114,5 @@ module PaperTrailDiff
|
|
|
114
114
|
owner_id = @relationship.owner_id(membership_record, reflection, state: :after)
|
|
115
115
|
association.position_for_id(owner_id)
|
|
116
116
|
end
|
|
117
|
-
|
|
118
|
-
#: (RecordSnapshot, String, AssociationSnapshot) -> RecordSnapshot
|
|
119
|
-
def replace_association(snapshot, name, association)
|
|
120
|
-
RecordSnapshot.new(
|
|
121
|
-
type: snapshot.type,
|
|
122
|
-
id: snapshot.id,
|
|
123
|
-
attributes: snapshot.attributes,
|
|
124
|
-
associations: snapshot.associations.merge(name => association)
|
|
125
|
-
)
|
|
126
|
-
end
|
|
127
117
|
end
|
|
128
118
|
end
|
|
@@ -19,6 +19,10 @@ module PaperTrailDiff
|
|
|
19
19
|
root_endpoint:,
|
|
20
20
|
context_endpoint:
|
|
21
21
|
)
|
|
22
|
+
if record.is_a?(PreparedRecordState)
|
|
23
|
+
return normalizer.from_state(record, tree: subtree, path: path, incoming: reflection)
|
|
24
|
+
end
|
|
25
|
+
|
|
22
26
|
boundary = Endpoint.version?(root_endpoint) ? root_endpoint : context_endpoint
|
|
23
27
|
reifier = @association_reader.call(context_endpoint, habtm_version: boundary)
|
|
24
28
|
normalizer.call_child(
|
|
@@ -10,15 +10,39 @@ module PaperTrailDiff
|
|
|
10
10
|
}.freeze
|
|
11
11
|
private_constant :RECORD_AFTER_HANDLERS
|
|
12
12
|
|
|
13
|
-
#: (?record_transition: untyped) -> void
|
|
14
|
-
def initialize(record_transition: nil)
|
|
13
|
+
#: (?record_transition: untyped, ?prepared_record_after: untyped) -> void
|
|
14
|
+
def initialize(record_transition: nil, prepared_record_after: nil)
|
|
15
15
|
@record_transition = record_transition
|
|
16
|
+
@prepared_record_after = prepared_record_after
|
|
17
|
+
@direct_loader = DirectRecordStateLoader.new
|
|
16
18
|
end
|
|
17
19
|
|
|
18
|
-
#: (untyped) -> untyped
|
|
19
|
-
def record_after(version)
|
|
20
|
+
#: (untyped, ?direct: bool) -> untyped
|
|
21
|
+
def record_after(version, direct: false)
|
|
20
22
|
return if version.event.to_s == 'destroy'
|
|
21
23
|
|
|
24
|
+
if direct
|
|
25
|
+
handled, state = direct_after(version)
|
|
26
|
+
return state if handled
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
handled, prepared = @prepared_record_after&.call(version)
|
|
30
|
+
return prepared if handled
|
|
31
|
+
|
|
32
|
+
fallback_record_after(version)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
#: (untyped) -> [bool, untyped]
|
|
36
|
+
def direct_after(version)
|
|
37
|
+
handled, state = @prepared_record_after&.call(version, direct: true)
|
|
38
|
+
return [true, state] if handled
|
|
39
|
+
|
|
40
|
+
state = direct_record_after(version)
|
|
41
|
+
[!state.nil?, state]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#: (untyped) -> untyped
|
|
45
|
+
def fallback_record_after(version)
|
|
22
46
|
changed_record = changed_record_after(version)
|
|
23
47
|
return changed_record if changed_record
|
|
24
48
|
|
|
@@ -70,18 +94,54 @@ module PaperTrailDiff
|
|
|
70
94
|
# +version.item+, which would issue one live-record query per event.
|
|
71
95
|
#: (untyped, untyped) -> untyped
|
|
72
96
|
def deserialized_changeset(version, model_class)
|
|
73
|
-
return unless model_class
|
|
97
|
+
return unless model_class
|
|
98
|
+
unless version.class.column_names.include?('object_changes')
|
|
99
|
+
return prepared_changeset(version)
|
|
100
|
+
end
|
|
74
101
|
|
|
75
102
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
76
103
|
adapter = paper_trail.config.object_changes_adapter
|
|
77
104
|
return adapter.load_changeset(version) if adapter.respond_to?(:load_changeset)
|
|
78
105
|
|
|
79
|
-
standard_changeset(paper_trail, version, model_class)
|
|
106
|
+
changes = standard_changeset(paper_trail, version, model_class)
|
|
107
|
+
changes.empty? ? prepared_changeset(version) : changes
|
|
80
108
|
end
|
|
81
109
|
|
|
82
110
|
private
|
|
83
111
|
|
|
84
112
|
# @rbs @record_transition: untyped
|
|
113
|
+
# @rbs @prepared_record_after: untyped
|
|
114
|
+
# @rbs @direct_loader: DirectRecordStateLoader
|
|
115
|
+
|
|
116
|
+
#: (untyped) -> PreparedRecordState?
|
|
117
|
+
def direct_record_after(version)
|
|
118
|
+
model = Endpoint.model_class(version)
|
|
119
|
+
changes = deserialized_changeset(version, model)
|
|
120
|
+
return unless changes.respond_to?(:each) && !changes.empty?
|
|
121
|
+
|
|
122
|
+
before = if version.object
|
|
123
|
+
@direct_loader.call(version)
|
|
124
|
+
else
|
|
125
|
+
PreparedRecordState.from_attributes(
|
|
126
|
+
model, {}
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
return unless before
|
|
130
|
+
|
|
131
|
+
attributes = before.attributes.merge(after_attributes(changes, model))
|
|
132
|
+
@direct_loader.complete(PreparedRecordState.from_attributes(model, attributes))
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
#: (untyped) -> untyped
|
|
136
|
+
def prepared_changeset(version)
|
|
137
|
+
transition = @record_transition&.call(version)
|
|
138
|
+
return {} unless transition
|
|
139
|
+
|
|
140
|
+
before, after = transition
|
|
141
|
+
(before.keys | after.keys).filter_map do |name|
|
|
142
|
+
[name, [before[name], after[name]]] unless before[name] == after[name]
|
|
143
|
+
end.to_h
|
|
144
|
+
end
|
|
85
145
|
|
|
86
146
|
#: (untyped, untyped) -> Hash[untyped, untyped]
|
|
87
147
|
def after_attributes(changes, model_class)
|
|
@@ -4,10 +4,12 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Dispatches safe descendant events to focused immutable snapshot appliers.
|
|
6
6
|
class ActivityEventSnapshotRefresher
|
|
7
|
-
#: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped) -> void
|
|
8
|
-
def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil)
|
|
7
|
+
#: (traversal: AssociationTraversal, pool: SnapshotPool, components: untyped, ?association_reader: untyped, ?record_transition: untyped, ?prepared_record_after: untyped) -> void
|
|
8
|
+
def initialize(traversal:, pool:, components:, association_reader: nil, record_transition: nil, prepared_record_after: nil) # rubocop:disable Layout/LineLength, Metrics/ParameterLists
|
|
9
9
|
@components = components
|
|
10
|
-
@record_resolver = ActivityEventRecordResolver.new(
|
|
10
|
+
@record_resolver = ActivityEventRecordResolver.new(
|
|
11
|
+
record_transition: record_transition, prepared_record_after: prepared_record_after
|
|
12
|
+
)
|
|
11
13
|
@route_finder = ActivityEventRouteFinder.new(traversal)
|
|
12
14
|
@relationship = ActivityRelationship.new
|
|
13
15
|
configure_appliers(pool, association_reader)
|
|
@@ -99,7 +101,7 @@ module PaperTrailDiff
|
|
|
99
101
|
|
|
100
102
|
#: (untyped) -> untyped
|
|
101
103
|
def record_after(version)
|
|
102
|
-
@record_resolver.record_after(version)
|
|
104
|
+
@record_resolver.record_after(version, direct: true)
|
|
103
105
|
end
|
|
104
106
|
|
|
105
107
|
#: (untyped, untyped) -> untyped
|
|
@@ -7,6 +7,18 @@ module PaperTrailDiff
|
|
|
7
7
|
# window -- but a transaction has to collapse the same way in both, or the
|
|
8
8
|
# same history would read differently depending on how it was asked for.
|
|
9
9
|
module ActivityGrouping
|
|
10
|
+
#: (Symbol?, ?activity: bool, ?snapshots: bool) -> void
|
|
11
|
+
def self.validate!(group, activity: true, snapshots: false)
|
|
12
|
+
unless group.nil? || group == :transaction
|
|
13
|
+
raise ConfigurationError, 'group: must be :transaction or nil'
|
|
14
|
+
end
|
|
15
|
+
raise ConfigurationError, 'snapshots: requires activity: true' if snapshots && !activity
|
|
16
|
+
|
|
17
|
+
return if group.nil? || activity
|
|
18
|
+
|
|
19
|
+
raise ConfigurationError, 'group: requires activity: true'
|
|
20
|
+
end
|
|
21
|
+
|
|
10
22
|
private
|
|
11
23
|
|
|
12
24
|
#: () -> bool
|
|
@@ -8,13 +8,17 @@ module PaperTrailDiff
|
|
|
8
8
|
attr_reader :root_snapshots #: Hash[Array[untyped], RecordSnapshot?]
|
|
9
9
|
attr_reader :first_snapshot #: RecordSnapshot?
|
|
10
10
|
attr_reader :last_snapshot #: RecordSnapshot?
|
|
11
|
+
attr_reader :last_boundary #: ActivityBoundary?
|
|
12
|
+
attr_reader :last_event_snapshot #: RecordSnapshot?
|
|
11
13
|
|
|
12
|
-
#: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?) -> void
|
|
13
|
-
def initialize(steps:, root_snapshots:, first_snapshot:, last_snapshot:)
|
|
14
|
+
#: (steps: Array[ActivityStep], root_snapshots: Hash[Array[untyped], RecordSnapshot?], first_snapshot: RecordSnapshot?, last_snapshot: RecordSnapshot?, ?last_boundary: ActivityBoundary?, ?last_event_snapshot: RecordSnapshot?) -> void
|
|
15
|
+
def initialize(steps:, root_snapshots:, first_snapshot:, last_snapshot:, last_boundary: nil, last_event_snapshot: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
14
16
|
@steps = steps.freeze
|
|
15
17
|
@root_snapshots = root_snapshots.freeze
|
|
16
18
|
@first_snapshot = first_snapshot
|
|
17
19
|
@last_snapshot = last_snapshot
|
|
20
|
+
@last_boundary = last_boundary
|
|
21
|
+
@last_event_snapshot = last_event_snapshot
|
|
18
22
|
freeze
|
|
19
23
|
end
|
|
20
24
|
|
|
@@ -46,7 +50,9 @@ module PaperTrailDiff
|
|
|
46
50
|
steps: @steps,
|
|
47
51
|
root_snapshots: @root_snapshots,
|
|
48
52
|
first_snapshot: @first_snapshot,
|
|
49
|
-
last_snapshot: final_snapshot
|
|
53
|
+
last_snapshot: final_snapshot,
|
|
54
|
+
last_boundary: @previous_boundary,
|
|
55
|
+
last_event_snapshot: @previous_snapshot
|
|
50
56
|
)
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -36,6 +36,8 @@ module PaperTrailDiff
|
|
|
36
36
|
|
|
37
37
|
#: (untyped, untyped, state: Symbol) -> untyped
|
|
38
38
|
def event_attribute(record, name, state:)
|
|
39
|
+
return record.attributes[name.to_s] if record.is_a?(PreparedRecordState)
|
|
40
|
+
|
|
39
41
|
if record.is_a?(ActivitySnapshotDelta)
|
|
40
42
|
return state == :before ? record.before_value(name) : record.after_value(name)
|
|
41
43
|
end
|
|
@@ -38,6 +38,7 @@ module PaperTrailDiff
|
|
|
38
38
|
|
|
39
39
|
changes = @changeset.call(version, model_class)
|
|
40
40
|
return [false, nil] unless changes.respond_to?(:each)
|
|
41
|
+
return [false, nil] if changes.empty?
|
|
41
42
|
|
|
42
43
|
branches = branches_to_refresh(model_class, changes)
|
|
43
44
|
partial = nil #: RecordSnapshot?
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Optional full/partial snapshot operations consumed by the activity sequence.
|
|
6
6
|
class ActivitySnapshotProvider
|
|
7
|
-
#: (snapshotter: untyped, refresher: untyped, preparer: untyped) -> void
|
|
8
|
-
def initialize(snapshotter:, refresher:, preparer:)
|
|
7
|
+
#: (snapshotter: untyped, refresher: untyped, preparer: untyped, ?activity_index: untyped) -> void
|
|
8
|
+
def initialize(snapshotter:, refresher:, preparer:, activity_index: nil)
|
|
9
|
+
@activity_index = activity_index
|
|
9
10
|
@snapshotter = snapshotter
|
|
10
11
|
@refresher = refresher
|
|
11
12
|
@preparer = preparer
|
|
@@ -55,6 +56,12 @@ module PaperTrailDiff
|
|
|
55
56
|
)
|
|
56
57
|
end
|
|
57
58
|
|
|
59
|
+
#: () -> PreparedActivityIndex?
|
|
60
|
+
def activity_index
|
|
61
|
+
@activity_index&.call
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @rbs @activity_index: untyped
|
|
58
65
|
# @rbs @snapshotter: untyped
|
|
59
66
|
# @rbs @refresher: untyped
|
|
60
67
|
# @rbs @preparer: untyped
|