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
@@ -4,13 +4,13 @@
4
4
  module PaperTrailDiff
5
5
  # Checks the recorded sequence itself, before any question of association
6
6
  # setup: whether the versions can be ordered at all, and whether timestamps
7
- # they share hide changes that ordering alone cannot recover.
7
+ # they share hide changes from a path PT-AT reads.
8
8
  class VersionSequenceDiagnostics
9
- #: (untyped, untyped, ?associations_selected: bool) -> void
10
- def initialize(from_version, to_version, associations_selected: false)
9
+ #: (untyped, untyped, ?fallback_selected: bool) -> void
10
+ def initialize(from_version, to_version, fallback_selected: false)
11
11
  @from_version = from_version
12
12
  @to_version = to_version
13
- @associations_selected = associations_selected
13
+ @fallback_selected = fallback_selected
14
14
  end
15
15
 
16
16
  #: () -> Array[DiagnosticIssue]
@@ -18,7 +18,7 @@ module PaperTrailDiff
18
18
  versions = ordered_range_versions
19
19
  issues = [] #: Array[DiagnosticIssue?]
20
20
  issues << unorderable(versions)
21
- issues << tied_timestamps(versions) if @associations_selected
21
+ issues << tied_timestamps(versions) if @fallback_selected
22
22
  issues.compact
23
23
  rescue StandardError
24
24
  []
@@ -28,7 +28,7 @@ module PaperTrailDiff
28
28
 
29
29
  # @rbs @from_version: untyped
30
30
  # @rbs @to_version: untyped
31
- # @rbs @associations_selected: bool
31
+ # @rbs @fallback_selected: bool
32
32
 
33
33
  # Ordering falls back to the id when timestamps tie, which only recovers the
34
34
  # real sequence for ids that increase with insertion. Reported before a run
@@ -46,11 +46,12 @@ module PaperTrailDiff
46
46
  )
47
47
  end
48
48
 
49
- # Association membership is recorded per version but resolved by timestamp,
50
- # so a tie hides any association change across that pair. Unlike an
51
- # unorderable sequence this is not always wrong: if nothing associated
52
- # changed between them the result is correct, and the gem cannot tell which
53
- # it is, because not seeing the change is the symptom. So it warns.
49
+ # Loaded history orders versions that share a timestamp by id, but PT-AT
50
+ # places association changes by timestamp alone, so a path it reads loses
51
+ # any change between such a pair. Unlike an unorderable sequence this is
52
+ # not always wrong: if nothing that path reaches changed between them the
53
+ # result is correct, and the gem cannot tell which it is, because not
54
+ # seeing the change is the symptom. So it warns.
54
55
  #: (Array[untyped]) -> DiagnosticIssue?
55
56
  def tied_timestamps(versions)
56
57
  pair = Support.tied_timestamp_pair(versions)
@@ -61,22 +62,36 @@ module PaperTrailDiff
61
62
  severity: :warning,
62
63
  code: :tied_version_timestamps,
63
64
  message: "versions #{left.id.inspect} and #{right.id.inspect} share the timestamp " \
64
- "#{left.created_at.inspect}, so association changes between them cannot " \
65
- 'be detected; record versions at sub-second precision to separate them',
65
+ "#{left.created_at.inspect}, and an association requested here is read with " \
66
+ 'PT-AT, which places association changes by timestamp alone, so a change ' \
67
+ 'between them can be missed; record versions at sub-second precision to ' \
68
+ 'separate them',
66
69
  version_id: left.id
67
70
  )
68
71
  end
69
72
 
70
73
  #: () -> Array[untyped]
71
74
  def ordered_range_versions
72
- bounds = [@from_version.created_at, @to_version.created_at].compact.sort
73
- return [] unless bounds.length == 2
75
+ range = time_range
76
+ return [] unless range
74
77
 
75
78
  @from_version.class
76
79
  .where(item_type: @from_version.item_type, item_id: @from_version.item_id)
77
- .where(created_at: bounds.first..bounds.last)
80
+ .where(created_at: range)
78
81
  .to_a
79
82
  .sort_by { |version| Support.chronological_version_key(version) }
80
83
  end
84
+
85
+ # Between the two versions, in either order, or from the one version on
86
+ # when the other endpoint is the current record. A version that recorded
87
+ # no time bounds nothing.
88
+ #: () -> Range[untyped]?
89
+ def time_range
90
+ from = @from_version.created_at
91
+ return from && (from..) unless @to_version
92
+
93
+ bounds = [from, @to_version.created_at].compact.sort
94
+ bounds.first..bounds.last if bounds.length == 2
95
+ end
81
96
  end
82
97
  end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # PT-AT reads an association's history from its owner's version tables: it
6
+ # looks for a child's versions in the owner's version table, and numbers a
7
+ # transaction by the first version saved in it, in whichever table. A model
8
+ # whose versions, or version associations, live in another table than its
9
+ # owner's would read other records' versions or none, so it is refused.
10
+ module VersionTable
11
+ # Why the association, or a part of a through, cannot be read across
12
+ # version tables.
13
+ #: (untyped) -> String?
14
+ def self.unsupported_reason(reflection)
15
+ part = separate_part(reflection)
16
+ return unless part
17
+
18
+ "#{part.active_record.name}##{part.name} keeps versions in #{tables(part.active_record)} " \
19
+ "but #{part.klass.name} in #{tables(part.klass)}, and association history is read " \
20
+ 'from one set of version tables'
21
+ end
22
+
23
+ # The reflection, or the first part of a through, whose ends keep versions
24
+ # in different tables. A polymorphic belongs_to names no single target,
25
+ # and an unversioned target has no history to compare.
26
+ #: (untyped) -> untyped
27
+ def self.separate_part(reflection)
28
+ if reflection.through_reflection?
29
+ return [reflection.through_reflection, reflection.source_reflection].filter_map do |part|
30
+ separate_part(part)
31
+ end.first
32
+ end
33
+ return if reflection.polymorphic?
34
+
35
+ owner = reflection.active_record
36
+ target = reflection.klass
37
+ return unless Support.versioned?(owner) && Support.versioned?(target)
38
+
39
+ reflection unless tables(owner) == tables(target)
40
+ end
41
+ private_class_method :separate_part
42
+
43
+ # PT-AT's version association rows joined to the versions they were saved
44
+ # with. Its `belongs_to :version` names PaperTrail's default class whatever
45
+ # table a model's versions are in, so the join names the table instead.
46
+ #: (untyped, untyped) -> untyped
47
+ def self.joined(associations, versions)
48
+ rows = associations.arel_table
49
+ table = versions.arel_table
50
+ associations.joins(rows.join(table).on(table[:id].eq(rows[:version_id])).join_sources)
51
+ end
52
+
53
+ #: (untyped) -> String
54
+ def self.tables(model)
55
+ history = model.paper_trail
56
+ "#{history.version_class.table_name} and #{history.version_association_class.table_name}"
57
+ end
58
+ private_class_method :tables
59
+ end
60
+ end
@@ -9,14 +9,18 @@ require_relative 'paper_trail_diff/version_query'
9
9
  require_relative 'paper_trail_diff/stored_time_precision'
10
10
  require_relative 'paper_trail_diff/version_boundary'
11
11
  require_relative 'paper_trail_diff/instrumentation'
12
+ require_relative 'paper_trail_diff/deprecation'
12
13
  require_relative 'paper_trail_diff/errors'
13
14
  require_relative 'paper_trail_diff/configuration'
14
15
  require_relative 'paper_trail_diff/endpoint'
15
16
  require_relative 'paper_trail_diff/recorded_class'
17
+ require_relative 'paper_trail_diff/association_key'
18
+ require_relative 'paper_trail_diff/version_table'
16
19
  require_relative 'paper_trail_diff/association_traversal'
17
20
  require_relative 'paper_trail_diff/scope_ranking'
18
21
  require_relative 'paper_trail_diff/scope_condition'
19
- require_relative 'paper_trail_diff/batch_safety'
22
+ require_relative 'paper_trail_diff/approximate_paths'
23
+ require_relative 'paper_trail_diff/fallback_paths'
20
24
  require_relative 'paper_trail_diff/traversal_preparer'
21
25
  require_relative 'paper_trail_diff/association_discovery'
22
26
  require_relative 'paper_trail_diff/diagnostics'
@@ -30,6 +34,7 @@ require_relative 'paper_trail_diff/traversal_emitter'
30
34
  require_relative 'paper_trail_diff/snapshot_traversal'
31
35
  require_relative 'paper_trail_diff/association_diff_traversal'
32
36
  require_relative 'paper_trail_diff/traversal'
37
+ require_relative 'paper_trail_diff/change_row'
33
38
  require_relative 'paper_trail_diff/collection_comparator'
34
39
  require_relative 'paper_trail_diff/nested_comparator'
35
40
  require_relative 'paper_trail_diff/engine'
@@ -46,6 +51,7 @@ require_relative 'paper_trail_diff/prepared_edge_loader'
46
51
  require_relative 'paper_trail_diff/prepared_history_loader'
47
52
  require_relative 'paper_trail_diff/prepared_association_reifier'
48
53
  require_relative 'paper_trail_diff/live_association_reader'
54
+ require_relative 'paper_trail_diff/batch_preload'
49
55
  require_relative 'paper_trail_diff/live_endpoint_batch_loader'
50
56
  require_relative 'paper_trail_diff/preloaded_endpoint_batch_loader'
51
57
  require_relative 'paper_trail_diff/live_endpoint_provider'
@@ -73,7 +79,7 @@ require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
73
79
  require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
74
80
  require_relative 'paper_trail_diff/branch_snapshot_refresher'
75
81
  require_relative 'paper_trail_diff/activity_boundary'
76
- require_relative 'paper_trail_diff/activity_grouping'
82
+ require_relative 'paper_trail_diff/activity_view'
77
83
  require_relative 'paper_trail_diff/activity_transaction_grouper'
78
84
  require_relative 'paper_trail_diff/step'
79
85
  require_relative 'paper_trail_diff/analysis'
@@ -85,11 +91,13 @@ require_relative 'paper_trail_diff/historical_filter'
85
91
  require_relative 'paper_trail_diff/association_activity_edge'
86
92
  require_relative 'paper_trail_diff/association_activity_selection'
87
93
  require_relative 'paper_trail_diff/scoped_root_selection'
94
+ require_relative 'paper_trail_diff/deleted_root_filter'
88
95
  require_relative 'paper_trail_diff/scoped_analysis_builder'
89
96
  require_relative 'paper_trail_diff/batched_root_analyzer'
90
97
  require_relative 'paper_trail_diff/version_range'
91
98
  require_relative 'paper_trail_diff/version_sequence_diagnostics'
92
99
  require_relative 'paper_trail_diff/time_range'
100
+ require_relative 'paper_trail_diff/transaction_cut'
93
101
  require_relative 'paper_trail_diff/time_version_range'
94
102
  require_relative 'paper_trail_diff/timeline_range'
95
103
  require_relative 'paper_trail_diff/timeline_builder'
@@ -99,8 +107,8 @@ require_relative 'paper_trail_diff/activity_child_candidate_loader'
99
107
  require_relative 'paper_trail_diff/activity_target_candidate_loader'
100
108
  require_relative 'paper_trail_diff/activity_version_collector'
101
109
  require_relative 'paper_trail_diff/activity_snapshot_sequence'
110
+ require_relative 'paper_trail_diff/activity_tree_filter'
102
111
  require_relative 'paper_trail_diff/activity_history'
103
- require_relative 'paper_trail_diff/time_activity_timeline_builder'
104
112
  require_relative 'paper_trail_diff/activity_timeline_builder'
105
113
  require_relative 'paper_trail_diff/paper_trail_adapter'
106
114
 
@@ -118,10 +126,10 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
118
126
  # type comparison_input = Hash[String | Symbol, untyped]
119
127
  # type comparison_results = Hash[identity, Diff]
120
128
  # type report_options = {
121
- # within: untyped, activity: bool, version_scope: untyped,
122
- # close_on: Symbol?, snapshots: bool, group: Symbol?
129
+ # within: untyped, activity: bool, version_scope: untyped, view: ActivityView
123
130
  # }
124
131
  # type ignore_option = Array[String | Symbol] | Hash[String | Symbol, untyped]
132
+ # type association_option = String | Symbol | Array[untyped] | Hash[untyped, untyped]
125
133
  # type reference_key = :type | :id | "type" | "id"
126
134
  # type traversal_context = :change | :included_state
127
135
  # type traversal_state = :before | :after
@@ -142,43 +150,45 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
142
150
 
143
151
  class << self # rubocop:disable Metrics/ClassLength
144
152
  # Compares net state between explicit PaperTrail-version or current-record endpoints.
145
- #: (untyped, untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool) -> Diff
146
- def compare(
153
+ #: (untyped, untyped, ?associations: association_option, ?ignore: ignore_option, ?reload_live_endpoints: bool?, ?allow_approximate: bool) -> Diff
154
+ def compare( # rubocop:disable Metrics/ParameterLists
147
155
  from_version,
148
156
  to_version,
149
157
  associations: [],
150
158
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
151
- reload_live_endpoints: true
159
+ reload_live_endpoints: nil,
160
+ allow_approximate: false
152
161
  )
153
162
  PaperTrailAdapter.new(
154
163
  associations: associations,
155
164
  ignore: ignore,
156
- reload_live_endpoints: reload_live_endpoints
157
- ).compare(
158
- from_version,
159
- to_version
160
- )
165
+ reload_live_endpoints: Deprecation.reload_live_endpoints!(reload_live_endpoints),
166
+ allow_approximate: allow_approximate
167
+ ).compare(from_version, to_version)
161
168
  end
162
169
 
163
170
  # Compares many independent endpoint pairs while batching current-record loading.
164
- #: (Array[comparison_input], ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?allow_expensive: bool) -> comparison_results
165
- def compare_many(
171
+ #: (Array[comparison_input], ?associations: association_option, ?ignore: ignore_option, ?reload_live_endpoints: bool?, ?allow_expensive: bool?, ?allow_approximate: bool) -> comparison_results
172
+ def compare_many( # rubocop:disable Metrics/ParameterLists
166
173
  comparisons,
167
174
  associations: [],
168
175
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
169
- reload_live_endpoints: true,
170
- allow_expensive: false
176
+ reload_live_endpoints: nil,
177
+ allow_expensive: nil,
178
+ allow_approximate: false
171
179
  )
180
+ Deprecation.allow_expensive!(allow_expensive)
172
181
  PaperTrailAdapter.new(
173
182
  associations: associations,
174
183
  ignore: ignore,
175
- reload_live_endpoints: reload_live_endpoints, allow_expensive: allow_expensive
184
+ reload_live_endpoints: Deprecation.reload_live_endpoints!(reload_live_endpoints),
185
+ allow_approximate: allow_approximate
176
186
  ).compare_many(comparisons)
177
187
  end
178
188
 
179
189
  # Compares every adjacent reconstructed state in an inclusive version range,
180
190
  # by default the record's whole history through its current state.
181
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
191
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: association_option, ?ignore: ignore_option, ?version_scope: untyped, ?close_on: Symbol?, ?allow_approximate: bool) -> Array[Step]
182
192
  def timeline( # rubocop:disable Metrics/ParameterLists
183
193
  record,
184
194
  from: nil,
@@ -187,23 +197,23 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
187
197
  associations: [],
188
198
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
189
199
  version_scope: nil,
190
- close_on: nil
200
+ close_on: nil,
201
+ allow_approximate: false
191
202
  )
192
- PaperTrailAdapter.new(associations: associations, ignore: ignore).timeline(
203
+ Deprecation.close_on!(close_on, within)
204
+ PaperTrailAdapter.new(
205
+ associations: associations, ignore: ignore, allow_approximate: allow_approximate
206
+ ).timeline(
193
207
  record,
194
208
  from: from,
195
209
  to: to,
196
210
  within: within,
197
- version_scope: version_scope,
198
- close_on: close_on
211
+ version_scope: version_scope
199
212
  )
200
213
  end
201
214
 
202
215
  # Compares adjacent root and selected-descendant activity boundaries.
203
- # `reload_live_endpoints:` applies whenever the range ends at the current
204
- # record: an explicit `to:` record, the default range, or a window with no
205
- # later version.
206
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?reload_live_endpoints: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
216
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: association_option, ?ignore: ignore_option, ?reload_live_endpoints: bool?, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?recorded_changes: bool, ?allow_approximate: bool) -> Array[ActivityStep]
207
217
  def activity_timeline( # rubocop:disable Metrics/ParameterLists
208
218
  record,
209
219
  from: nil,
@@ -211,30 +221,32 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
211
221
  within: nil,
212
222
  associations: [],
213
223
  ignore: DEFAULT_IGNORED_ATTRIBUTES,
214
- reload_live_endpoints: true,
224
+ reload_live_endpoints: nil,
215
225
  version_scope: nil,
216
226
  close_on: nil,
217
227
  snapshots: false,
218
- group: nil
228
+ group: nil,
229
+ recorded_changes: false,
230
+ allow_approximate: false
219
231
  )
232
+ Deprecation.close_on!(close_on, within)
220
233
  PaperTrailAdapter.new(
221
- associations: associations,
222
- ignore: ignore,
223
- reload_live_endpoints: reload_live_endpoints
234
+ associations: associations, ignore: ignore,
235
+ reload_live_endpoints: Deprecation.reload_live_endpoints!(reload_live_endpoints),
236
+ recorded_changes: recorded_changes, allow_approximate: allow_approximate
224
237
  ).activity_timeline(
225
238
  record,
226
239
  from: from,
227
240
  to: to,
228
241
  within: within,
229
242
  version_scope: version_scope,
230
- close_on: close_on,
231
243
  group: group,
232
244
  snapshots: snapshots
233
245
  )
234
246
  end
235
247
 
236
248
  # Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
237
- #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
249
+ #: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: association_option, ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?recorded_changes: bool, ?allow_approximate: bool) -> Analysis
238
250
  def analyze( # rubocop:disable Metrics/ParameterLists
239
251
  record,
240
252
  from: nil,
@@ -246,16 +258,21 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
246
258
  version_scope: nil,
247
259
  close_on: nil,
248
260
  snapshots: false,
249
- group: nil
261
+ group: nil,
262
+ recorded_changes: false,
263
+ allow_approximate: false
250
264
  )
251
- PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
265
+ Deprecation.close_on!(close_on, within)
266
+ PaperTrailAdapter.new(
267
+ associations: associations, ignore: ignore, recorded_changes: recorded_changes,
268
+ allow_approximate: allow_approximate
269
+ ).analyze(
252
270
  record,
253
271
  from: from,
254
272
  to: to,
255
273
  within: within,
256
274
  activity: activity,
257
275
  version_scope: version_scope,
258
- close_on: close_on,
259
276
  snapshots: snapshots,
260
277
  group: group
261
278
  )
@@ -266,7 +283,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
266
283
  # returns a frozen Hash keyed by identity. Roots with nothing to report in
267
284
  # the window return an empty `Analysis`. To let a relation choose the
268
285
  # records, use `analyze_scope`.
269
- #: (Array[untyped], ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool) -> Hash[identity, Analysis]
286
+ #: (Array[untyped], ?within: untyped, ?associations: association_option, ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?allow_expensive: bool?, ?recorded_changes: bool, ?allow_approximate: bool) -> Hash[identity, Analysis]
270
287
  def analyze_many( # rubocop:disable Metrics/ParameterLists
271
288
  records,
272
289
  within: nil,
@@ -277,12 +294,17 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
277
294
  close_on: nil,
278
295
  snapshots: false,
279
296
  group: nil,
280
- allow_expensive: false
297
+ allow_expensive: nil,
298
+ recorded_changes: false,
299
+ allow_approximate: false
281
300
  )
301
+ Deprecation.close_on!(close_on, within)
302
+ Deprecation.allow_expensive!(allow_expensive)
282
303
  PaperTrailAdapter.new(
283
- associations: associations, ignore: ignore, allow_expensive: allow_expensive
304
+ associations: associations, ignore: ignore,
305
+ recorded_changes: recorded_changes, allow_approximate: allow_approximate
284
306
  ).analyze_many(records, within: within, activity: activity, version_scope: version_scope,
285
- close_on: close_on, snapshots: snapshots, group: group)
307
+ snapshots: snapshots, group: group)
286
308
  end
287
309
 
288
310
  # Analyzes every root the relation reaches whose history moved inside the
@@ -311,7 +333,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
311
333
  # Note also that a relation selects on current state, not on state during
312
334
  # the window: `where(status: 'published')` means published *now*, which is a
313
335
  # different set from what was published while the window was open.
314
- #: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool) -> ScopedAnalysis
336
+ #: (untyped, limit: Integer?, ?within: untyped, ?associations: association_option, ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool, ?allow_expensive: bool?, ?recorded_changes: bool, ?allow_approximate: bool) -> ScopedAnalysis
315
337
  def analyze_scope( # rubocop:disable Metrics/ParameterLists
316
338
  scope,
317
339
  limit:,
@@ -326,13 +348,18 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
326
348
  historical_filter: nil,
327
349
  max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT,
328
350
  include_deleted: false,
329
- allow_expensive: false
351
+ allow_expensive: nil,
352
+ recorded_changes: false,
353
+ allow_approximate: false
330
354
  )
355
+ Deprecation.close_on!(close_on, within)
356
+ Deprecation.allow_expensive!(allow_expensive)
331
357
  adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore,
332
- allow_expensive: allow_expensive)
358
+ recorded_changes: recorded_changes,
359
+ allow_approximate: allow_approximate)
333
360
  adapter.analyze_scope(
334
361
  scope, limit: limit, within: within, activity: activity,
335
- version_scope: version_scope, close_on: close_on, snapshots: snapshots, group: group,
362
+ version_scope: version_scope, snapshots: snapshots, group: group,
336
363
  historical_filter: historical_filter, max_filter_versions: max_filter_versions,
337
364
  include_deleted: include_deleted
338
365
  )
@@ -342,21 +369,35 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
342
369
  # for every root that changed or whose requested associations did.
343
370
  # Historical windows remain explicit on analyze_scope; this entry point is
344
371
  # deliberately named for a report through now rather than an as-of query.
345
- #: (untyped, since: untyped, limit: Integer, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool) -> ScopedAnalysis
372
+ #: (untyped, since: untyped, limit: Integer, ?associations: association_option, ?ignore: ignore_option, ?snapshots: bool, ?group: Symbol?, ?include_deleted: bool, ?historical_filter: untyped, ?max_filter_versions: Integer, ?allow_expensive: bool?, ?recorded_changes: bool, ?allow_approximate: bool) -> ScopedAnalysis
346
373
  def report( # rubocop:disable Metrics/ParameterLists
347
374
  scope, since:, limit:, associations: [], ignore: DEFAULT_IGNORED_ATTRIBUTES,
348
375
  snapshots: false, group: nil, include_deleted: false, historical_filter: nil,
349
- max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, allow_expensive: false
376
+ max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, allow_expensive: nil,
377
+ recorded_changes: false, allow_approximate: false
350
378
  )
379
+ # A range here would become the start of another range.
380
+ if since.nil? || since.is_a?(Range)
381
+ raise InvalidTimeRangeError, '`since` must be a time, date, or string; for a range, ' \
382
+ 'pass it as `within:` to analyze_scope'
383
+ end
384
+
351
385
  analyze_scope(
352
386
  scope,
353
387
  within: since..Time.now.utc, limit: limit, associations: associations,
354
- ignore: ignore, activity: true, close_on: :current, snapshots: snapshots, group: group,
388
+ ignore: ignore, activity: true, snapshots: snapshots, group: group,
355
389
  include_deleted: include_deleted, historical_filter: historical_filter,
356
- max_filter_versions: max_filter_versions, allow_expensive: allow_expensive
390
+ max_filter_versions: max_filter_versions, allow_expensive: allow_expensive,
391
+ recorded_changes: recorded_changes, allow_approximate: allow_approximate
357
392
  )
358
393
  end
359
394
 
395
+ # Warns about deprecated options; see Deprecation.
396
+ #: () -> untyped
397
+ def deprecator
398
+ Deprecation.deprecator
399
+ end
400
+
360
401
  # Returns the association macros this release can normalize.
361
402
  #: () -> Array[Symbol]
362
403
  def supported_association_macros
@@ -369,10 +410,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
369
410
  AssociationDiscovery.new(model_or_record, max_depth: max_depth).call
370
411
  end
371
412
 
372
- # Reports known reconstruction hazards without mutating application state.
373
- #: (untyped, untyped, ?associations: Array[String | Symbol]) -> DiagnosticReport
374
- def diagnose(from_version, to_version, associations: [])
375
- HistoryDiagnostics.new(from_version, to_version, associations: associations).call
413
+ # Reports known reconstruction hazards without mutating application state,
414
+ # for the endpoints `compare` takes: two versions, or a version and the
415
+ # current record. Every reason `compare` would refuse them is an error.
416
+ #: (untyped, untyped, ?associations: association_option, ?allow_approximate: bool) -> DiagnosticReport
417
+ def diagnose(from, to, associations: [], allow_approximate: false)
418
+ HistoryDiagnostics.new(from, to, associations: associations,
419
+ allow_approximate: allow_approximate).call
376
420
  end
377
421
 
378
422
  # Looks inside an attribute the database stores whole, such as a JSON or
@@ -404,9 +448,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
404
448
 
405
449
  # Reads what PaperTrail recorded with one version: its `object_changes`,
406
450
  # typed by the version's model, as `{ "title" => <from "Draft" to "Published"> }`.
407
- #
408
- # versions = PaperTrail::Version.where(id: steps.map { _1.source_boundary.version_id })
409
- # versions.to_h { [_1.id, PaperTrailDiff.recorded_changes(_1)] }
451
+ # Activity methods read it for every step source with `recorded_changes: true`.
410
452
  #
411
453
  # A step's diff compares the states before and after an event, so it also
412
454
  # includes anything saved without a version in between; this does not.
@@ -24,26 +24,49 @@ module PaperTrailDiff
24
24
  # report one save as one step rather than as its parts.
25
25
  attr_reader transaction_id: untyped
26
26
 
27
- # : (untyped) -> ActivityBoundary
28
- def self.from_version: (untyped) -> ActivityBoundary
27
+ @recorded_changes: untyped
29
28
 
30
- # : (untyped, captured_at: untyped) -> ActivityBoundary
31
- def self.current: (untyped, captured_at: untyped) -> ActivityBoundary
29
+ # Stands for recorded changes nobody asked to read, which differs from a
30
+ # version that recorded nothing (nil).
31
+ NOT_READ: untyped
32
+
33
+ NOT_READ_MESSAGE: ::String
34
+
35
+ # `recorded:` names the attributes to leave out of the version's recorded
36
+ # changes, or is nil when they were not asked for. Reading them decodes
37
+ # `object_changes`, which a caller who does not want them should not pay.
38
+ # : (untyped, ?recorded: Array[String]?) -> ActivityBoundary
39
+ def self.from_version: (untyped, ?recorded: Array[String]?) -> ActivityBoundary
40
+
41
+ # The current record has no version, so it recorded nothing.
42
+ # : (untyped, captured_at: untyped, ?recorded: Array[String]?) -> ActivityBoundary
43
+ def self.current: (untyped, captured_at: untyped, ?recorded: Array[String]?) -> ActivityBoundary
32
44
 
33
45
  # The state a `destroy` version leaves behind. A version records the state
34
46
  # before its own event, so the boundary built from a destroy version still
35
47
  # holds the record; this one is the absence that follows it.
36
- # : (untyped) -> ActivityBoundary
37
- def self.destroyed: (untyped) -> ActivityBoundary
38
-
39
- # A custom version class need not carry the column, and PaperTrail leaves
40
- # it nil outside a transaction. Both read as "no transaction here", which
41
- # groups with nothing.
42
- # : (untyped) -> untyped
43
- def self.transaction_id: (untyped) -> untyped
44
-
45
- # : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
46
- def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
48
+ # : (untyped, ?recorded: Array[String]?) -> ActivityBoundary
49
+ def self.destroyed: (untyped, ?recorded: Array[String]?) -> ActivityBoundary
50
+
51
+ # : (untyped, Array[String]?) -> untyped
52
+ private def self.recorded_changes: (untyped, Array[String]?) -> untyped
53
+
54
+ # `recorded_changes:` takes what `PaperTrailDiff.recorded_changes` returns
55
+ # for the version; leave it out when they were not read.
56
+ # : (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
57
+ def initialize: (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
58
+
59
+ # What PaperTrail recorded with this boundary's version, as
60
+ # `PaperTrailDiff.recorded_changes` returns it: nil when nothing was
61
+ # recorded, and always nil for the current record, which has no version.
62
+ # Raises unless the timeline was built with `recorded_changes: true`, so a
63
+ # forgotten option cannot pass for a version that recorded nothing.
64
+ # : () -> Hash[String, ValueChange]?
65
+ def recorded_changes: () -> Hash[String, ValueChange]?
66
+
67
+ # Whether this boundary's recorded changes were read.
68
+ # : () -> bool
69
+ def recorded_changes?: () -> bool
47
70
 
48
71
  # : () -> bool
49
72
  def version?: () -> bool
@@ -75,14 +98,20 @@ module PaperTrailDiff
75
98
 
76
99
  attr_reader to_snapshot: RecordSnapshot?
77
100
 
101
+ # The events whose changes this step reports, in the order they happened.
102
+ # That is `source_boundary` alone, unless the step is a transaction group,
103
+ # which holds every event in it: an attribution that names only the first
104
+ # would credit the rest of the save to whoever opened it.
105
+ attr_reader sources: Array[ActivityBoundary]
106
+
78
107
  # Compares two reconstructed states and keeps them only when asked, which is
79
108
  # every caller's shape: the diff always comes from the pair, the pair itself
80
109
  # is retained on request.
81
- # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
82
- def self.between: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool) -> ActivityStep
110
+ # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool, ?sources: Array[ActivityBoundary]?) -> ActivityStep
111
+ def self.between: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, from_snapshot: RecordSnapshot?, to_snapshot: RecordSnapshot?, retain: bool, ?sources: Array[ActivityBoundary]?) -> ActivityStep
83
112
 
84
- # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
85
- def initialize: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?) -> void
113
+ # : (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?, ?sources: Array[ActivityBoundary]?) -> void
114
+ def initialize: (from_boundary: ActivityBoundary, to_boundary: ActivityBoundary, diff: Diff, ?from_snapshot: RecordSnapshot?, ?to_snapshot: RecordSnapshot?, ?sources: Array[ActivityBoundary]?) -> void
86
115
 
87
116
  # : () -> bool
88
117
  def empty?: () -> bool
@@ -92,6 +121,12 @@ module PaperTrailDiff
92
121
  # : () -> ActivityBoundary
93
122
  def source_boundary: () -> ActivityBoundary
94
123
 
124
+ # The diff as flat rows, each attributed to `source_boundary`.
125
+ # : () -> Array[ChangeRow]
126
+ def change_rows: () -> Array[ChangeRow]
127
+
128
+ # `metadata: true` also lists `sources`, since who made a change is
129
+ # metadata as much as the source boundary's own author is.
95
130
  # : (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
96
131
  def to_h: (?metadata: bool, ?snapshots: bool) -> Hash[Symbol, untyped]
97
132
  end