paper_trail_diff 0.12.0 → 0.13.1

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.
Files changed (134) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +158 -0
  3. data/QUICKSTART.md +149 -79
  4. data/README.md +179 -1388
  5. data/docs/architecture.md +88 -0
  6. data/docs/performance.md +309 -0
  7. data/docs/reference.md +977 -0
  8. data/docs/testing.md +169 -0
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
  10. data/lib/paper_trail_diff/activity_boundary.rb +20 -8
  11. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
  12. data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
  13. data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
  14. data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
  15. data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
  16. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
  17. data/lib/paper_trail_diff/activity_grouping.rb +12 -0
  18. data/lib/paper_trail_diff/activity_history.rb +9 -3
  19. data/lib/paper_trail_diff/activity_relationship.rb +2 -0
  20. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
  21. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
  22. data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
  23. data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
  24. data/lib/paper_trail_diff/analysis.rb +9 -4
  25. data/lib/paper_trail_diff/analysis_batch.rb +28 -7
  26. data/lib/paper_trail_diff/association_traversal.rb +23 -0
  27. data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
  28. data/lib/paper_trail_diff/batch_safety.rb +55 -0
  29. data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
  30. data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
  31. data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
  32. data/lib/paper_trail_diff/collection_comparator.rb +18 -20
  33. data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
  34. data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
  35. data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
  36. data/lib/paper_trail_diff/endpoint.rb +10 -0
  37. data/lib/paper_trail_diff/engine.rb +4 -2
  38. data/lib/paper_trail_diff/errors.rb +9 -0
  39. data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
  40. data/lib/paper_trail_diff/historical_filter.rb +176 -0
  41. data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
  42. data/lib/paper_trail_diff/instrumentation.rb +2 -2
  43. data/lib/paper_trail_diff/nested_comparator.rb +39 -15
  44. data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
  45. data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
  46. data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
  47. data/lib/paper_trail_diff/prepared_history.rb +21 -30
  48. data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
  49. data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
  50. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
  51. data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
  52. data/lib/paper_trail_diff/root_version_plan.rb +35 -3
  53. data/lib/paper_trail_diff/root_version_selection.rb +16 -5
  54. data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
  55. data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
  56. data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
  57. data/lib/paper_trail_diff/snapshot.rb +14 -1
  58. data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
  59. data/lib/paper_trail_diff/step.rb +10 -5
  60. data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
  61. data/lib/paper_trail_diff/support.rb +13 -3
  62. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
  63. data/lib/paper_trail_diff/time_range.rb +21 -2
  64. data/lib/paper_trail_diff/time_version_range.rb +2 -16
  65. data/lib/paper_trail_diff/timeline_builder.rb +5 -4
  66. data/lib/paper_trail_diff/timeline_range.rb +29 -46
  67. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
  68. data/lib/paper_trail_diff/traversal.rb +1 -1
  69. data/lib/paper_trail_diff/version.rb +1 -1
  70. data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
  71. data/lib/paper_trail_diff/version_boundary.rb +48 -0
  72. data/lib/paper_trail_diff/version_query.rb +41 -0
  73. data/lib/paper_trail_diff/version_range.rb +10 -38
  74. data/lib/paper_trail_diff.rb +78 -42
  75. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
  76. data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
  77. data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
  78. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
  79. data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
  80. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
  81. data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
  82. data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
  83. data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
  84. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
  85. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
  86. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
  87. data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
  88. data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
  90. data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
  91. data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
  92. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
  93. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
  94. data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
  95. data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
  96. data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
  97. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
  98. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
  99. data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
  100. data/sig/generated/paper_trail_diff/engine.rbs +4 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +12 -0
  102. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
  105. data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
  106. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
  107. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
  108. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
  110. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
  111. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
  112. data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
  113. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
  114. data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
  115. data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
  116. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
  117. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
  118. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
  119. data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
  120. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
  121. data/sig/generated/paper_trail_diff/step.rbs +8 -2
  122. data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
  123. data/sig/generated/paper_trail_diff/support.rbs +7 -0
  124. data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
  125. data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
  126. data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
  127. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  128. data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
  129. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
  130. data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
  131. data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
  132. data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
  133. data/sig/generated/paper_trail_diff.rbs +31 -25
  134. metadata +31 -5
data/docs/testing.md ADDED
@@ -0,0 +1,169 @@
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 library one expression at
43
+ a time and expects a spec to fail each time. A change that every spec still
44
+ passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
45
+
46
+ - what counts as a change: `Engine`, `CollectionComparator`,
47
+ `CollectionIdentityIndex`, and `NestedComparator`;
48
+ - which columns are compared: `SnapshotNormalizer` and `SnapshotPool`;
49
+ - which history a range or filter reads: `TimelineRange`, `RootVersionPlan`,
50
+ and `HistoricalFilter`.
51
+
52
+ The specs for the classes that read the database use tables of their own
53
+ (`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb`), so
54
+ they run in both the core and the association suite.
55
+
56
+ Mutant does not run every spec for every mutation. It runs the specs whose
57
+ description names the mutated class (`RSpec.describe PaperTrailDiff::Engine`),
58
+ and only falls back to wider names such as `PaperTrailDiff` when none does. A
59
+ spec named after a behavior, as most integration specs are, never runs. So
60
+ each mutated class has a unit spec of its own that catches every mutation in
61
+ it, and a class added to `mutant.yml` needs one too.
62
+
63
+ Mutant counts a mutation that runs past its time limit as caught, so
64
+ `mutant.yml` sets the limit well above the time the whole suite takes. With
65
+ mutant's 5-second default, mutations no spec caught were reported as caught.
66
+
67
+ In CI, a pull request mutates only the methods it changes. The full set runs
68
+ nightly in `.github/workflows/mutation.yml`, and only when `main` has a commit
69
+ that has not passed yet. Start it by hand from the Actions tab with "Run
70
+ workflow".
71
+
72
+ Mutant needs Ruby 3.3 or newer, so it has its own bundle:
73
+
74
+ ```console
75
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle install
76
+ mise exec -- bundle exec rake mutant
77
+ ```
78
+
79
+ Pass a subject to mutate one class, and `--since main` to mutate only what a
80
+ branch changed:
81
+
82
+ ```console
83
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run 'PaperTrailDiff::Engine*'
84
+ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --since main
85
+ ```
86
+
87
+ When a mutation survives, either add the spec that would catch it, or, if the
88
+ mutated code means the same thing, simplify the code to that form. Reserve a
89
+ `mutant:disable` comment for a method whose surviving mutations all mean the
90
+ same thing as the code, and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
91
+ gives each worker its own copy of the SQLite fixture database.
92
+
93
+ ## Test clock
94
+
95
+ The specs build time windows from `Time.now` around versions they have just
96
+ written, so they need a clock that never runs backwards. Virtualized hosts,
97
+ WSL2 in particular, step the wall clock back by hundreds of milliseconds when
98
+ they resynchronize, which made window-based specs fail at random.
99
+ `spec/support/monotonic_clock.rb` therefore makes `Time.now` strictly
100
+ increasing, in whole microseconds, for the test process. ActiveRecord
101
+ timestamps, PaperTrail's `created_at`, and windows built from `Time.now` all
102
+ read it, so they stay consistent however the host clock moves. It affects only
103
+ the test process; the gem itself reads the ordinary clock.
104
+
105
+ ## Other databases locally
106
+
107
+ Install the adapter's client development libraries first. On Debian/Ubuntu,
108
+ the packages are `libpq-dev` for PostgreSQL, `default-libmysqlclient-dev` for
109
+ MySQL, and `freetds-dev` for SQL Server. The optional development group includes
110
+ all three adapters:
111
+
112
+ ```console
113
+ BUNDLE_WITH=database_adapters mise exec -- bundle install
114
+ ```
115
+
116
+ Create a dedicated disposable database named `paper_trail_diff_test`. The suites
117
+ recreate their fixture tables. Set `PAPER_TRAIL_DIFF_DATABASE_URL` to its
118
+ connection URL and run both suites:
119
+
120
+ ```console
121
+ 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
122
+ 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
123
+ 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
124
+ ```
125
+
126
+ These credentials are examples for disposable local instances. SQL Server's
127
+ adapter version follows ActiveRecord's version; Bundler selects a compatible
128
+ adapter. No database driver is a runtime dependency of the gem.
129
+
130
+ SQL Server's adapter wraps datetime2 values during serialization. With
131
+ PaperTrail's default YAML serializer, the fixtures explicitly permit
132
+ `ActiveRecord::ConnectionAdapters::SQLServer::Type::Data` and
133
+ `ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTime2`, in addition to
134
+ `Symbol` and `Time`. Applications using that serializer need the corresponding
135
+ `ActiveRecord.yaml_column_permitted_classes` configuration. The gem does not
136
+ change an application's YAML configuration.
137
+
138
+ The CI service configuration is in `.github/workflows/ci.yml`. PostgreSQL and
139
+ MySQL create the named database during container initialization. The SQL Server
140
+ jobs create it explicitly after the service passes its health check.
141
+
142
+ ## Generated signatures
143
+
144
+ Inline RBS comments in `lib/` are the source of truth:
145
+
146
+ ```console
147
+ mise exec -- bundle exec rake rbs
148
+ mise exec -- bundle exec rake typecheck
149
+ ```
150
+
151
+ The typecheck task regenerates signatures and compares them with Git's index
152
+ before running Steep. Unstaged generated changes therefore fail the gate even
153
+ when they are correct. Review and stage matching source/signature changes before
154
+ running the complete gate. The Steep wrapper also fails on internal errors even
155
+ if Steep returns exit status zero.
156
+
157
+ ## Releasing
158
+
159
+ Tags matching `v*` publish through RubyGems trusted publishing. Never add a
160
+ long-lived RubyGems API key. Before tagging:
161
+
162
+ 1. Update `PaperTrailDiff::VERSION` and replace `Unreleased` in the changelog
163
+ with that version and the release date.
164
+ 2. Run `mise exec -- bundle exec rake release:preflight`. It runs the full gate,
165
+ checks that the tree is clean and pushed, that the Appraisal gemfiles are
166
+ current, and that the changelog names the version, then builds the gem. It
167
+ does not tag, push, or publish.
168
+ 3. Configure the RubyGems trusted publisher for `release.yml` and the `release`
169
+ 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
- [replace_association(snapshot, name, updated_association), true]
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
- #: () -> Hash[Symbol, untyped]
165
- def to_h
166
- {
167
- from: from_boundary.to_h,
168
- to: to_boundary.to_h,
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
- fallback_record << @record_resolver.record_after(version) if fallback_record.empty?
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 = replace_association(snapshot, name, updated_association)
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 && version.class.column_names.include?('object_changes')
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(record_transition: record_transition)
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