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
@@ -5,22 +5,22 @@ module PaperTrailDiff
5
5
  # Builds one root's Analysis inside a batch, from versions the batch already
6
6
  # selected and history it already prepared.
7
7
  class BatchedRootAnalyzer
8
- #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool, ?within: untyped, ?snapshots: bool, ?group: Symbol?) -> void
9
- def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, within: nil, snapshots: false, group: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
8
+ #: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool, view: ActivityView, ?within: untyped) -> void
9
+ def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, view:, within: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
10
10
  @tree = tree
11
11
  @timeline_snapshotter = timeline_snapshotter
12
12
  @activity_snapshotter = activity_snapshotter
13
13
  @preparer = preparer
14
14
  @activity = activity
15
+ @view = view
15
16
  @within = within
16
- @snapshots = snapshots
17
- @group = group
18
17
  end
19
18
 
20
- #: (untyped, RootVersionPlan) -> Analysis
21
- def call(record, plan)
19
+ # `window` is the root's window as the batch moved it to transaction edges.
20
+ #: (untyped, RootVersionPlan, ?TimeRange?) -> Analysis
21
+ def call(record, plan, window = nil)
22
22
  @preparer.call(record.class, historical: true)
23
- return activity_analysis(record, plan) if @activity
23
+ return activity_analysis(record, plan, window) if @activity
24
24
 
25
25
  TimelineBuilder.new(
26
26
  record,
@@ -40,19 +40,17 @@ module PaperTrailDiff
40
40
  # @rbs @preparer: untyped
41
41
  # @rbs @activity: bool
42
42
  # @rbs @within: untyped
43
- # @rbs @snapshots: bool
44
- # @rbs @group: Symbol?
43
+ # @rbs @view: ActivityView
45
44
 
46
- #: (untyped, RootVersionPlan) -> Analysis
47
- def activity_analysis(record, plan)
45
+ #: (untyped, RootVersionPlan, TimeRange?) -> Analysis
46
+ def activity_analysis(record, plan, window)
48
47
  range = TimelineRange.new(
49
48
  record, from: (@within ? nil : plan.versions.first),
50
49
  to: (@within ? nil : plan.closing_record || plan.versions.last),
51
- within: @within, plan: plan
50
+ within: (window || @within if @within), plan: plan
52
51
  )
53
52
  ActivityTimelineBuilder.new(
54
- record, range: range, tree: @tree, snapshotter: @activity_snapshotter,
55
- snapshots: @snapshots, group: @group
53
+ record, range: range, tree: @tree, snapshotter: @activity_snapshotter, view: @view
56
54
  ).analyze
57
55
  end
58
56
  end
@@ -6,9 +6,13 @@ module PaperTrailDiff
6
6
  # queries. Only the range forms that mean the same thing for every root are
7
7
  # supported: a shared wall-clock window, or each root's own whole history.
8
8
  class BatchedRootVersions
9
- #: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool) -> void
10
- def initialize(records, time_range:, version_scope:, live_endpoints:, context_required:)
9
+ #: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool, windows: Hash[Array[String], TimeRange]) -> void
10
+ def initialize( # rubocop:disable Metrics/ParameterLists
11
+ records, time_range:, version_scope:, live_endpoints:, context_required:, windows:
12
+ )
11
13
  @records = records
14
+ # Roots whose window moved to a transaction's start, by identity.
15
+ @windows = windows
12
16
  # An activity view needs a root to reconstruct from even in a window with
13
17
  # no root version, exactly as a single-record activity timeline does.
14
18
  @context_required = context_required
@@ -34,32 +38,55 @@ module PaperTrailDiff
34
38
  # @rbs @version_scope: untyped
35
39
  # @rbs @live_endpoints: Hash[Array[String], untyped]?
36
40
  # @rbs @context_required: bool
41
+ # @rbs @windows: Hash[Array[String], TimeRange]
37
42
 
38
43
  #: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
39
44
  def select_model(model_class, records, selected)
40
45
  in_range, chosen, trailing = model_versions(model_class, records)
41
46
  records.each do |record|
42
47
  key = Endpoint.identity(record)
43
- selected[key] = versions_for(
44
- in_range.fetch(record.id.to_s, []), chosen, trailing[record.id.to_s],
45
- live_endpoint(key, record)
46
- )
48
+ own = own_window(key, record.id.to_s, in_range, trailing)
49
+ selected[key] = versions_for(*own, chosen, live_endpoint(key, record))
47
50
  end
48
51
  end
49
52
 
53
+ # Moved windows only start and end earlier, so the versions read for the
54
+ # widest of them hold each root's own, and the first left past a moved end.
55
+ #: (Array[String], String, Hash[String, Array[untyped]], Hash[String, untyped]) -> [Array[untyped], untyped]
56
+ def own_window(key, id, in_range, trailing)
57
+ loaded = in_range.fetch(id, [])
58
+ return [loaded, trailing[id]] if @windows.empty?
59
+
60
+ # A batch moves windows only when it has one, so this is never nil.
61
+ window = @windows.fetch(key) { @time_range } #: untyped
62
+ own, rest = loaded.partition { |version| window.include?(version.created_at) }
63
+ [own, rest.find { |version| version.created_at >= window.end_time } || trailing[id]]
64
+ end
65
+
50
66
  #: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
51
67
  def model_versions(model_class, records)
52
68
  range = @time_range
53
69
  empty = {} #: Hash[String, untyped]
70
+ widest = widest_range(records)
54
71
  [
55
- grouped_versions(model_class, records, range),
56
- chosen_version_ids(model_class, records, range),
72
+ grouped_versions(model_class, records, widest),
73
+ chosen_version_ids(model_class, records, widest),
57
74
  range ? trailing_versions(model_class, records, range) : empty
58
75
  ]
59
76
  end
60
77
 
61
- #: (Array[untyped], Set[untyped]?, untyped, untyped) -> RootVersionPlan
62
- def versions_for(in_range, chosen, after_range, live_endpoint)
78
+ #: (Array[untyped]) -> TimeRange?
79
+ def widest_range(records)
80
+ range = @time_range
81
+ return unless range
82
+
83
+ moved = records.filter_map { |record| @windows[Endpoint.identity(record)]&.begin_time }
84
+ start = [range.begin_time, *moved].min
85
+ TimeRange.new(range.exclude_end? ? (start...range.end_time) : (start..range.end_time))
86
+ end
87
+
88
+ #: (Array[untyped], untyped, Set[untyped]?, untyped) -> RootVersionPlan
89
+ def versions_for(in_range, after_range, chosen, live_endpoint)
63
90
  RootVersionSelection.new(
64
91
  in_range: in_range,
65
92
  selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
@@ -109,10 +136,7 @@ module PaperTrailDiff
109
136
  #: (untyped, Array[untyped]) -> untyped
110
137
  def base_scope(model_class, records)
111
138
  # Active Record reads each record's id.
112
- model_class.paper_trail.version_class.where(
113
- item_type: model_class.base_class.name,
114
- item_id: records
115
- )
139
+ VersionQuery.for_items(model_class, records)
116
140
  end
117
141
  end
118
142
  end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # One change from an activity step, flat enough for a history table or an
6
+ # export: a field that changed, or a record that appeared or disappeared,
7
+ # with the event the step attributes it to.
8
+ class ChangeRow
9
+ attr_reader :change #: Symbol
10
+ attr_reader :association_path #: Array[String]
11
+ attr_reader :association_kind #: Symbol?
12
+ # The record the row is about, or nil for the root itself.
13
+ attr_reader :record #: RecordReference?
14
+ # The field the row is about, or nil for a row about a whole record.
15
+ attr_reader :attribute #: String?
16
+ attr_reader :from #: untyped
17
+ attr_reader :to #: untyped
18
+ # The step's source boundary: for a transaction group, its first event.
19
+ attr_reader :source #: ActivityBoundary
20
+
21
+ #: (change: Symbol, association_path: Array[String], association_kind: Symbol?, record: RecordReference?, attribute: String?, from: untyped, to: untyped, source: ActivityBoundary) -> void
22
+ def initialize( # rubocop:disable Metrics/ParameterLists
23
+ change:, association_path:, association_kind:, record:, attribute:, from:, to:, source:
24
+ )
25
+ @change = change
26
+ @association_path = association_path
27
+ @association_kind = association_kind
28
+ @record = record
29
+ @attribute = attribute
30
+ @from = from
31
+ @to = to
32
+ @source = source
33
+ freeze
34
+ end
35
+
36
+ # The source's identifying fields sit beside the change rather than nested,
37
+ # so the hash is one row of a table as it stands.
38
+ #: () -> Hash[Symbol, untyped]
39
+ def to_h
40
+ {
41
+ change: change, association_path: association_path, association_kind: association_kind,
42
+ record: record&.to_h, attribute: attribute,
43
+ from: Support.serialize(from), to: Support.serialize(to)
44
+ }.merge(source_fields)
45
+ end
46
+
47
+ private
48
+
49
+ #: () -> Hash[Symbol, untyped]
50
+ def source_fields
51
+ { version_id: source.version_id, event: source.event, whodunnit: source.whodunnit,
52
+ recorded_at: source.recorded_at, transaction_id: source.transaction_id }
53
+ end
54
+ end
55
+
56
+ # Flattens one step's diff into change rows, in the diff's walking order.
57
+ #
58
+ # A row is about one record. An edited field is `:changed`. A record that
59
+ # appeared or disappeared is one `:added` or `:removed` row, followed by a
60
+ # row for each of its fields and for each record it holds, so a table can
61
+ # list a new comment's body without a second walker. That covers a record
62
+ # joining or leaving a collection, a to-one target set, cleared or replaced
63
+ # (the old target removed, then the new one added), and the root's own
64
+ # creation or deletion.
65
+ #
66
+ # Two kinds of entry add nothing a row would show. One that only groups
67
+ # others (an edited record, an association, a relationship) is left out,
68
+ # because the rows under it name the same record. A blank field of an added
69
+ # or removed record is left out too, because the record's own row already
70
+ # says it appeared or disappeared.
71
+ class ChangeRows
72
+ RECORD_ENTRIES = %i[record_added record_removed record_included].freeze
73
+ private_constant :RECORD_ENTRIES
74
+
75
+ #: (Diff, source: ActivityBoundary) -> Array[ChangeRow]
76
+ def self.call(diff, source:)
77
+ new(source).call(diff)
78
+ end
79
+
80
+ #: (ActivityBoundary) -> void
81
+ def initialize(source)
82
+ @source = source
83
+ end
84
+
85
+ #: (Diff) -> Array[ChangeRow]
86
+ def call(diff)
87
+ rows = [] #: Array[ChangeRow]
88
+ diff.each_entry do |entry|
89
+ row = row_for(entry)
90
+ rows << row if row
91
+ end
92
+ rows.freeze
93
+ end
94
+
95
+ private
96
+
97
+ # @rbs @source: ActivityBoundary
98
+
99
+ #: (TraversalEntry) -> ChangeRow?
100
+ def row_for(entry)
101
+ return changed_row(entry) if entry.kind == :attribute_changed
102
+ return presence_row(entry, nil) if RECORD_ENTRIES.include?(entry.kind)
103
+ return unless entry.kind == :attribute_included && !entry.value.nil?
104
+
105
+ presence_row(entry, entry.attribute)
106
+ end
107
+
108
+ #: (TraversalEntry) -> ChangeRow
109
+ def changed_row(entry)
110
+ value = entry.value #: ValueChange
111
+ row(entry, :changed, entry.attribute, value.from, value.to)
112
+ end
113
+
114
+ # An added record's state is the after side and a removed one's the before
115
+ # side, so a field's value goes on the side that has it.
116
+ #: (TraversalEntry, String?) -> ChangeRow
117
+ def presence_row(entry, attribute)
118
+ value = attribute ? entry.value : nil
119
+ return row(entry, :added, attribute, nil, value) if entry.state == :after
120
+
121
+ row(entry, :removed, attribute, value, nil)
122
+ end
123
+
124
+ #: (TraversalEntry, Symbol, String?, untyped, untyped) -> ChangeRow
125
+ def row(entry, change, attribute, from, to)
126
+ ChangeRow.new(
127
+ change: change, association_path: entry.association_path,
128
+ association_kind: entry.association_kind, record: entry.record,
129
+ attribute: attribute, from: from, to: to, source: @source
130
+ )
131
+ end
132
+ end
133
+ end
@@ -3,6 +3,8 @@
3
3
 
4
4
  module PaperTrailDiff
5
5
  # Compares collection snapshots with a low-allocation path for stable membership.
6
+ # Snapshots hold their records in type and ID order, so results follow it
7
+ # without sorting here.
6
8
  class CollectionComparator
7
9
  #: (AssociationSnapshot, AssociationSnapshot, record_comparer: untyped) -> void
8
10
  def initialize(from_association, to_association, record_comparer:)
@@ -50,9 +52,7 @@ module PaperTrailDiff
50
52
  pairs = aligned_changed_pairs
51
53
  return unless pairs
52
54
 
53
- pairs.sort_by { |_from, to| sortable_identity(to.identity) }.filter_map do |pair|
54
- @record_comparer.call(pair.fetch(0), pair.fetch(1))
55
- end
55
+ pairs.filter_map { |pair| @record_comparer.call(pair.fetch(0), pair.fetch(1)) }
56
56
  end
57
57
 
58
58
  #: () -> Array[[RecordSnapshot, RecordSnapshot]]?
@@ -105,13 +105,12 @@ module PaperTrailDiff
105
105
 
106
106
  #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
107
107
  def records_missing_from(records, other_records)
108
- identities = sorted_identities(records.keys - other_records.keys)
109
- identities.map { |identity| records.fetch(identity) }
108
+ (records.keys - other_records.keys).map { |identity| records.fetch(identity) }
110
109
  end
111
110
 
112
111
  #: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
113
112
  def changed_records(from_records, to_records)
114
- sorted_identities(from_records.keys & to_records.keys).filter_map do |identity|
113
+ (from_records.keys & to_records.keys).filter_map do |identity|
115
114
  from_record = from_records.fetch(identity)
116
115
  to_record = to_records.fetch(identity)
117
116
  next if from_record.equal?(to_record)
@@ -132,32 +131,6 @@ module PaperTrailDiff
132
131
  index
133
132
  end
134
133
 
135
- #: (Array[identity]) -> Array[identity]
136
- def sorted_identities(identities)
137
- identities.sort_by { |identity| sortable_identity(identity) }
138
- end
139
-
140
- # Identities sort by type first so that mixed id types stay comparable, then
141
- # naturally within one type. Ordering by the printed form instead would put
142
- # id 10 before id 2, which is deterministic but reads as unsorted wherever a
143
- # result is rendered.
144
- #: (identity) -> Array[untyped]
145
- def sortable_identity(identity)
146
- [identity.fetch(0), sortable_id(identity.fetch(1))]
147
- end
148
-
149
- # Only the order of the group numbers matters, so moving the lowest one
150
- # down or the highest one up changes nothing; the spec checks the order.
151
- # mutant:disable
152
- #: (untyped) -> Array[untyped]
153
- def sortable_id(id)
154
- case id
155
- when Numeric then [0, id]
156
- when String, Symbol then [1, id.to_s]
157
- else [2, id.inspect]
158
- end
159
- end
160
-
161
134
  #: (identity) -> void
162
135
  def raise_duplicate_identity!(identity)
163
136
  raise ArgumentError, "duplicate record identity: #{identity}"
@@ -7,19 +7,40 @@ module PaperTrailDiff
7
7
  attr_reader :children #: Hash[String, AssociationTree]
8
8
 
9
9
  class << self
10
+ # Takes what `includes` takes: a name, a dotted path, a hash of names to
11
+ # what to include below them, or an array of any of these.
10
12
  #: (untyped) -> AssociationTree
11
13
  def build(values)
12
- unless valid_values?(values)
13
- raise ConfigurationError, 'associations: must be an array of strings or symbols'
14
- end
15
-
16
14
  tree = {} #: Hash[String, untyped]
17
- values.each { |value| add_path(tree, value.to_s) }
15
+ paths(values, nil).each { |path| add_path(tree, path) }
18
16
  from_hash(tree)
19
17
  end
20
18
 
21
19
  private
22
20
 
21
+ #: (untyped, String?) -> Array[String]
22
+ def paths(value, prefix)
23
+ case value
24
+ when String, Symbol then [joined(prefix, value)]
25
+ when Array then value.flat_map { |item| paths(item, prefix) }
26
+ when Hash then value.flat_map { |name, nested| nested_paths(name, nested, prefix) }
27
+ else
28
+ raise ConfigurationError,
29
+ 'associations: must be names, dotted paths, hashes of them, or arrays of these'
30
+ end
31
+ end
32
+
33
+ # `{ comments: :replies }` names comments as well as their replies.
34
+ #: (untyped, untyped, String?) -> Array[String]
35
+ def nested_paths(name, nested, prefix)
36
+ paths(name, prefix).flat_map { |path| [path, *paths(nested, path)] }
37
+ end
38
+
39
+ #: (String?, String | Symbol) -> String
40
+ def joined(prefix, name)
41
+ prefix ? "#{prefix}.#{name}" : name.to_s
42
+ end
43
+
23
44
  #: (Hash[String, untyped], String) -> void
24
45
  def add_path(tree, path)
25
46
  segments = path.split('.', -1)
@@ -39,13 +60,6 @@ module PaperTrailDiff
39
60
  end
40
61
  new(children)
41
62
  end
42
-
43
- #: (untyped) -> bool
44
- def valid_values?(values)
45
- values.is_a?(Array) && values.all? do |value|
46
- value.is_a?(String) || value.is_a?(Symbol)
47
- end
48
- end
49
63
  end
50
64
 
51
65
  #: (Hash[String, AssociationTree]) -> void
@@ -166,5 +180,14 @@ module PaperTrailDiff
166
180
  external_path = path.empty? ? ROOT_PATH : path
167
181
  (all + paths.fetch(external_path, [])).uniq.freeze
168
182
  end
183
+
184
+ # The names to leave out of what a version recorded. A root version is
185
+ # known to be the root's, so its own path's names apply; any other version
186
+ # does not say which path reached it, so only the names ignored everywhere
187
+ # do.
188
+ #: (root: bool) -> Array[String]
189
+ def recorded_attributes(root:)
190
+ root ? attributes_for('') : all
191
+ end
169
192
  end
170
193
  end
@@ -24,12 +24,14 @@ module PaperTrailDiff
24
24
  def call
25
25
  destroys = destroy_versions
26
26
  records = historical_roots(destroys)
27
- selected = plans(records, destroys)
28
- prepare(records, selected)
27
+ windows = TransactionCut.batch_windows(records, @time_range)
28
+ selected = plans(records, destroys, windows)
29
+ prepare(records, selected, windows)
29
30
  analyses = records.to_h do |record|
30
31
  key = Endpoint.identity(record)
31
32
  plan = selected.fetch(key)
32
- [key, plan.empty? ? Analysis.empty : @analyzer.call(record, plan)]
33
+ window = windows.fetch(key, @time_range)
34
+ [key, plan.empty? ? Analysis.empty : @analyzer.call(record, plan, window)]
33
35
  end
34
36
  with_unavailable(analyses)
35
37
  end
@@ -51,20 +53,20 @@ module PaperTrailDiff
51
53
 
52
54
  # A root destroyed before the window had no state inside it to read, so
53
55
  # only the others take the version after the window as context.
54
- #: (Array[untyped], Array[untyped]) -> Hash[Array[String], RootVersionPlan]
55
- def plans(records, destroys)
56
+ #: (Array[untyped], Array[untyped], Hash[Array[String], TimeRange]) -> Hash[Array[String], RootVersionPlan]
57
+ def plans(records, destroys, windows)
56
58
  start = @time_range&.begin_time
57
59
  early = destroys.select { |version| start && version.created_at < start }
58
60
  .map { |version| version.item_id.to_s }
59
61
  before, during = records.partition { |record| early.include?(record.id.to_s) }
60
- plans_for(during, @context_required).merge(plans_for(before, false))
62
+ plans_for(during, @context_required, windows).merge(plans_for(before, false, windows))
61
63
  end
62
64
 
63
- #: (Array[untyped], bool) -> Hash[Array[String], RootVersionPlan]
64
- def plans_for(records, context_required)
65
+ #: (Array[untyped], bool, Hash[Array[String], TimeRange]) -> Hash[Array[String], RootVersionPlan]
66
+ def plans_for(records, context_required, windows)
65
67
  BatchedRootVersions.new(
66
68
  records, time_range: @time_range, version_scope: @version_scope,
67
- live_endpoints: nil, context_required: context_required
69
+ live_endpoints: nil, context_required: context_required, windows: windows
68
70
  ).call
69
71
  end
70
72
 
@@ -84,28 +86,26 @@ module PaperTrailDiff
84
86
 
85
87
  #: () -> Array[untyped]
86
88
  def destroy_versions
87
- destroys = @model.paper_trail.version_class.where(
88
- item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
89
- )
89
+ destroys = VersionQuery.for_items(@model, @identities.map(&:last)).where(event: 'destroy')
90
90
  # Oldest first, so each item keeps its latest destroy.
91
91
  VersionQuery.chronological(destroys).index_by(&:item_id).values
92
92
  end
93
93
 
94
- #: (Array[untyped]) -> Hash[Symbol, untyped]
95
- def preparation_range(versions)
96
- { start_at: @time_range&.begin_time,
94
+ #: (Array[untyped], Hash[Array[String], TimeRange]) -> Hash[Symbol, untyped]
95
+ def preparation_range(versions, windows)
96
+ { start_at: TransactionCut.earliest_start(@time_range, windows),
97
97
  end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
98
98
  end
99
99
 
100
- #: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
101
- def prepare(records, selected)
100
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], TimeRange]) -> void
101
+ def prepare(records, selected, windows)
102
102
  records.group_by(&:class).each_value do |group|
103
103
  versions = group.flat_map do |record|
104
104
  selected.fetch(Endpoint.identity(record)).reconstruction_versions
105
105
  end
106
106
  next if versions.empty?
107
107
 
108
- @history_preparer.call(group, versions, **preparation_range(versions))
108
+ @history_preparer.call(group, versions, **preparation_range(versions, windows))
109
109
  end
110
110
  end
111
111
  end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Keeps the deleted roots the relation would have selected, by the state
6
+ # each held when it was destroyed. A relation's conditions read the live
7
+ # table, where a destroyed root has no row, so without this a report on one
8
+ # account's records would include every account's deletions.
9
+ class DeletedRootFilter
10
+ #: (untyped) -> void
11
+ def initialize(relation)
12
+ @relation = relation
13
+ condition = ScopeCondition.for_relation(relation, model)
14
+ unless condition
15
+ raise ConfigurationError,
16
+ 'include_deleted: cannot test deleted records against this relation: its ' \
17
+ "conditions must be plain conditions on #{model.name}'s own columns, as an " \
18
+ "association scope's must; select deleted records some other way"
19
+ end
20
+
21
+ @condition = condition
22
+ @loader = DirectRecordStateLoader.new
23
+ end
24
+
25
+ # A root whose destroy PaperTrail did not record has no state to test
26
+ # and stays, as it has no state to report either. One whose destroy
27
+ # recorded no state is left out.
28
+ #: (Array[Array[String]]) -> Array[Array[String]]
29
+ def call(identities)
30
+ states = destroyed_states(identities.map(&:last))
31
+ identities.select do |identity|
32
+ id = identity.last
33
+ !states.key?(id) || selected?(states.fetch(id))
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ # @rbs @relation: untyped
40
+ # @rbs @condition: ScopeCondition
41
+ # @rbs @loader: DirectRecordStateLoader
42
+
43
+ # A relation delegates class methods to its model, so every mutation here,
44
+ # which drops `.model`, answers the same.
45
+ # mutant:disable
46
+ #: () -> untyped
47
+ def model
48
+ @relation.model
49
+ end
50
+
51
+ #: (Hash[String, untyped]?) -> bool
52
+ def selected?(attributes)
53
+ return false unless attributes
54
+
55
+ AssociationTraversal.sti_member?(model, attributes) && @condition.matches?(attributes)
56
+ end
57
+
58
+ # The state each root held before its latest destroy.
59
+ #: (Array[String]) -> Hash[String, Hash[String, untyped]?]
60
+ def destroyed_states(ids)
61
+ destroys = VersionQuery.for_items(model, ids).where(event: 'destroy')
62
+ VersionQuery.chronological(destroys).to_h do |version|
63
+ [version.item_id.to_s, @loader.call(version)&.attributes || reified_attributes(version)]
64
+ end
65
+ end
66
+
67
+ # Read directly, with a column `skip:` left out of history as its default,
68
+ # so a report checks its ceiling before reifying anything. A version the
69
+ # direct reader declines, such as one naming a column since removed, is
70
+ # reified. Only the record's own attributes are read, so the association
71
+ # options, which only spare queries, cannot change an answer.
72
+ # mutant:disable
73
+ #: (untyped) -> Hash[String, untyped]?
74
+ def reified_attributes(version)
75
+ version.reify(dup: true, has_many: false, has_one: false, belongs_to: false,
76
+ has_and_belongs_to_many: false)&.attributes
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Warns about options that do nothing any more and will be removed. In a
6
+ # Rails app, register the deprecator to configure it with the app's own:
7
+ #
8
+ # Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator
9
+ module Deprecation
10
+ CLOSE_ON = 'close_on: is deprecated and has no effect: a window already closes on the ' \
11
+ 'current record when nothing on its requested paths was recorded after it. ' \
12
+ 'Remove the option.'
13
+ ALLOW_EXPENSIVE = 'allow_expensive: is deprecated and has no effect: allow_approximate: true ' \
14
+ 'alone accepts a path read with PT-AT, in batch methods too. Remove the ' \
15
+ 'option.'
16
+ RELOAD_LIVE_ENDPOINTS = 'reload_live_endpoints: is deprecated and will be removed: skipping ' \
17
+ 'the reload saves a query for the records and one per association ' \
18
+ 'level, and reads values that may be stale. Remove the option.'
19
+
20
+ # Required when first used, as the library loads nothing it does not need.
21
+ #: () -> untyped
22
+ def self.deprecator
23
+ @deprecator ||= begin
24
+ require 'active_support/deprecation'
25
+ active_support = Object.const_get(:ActiveSupport) #: untyped
26
+ active_support.const_get(:Deprecation).new('a future release', 'paper_trail_diff')
27
+ end
28
+ end
29
+
30
+ # The one value close_on: took. It named what ends a wall-clock window,
31
+ # so it was meaningless without one, and until it is removed it is still
32
+ # checked as it was.
33
+ CLOSE_ON_CURRENT = :current
34
+
35
+ #: (Symbol?, untyped) -> void
36
+ def self.close_on!(close_on, within)
37
+ return if close_on.nil?
38
+ unless close_on == CLOSE_ON_CURRENT
39
+ raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
40
+ end
41
+ raise ConfigurationError, 'close_on: requires `within`' if within.nil?
42
+
43
+ deprecator.warn(CLOSE_ON)
44
+ end
45
+
46
+ #: (untyped) -> void
47
+ def self.allow_expensive!(allow_expensive)
48
+ deprecator.warn(ALLOW_EXPENSIVE) unless allow_expensive.nil?
49
+ end
50
+
51
+ # The value to use: the option still works until it is removed, and nil
52
+ # stands for its old default.
53
+ #: (untyped) -> untyped
54
+ def self.reload_live_endpoints!(reload)
55
+ return true if reload.nil?
56
+
57
+ deprecator.warn(RELOAD_LIVE_ENDPOINTS)
58
+ reload
59
+ end
60
+ end
61
+ end