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.
Files changed (137) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +343 -0
  3. data/QUICKSTART.md +21 -4
  4. data/README.md +58 -55
  5. data/docs/architecture.md +32 -11
  6. data/docs/performance.md +116 -391
  7. data/docs/reference.md +345 -106
  8. data/docs/testing.md +63 -7
  9. data/lib/paper_trail_diff/activity_boundary.rb +83 -26
  10. data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
  11. data/lib/paper_trail_diff/activity_event.rb +41 -10
  12. data/lib/paper_trail_diff/activity_history.rb +34 -9
  13. data/lib/paper_trail_diff/activity_range.rb +18 -3
  14. data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
  15. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
  16. data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
  17. data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
  18. data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
  19. data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
  20. data/lib/paper_trail_diff/activity_view.rb +69 -0
  21. data/lib/paper_trail_diff/analysis.rb +13 -1
  22. data/lib/paper_trail_diff/analysis_batch.rb +24 -30
  23. data/lib/paper_trail_diff/approximate_paths.rb +48 -0
  24. data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
  25. data/lib/paper_trail_diff/association_discovery.rb +9 -1
  26. data/lib/paper_trail_diff/association_key.rb +56 -0
  27. data/lib/paper_trail_diff/association_traversal.rb +13 -4
  28. data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
  29. data/lib/paper_trail_diff/batch_preload.rb +53 -0
  30. data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
  31. data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
  32. data/lib/paper_trail_diff/change_row.rb +133 -0
  33. data/lib/paper_trail_diff/collection_comparator.rb +5 -32
  34. data/lib/paper_trail_diff/configuration.rb +35 -12
  35. data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
  36. data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
  37. data/lib/paper_trail_diff/deprecation.rb +61 -0
  38. data/lib/paper_trail_diff/diagnostics.rb +84 -99
  39. data/lib/paper_trail_diff/endpoint.rb +26 -3
  40. data/lib/paper_trail_diff/errors.rb +3 -1
  41. data/lib/paper_trail_diff/fallback_paths.rb +38 -0
  42. data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
  43. data/lib/paper_trail_diff/historical_filter.rb +19 -10
  44. data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
  45. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
  46. data/lib/paper_trail_diff/nested_comparator.rb +19 -18
  47. data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
  48. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
  49. data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
  50. data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
  51. data/lib/paper_trail_diff/prepared_history.rb +9 -1
  52. data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
  53. data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
  54. data/lib/paper_trail_diff/root_version_selection.rb +4 -2
  55. data/lib/paper_trail_diff/scope_condition.rb +76 -21
  56. data/lib/paper_trail_diff/scope_ranking.rb +12 -2
  57. data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
  58. data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
  59. data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
  60. data/lib/paper_trail_diff/snapshot.rb +16 -1
  61. data/lib/paper_trail_diff/support.rb +29 -2
  62. data/lib/paper_trail_diff/time_range.rb +32 -10
  63. data/lib/paper_trail_diff/timeline_builder.rb +7 -7
  64. data/lib/paper_trail_diff/timeline_range.rb +9 -1
  65. data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
  66. data/lib/paper_trail_diff/transaction_cut.rb +181 -0
  67. data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
  68. data/lib/paper_trail_diff/version.rb +1 -1
  69. data/lib/paper_trail_diff/version_boundary.rb +37 -8
  70. data/lib/paper_trail_diff/version_query.rb +34 -10
  71. data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
  72. data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
  73. data/lib/paper_trail_diff/version_table.rb +60 -0
  74. data/lib/paper_trail_diff.rb +98 -56
  75. data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
  76. data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
  77. data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
  78. data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
  79. data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
  80. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
  81. data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
  82. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
  83. data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
  84. data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
  85. data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
  86. data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
  87. data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
  89. data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
  90. data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
  91. data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
  92. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
  93. data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
  94. data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
  95. data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
  96. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
  97. data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
  98. data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
  99. data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
  100. data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
  101. data/sig/generated/paper_trail_diff/errors.rbs +3 -1
  102. data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
  103. data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
  104. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
  105. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
  106. data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
  107. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
  108. data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
  109. data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
  110. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
  111. data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
  112. data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
  113. data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
  114. data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
  115. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
  116. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
  117. data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
  118. data/sig/generated/paper_trail_diff/support.rbs +15 -0
  119. data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
  120. data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
  121. data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
  122. data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
  123. data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
  124. data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
  125. data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
  126. data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
  127. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
  128. data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
  129. data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
  130. data/sig/generated/paper_trail_diff.rbs +29 -26
  131. metadata +46 -10
  132. data/lib/paper_trail_diff/activity_grouping.rb +0 -38
  133. data/lib/paper_trail_diff/batch_safety.rb +0 -63
  134. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
  135. data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
  136. data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
  137. 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`, and `AssociationActivityEdge`;
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 a batch refuses: `BatchSafety`;
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. It affects only
126
- the test process; the gem itself reads the ordinary clock.
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
- #: (untyped) -> ActivityBoundary
22
- def from_version(version)
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
- #: (untyped, captured_at: untyped) -> ActivityBoundary
36
- def current(record, captured_at:)
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
- # A custom version class need not carry the column, and PaperTrail leaves
64
- # it nil outside a transaction. Both read as "no transaction here", which
65
- # groups with nothing.
66
- #: (untyped) -> untyped
67
- def transaction_id(version)
68
- version.transaction_id if version.respond_to?(:transaction_id)
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
- #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
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
- record: record.to_h)
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.joins(:version).where(
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
- version = event.version
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
- #: (Array[untyped], Array[ActivityEvent], untyped, ?current: untyped, ?include_step: untyped, ?snapshots: bool) -> void
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, snapshots: false
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
- @snapshots = snapshots
46
+ @closing = closing
43
47
  end
44
48
 
45
49
  #: () -> ActivityHistory
46
50
  def call
47
51
  reset
48
- sequence.each { |event, snapshot| consume(event, snapshot) }
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 @snapshots: bool
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
- #: (ActivityEvent, RecordSnapshot?) -> void
98
- def consume(event, snapshot)
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
- boundary = ActivityBoundary.from_version(event.version)
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: @snapshots
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. Exact tie-breaking
30
- # remains in #include? so this works for integer, UUID, and custom version IDs.
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