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
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Leaves out the events of records that were outside the root's tree.
6
+ #
7
+ # A record found in the tree at one time in a range brings every version it
8
+ # saved in the range, including those it saved while it belonged elsewhere:
9
+ # a comment edited on another article before it moved to this one, or after
10
+ # it left a scope. Such a version changes nothing the root shows. As a
11
+ # boundary it would open a step of its own, and inside a transaction, where
12
+ # a change surfaces one event late, that step would take the credit for the
13
+ # root's own change.
14
+ #
15
+ # PT-AT reads associations as they were before a transaction, so a record
16
+ # is judged by two snapshots: the one at its transaction's first event, and
17
+ # the one after its last, which is the next event's or what the range
18
+ # closes on. A record in neither was outside the tree throughout.
19
+ #
20
+ # The root's own events are always kept, and so are the versions of a
21
+ # through's join rows, which a snapshot does not hold, and an event whose
22
+ # step is not reported, which only bounds one that is.
23
+ class ActivityTreeFilter
24
+ # `closing` is the snapshot after the last event: the current record's,
25
+ # or nil when nothing follows, as after the root's destruction.
26
+ #: (Array[ActivityEvent], reported: untyped, closing: RecordSnapshot?) -> void
27
+ def initialize(events, reported:, closing:)
28
+ @reported = reported
29
+ @closing = closing
30
+ @starts, @ends = spans(events)
31
+ @types = {} #: Hash[String, Array[String?]]
32
+ end
33
+
34
+ # Yields each event of the sequence with its snapshot, and whether to keep
35
+ # it, in order.
36
+ #: (untyped) { (ActivityEvent, RecordSnapshot?, bool) -> void } -> void
37
+ def call(sequence)
38
+ pairs = sequence.to_enum.to_a #: Array[[ActivityEvent, RecordSnapshot?]]
39
+ pairs.each_with_index do |(event, snapshot), index|
40
+ yield event, snapshot, keep?(event, index, pairs)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # @rbs @reported: untyped
47
+ # @rbs @closing: RecordSnapshot?
48
+ # @rbs @starts: Array[Integer]
49
+ # @rbs @ends: Array[Integer]
50
+ # @rbs @types: Hash[String, Array[String?]]
51
+
52
+ #: (ActivityEvent, Integer, Array[[ActivityEvent, RecordSnapshot?]]) -> bool
53
+ def keep?(event, index, pairs)
54
+ return true if event.root? || event.join? || !reported?(event)
55
+
56
+ position = @ends.fetch(index)
57
+ after = position < pairs.length ? pairs.fetch(position).last : @closing
58
+ holds?(pairs.fetch(@starts.fetch(index)).last, event) || holds?(after, event)
59
+ end
60
+
61
+ #: (ActivityEvent) -> bool
62
+ def reported?(event)
63
+ !@reported || @reported.call(event)
64
+ end
65
+
66
+ # Where each event's transaction starts, and the position just past its
67
+ # last event. Two transactions can interleave, so a transaction's events
68
+ # need not be adjacent.
69
+ #: (Array[ActivityEvent]) -> [Array[Integer], Array[Integer]]
70
+ def spans(events)
71
+ keys = transaction_keys(events)
72
+ first = {} #: Hash[Array[untyped] | Integer, Integer]
73
+ last = {} #: Hash[Array[untyped] | Integer, Integer]
74
+ keys.each_with_index do |key, index|
75
+ first[key] ||= index
76
+ last[key] = index
77
+ end
78
+ [keys.map { |key| first.fetch(key) }, keys.map { |key| last.fetch(key) + 1 }]
79
+ end
80
+
81
+ # A version saved outside a transaction is a transaction of its own,
82
+ # keyed by its position.
83
+ #: (Array[ActivityEvent]) -> Array[Array[untyped] | Integer]
84
+ def transaction_keys(events)
85
+ events.each_with_index.map do |event, index|
86
+ ActivityTransactionGroups.transaction_key(event.version) || index
87
+ end
88
+ end
89
+
90
+ #: (RecordSnapshot?, ActivityEvent) -> bool
91
+ def holds?(snapshot, event)
92
+ return false unless snapshot
93
+
94
+ version = event.version
95
+ types = types(version.item_type)
96
+ branches = event.branches #: Array[String]
97
+ branches.any? { |name| contains?(snapshot.associations.fetch(name), types, version.item_id) }
98
+ end
99
+
100
+ #: (AssociationSnapshot, Array[String?], untyped) -> bool
101
+ def contains?(association, types, id)
102
+ types.any? { |type| association.position(type, id) } || association.records.any? do |record|
103
+ record.associations.each_value.any? { |nested| contains?(nested, types, id) }
104
+ end
105
+ end
106
+
107
+ # A version names its record's base class, and a snapshot the record's
108
+ # own class, which may be any subclass of it.
109
+ #: (String) -> Array[String?]
110
+ def types(base_name)
111
+ @types[base_name] ||= [base_name, *Object.const_get(base_name).descendants.map(&:name)]
112
+ end
113
+ end
114
+ end
@@ -22,13 +22,15 @@ module PaperTrailDiff
22
22
  @registry = ActivityEventRegistry.new
23
23
  end
24
24
 
25
+ # A tie between version tables, or between ids that do not count up,
26
+ # raises rather than building a timeline in an arbitrary order.
25
27
  #: () -> Array[ActivityEvent]
26
28
  def call
27
29
  @registry.add(@root_versions)
28
30
  collect_tree(@record.class, [@record.id], @tree, path: '', branch: nil) unless @tree.empty?
29
- @registry.to_a.sort_by do |event|
30
- Support.chronological_version_key(event.version)
31
- end.freeze
31
+ events = @registry.to_a.sort_by { |event| Support.chronological_version_key(event.version) }
32
+ pair = Support.ambiguous_pair(events.map(&:version))
33
+ pair ? raise(AmbiguousVersionOrderError, Support.ambiguous_message(pair)) : events.freeze
32
34
  end
33
35
 
34
36
  private
@@ -92,7 +94,7 @@ module PaperTrailDiff
92
94
  # A through is always read with prepared history, which knows the
93
95
  # join row's state either side of each version.
94
96
  @registry.add(@prepared&.join_versions(group, reflection) || group.fetch(:versions),
95
- branch: branch)
97
+ branch: branch, join: true)
96
98
  source = reflection.source_reflection
97
99
  children = edge_groups(group, source, branch)
98
100
  merge_groups!(result, children)
@@ -141,7 +143,7 @@ module PaperTrailDiff
141
143
  return identity_groups(rows, reflection)
142
144
  end
143
145
  transaction_ids = parent_versions.filter_map do |version|
144
- version.transaction_id if version.respond_to?(:transaction_id)
146
+ VersionBoundary.transaction_id(version)
145
147
  end
146
148
  rows = parent_class.paper_trail.version_association_class.where(
147
149
  version_id: transaction_ids,
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # How a request presents its activity steps: whether they keep their
6
+ # snapshots, whether a transaction's steps merge into one, and which of
7
+ # their versions' recorded changes they carry. Built and checked once per
8
+ # request, and read the same way by both activity builders, or the same
9
+ # history would read differently depending on how it was asked for.
10
+ class ActivityView
11
+ # Options only an activity view uses are refused without one.
12
+ #: (activity: bool, snapshots: bool, group: Symbol?, recorded: IgnorePolicy?) -> ActivityView
13
+ def self.build(activity:, snapshots:, group:, recorded:)
14
+ view = new(snapshots: snapshots, group: group, recorded: recorded)
15
+ return view if activity
16
+
17
+ { snapshots: snapshots, recorded_changes: !recorded.nil?, group: !group.nil? }
18
+ .each do |name, given|
19
+ raise ConfigurationError, "#{name}: requires activity: true" if given
20
+ end
21
+ view
22
+ end
23
+
24
+ #: (?snapshots: bool, ?group: Symbol?, ?recorded: IgnorePolicy?) -> void
25
+ def initialize(snapshots: false, group: nil, recorded: nil)
26
+ unless group.nil? || group == :transaction
27
+ raise ConfigurationError, 'group: must be :transaction or nil'
28
+ end
29
+
30
+ @snapshots = snapshots
31
+ @group = group
32
+ @recorded = recorded
33
+ freeze
34
+ end
35
+
36
+ # Merging a group compares its outer states, so the snapshots must survive
37
+ # the build even when the caller did not ask to keep them.
38
+ #: () -> bool
39
+ def retain?
40
+ @snapshots || grouping?
41
+ end
42
+
43
+ # The attributes a boundary's version recorded, when they were asked for.
44
+ #: (root: bool) -> Array[String]?
45
+ def recorded_attributes(root:)
46
+ @recorded&.recorded_attributes(root: root)
47
+ end
48
+
49
+ # Applied last, to finished steps, so grouping sees the same timeline the
50
+ # caller would otherwise have received.
51
+ #: (Array[ActivityStep]) -> Array[ActivityStep]
52
+ def present(steps)
53
+ return steps unless grouping?
54
+
55
+ ActivityTransactionGrouper.new(steps, retain: @snapshots).call
56
+ end
57
+
58
+ private
59
+
60
+ # @rbs @snapshots: bool
61
+ # @rbs @group: Symbol?
62
+ # @rbs @recorded: IgnorePolicy?
63
+
64
+ #: () -> bool
65
+ def grouping?
66
+ @group == :transaction
67
+ end
68
+ end
69
+ end
@@ -11,7 +11,9 @@ module PaperTrailDiff
11
11
  attr_reader :to_snapshot #: RecordSnapshot?
12
12
 
13
13
  # The result for a record whose requested history contains no versions,
14
- # which is an empty history rather than a failed request.
14
+ # which is an empty history rather than a failed request. Its snapshots
15
+ # are nil because no state was read, not because the record was absent:
16
+ # reading one would rebuild the whole graph, per record in a batch.
15
17
  #: () -> Analysis
16
18
  def self.empty
17
19
  timeline = [] #: Array[Step]
@@ -31,6 +33,16 @@ module PaperTrailDiff
31
33
  freeze
32
34
  end
33
35
 
36
+ # Every activity step's rows, in time order. Only the activity timeline says
37
+ # who made each change, so an analysis built without it has no rows to give.
38
+ #: () -> Array[ChangeRow]
39
+ def change_rows
40
+ steps = activity_timeline
41
+ raise ConfigurationError, 'change_rows requires activity: true' unless steps
42
+
43
+ steps.flat_map(&:change_rows).freeze
44
+ end
45
+
34
46
  # Reconstructed endpoint states are opt-in, because they carry the whole
35
47
  # selected graph whether or not anything changed, which dwarfs the rest of
36
48
  # the payload for a wide graph.
@@ -6,16 +6,16 @@ module PaperTrailDiff
6
6
  # versions and preparing their association history once for the whole batch
7
7
  # rather than once per root.
8
8
  class AnalysisBatch
9
- #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?close_on_current: bool, ?context_required: bool) -> void
9
+ #: (Array[untyped], time_range: TimeRange?, live_loader: untyped, history_preparer: untyped, analyzer: untyped, ?version_scope: untyped, ?context_required: bool) -> void
10
10
  def initialize( # rubocop:disable Metrics/ParameterLists
11
11
  records, time_range:, live_loader:, history_preparer:, analyzer:, version_scope: nil,
12
- close_on_current: false, context_required: false
12
+ context_required: false
13
13
  )
14
14
  @records = records
15
15
  @context_required = context_required
16
16
  @time_range = time_range
17
- @version_scope = validated_scope(version_scope)
18
- @close_on_current = close_on_current
17
+ # Checked up front rather than failing partway through a batch.
18
+ @version_scope = VersionScopeFilter.validated(version_scope)
19
19
  @live_loader = live_loader
20
20
  @history_preparer = history_preparer
21
21
  @analyzer = analyzer
@@ -25,16 +25,17 @@ module PaperTrailDiff
25
25
  def call
26
26
  records = validated_records
27
27
  loaded = @live_loader.call(records)
28
+ windows = TransactionCut.batch_windows(records, @time_range)
28
29
  selected = BatchedRootVersions.new(
29
30
  records, time_range: @time_range, version_scope: @version_scope,
30
- live_endpoints: (loaded if @close_on_current),
31
- context_required: @context_required
31
+ live_endpoints: loaded,
32
+ context_required: @context_required, windows: windows
32
33
  ).call
33
- prepare(records, selected, loaded)
34
+ prepare(records, selected, loaded, windows)
34
35
  records.to_h do |record|
35
36
  key = Endpoint.identity(record)
36
37
  plan = selected.fetch(key, RootVersionPlan.empty)
37
- [Support.immutable_copy(key), analysis_for(record, plan)]
38
+ [Support.immutable_copy(key), analysis_for(record, plan, windows.fetch(key, @time_range))]
38
39
  end.freeze
39
40
  end
40
41
 
@@ -43,26 +44,19 @@ module PaperTrailDiff
43
44
  # @rbs @records: Array[untyped]
44
45
  # @rbs @time_range: TimeRange?
45
46
  # @rbs @version_scope: untyped
46
- # @rbs @close_on_current: bool
47
47
  # @rbs @context_required: bool
48
48
  # @rbs @live_loader: untyped
49
49
  # @rbs @history_preparer: untyped
50
50
  # @rbs @analyzer: untyped
51
51
 
52
- # A filter is a callable that narrows the version relation, so it is checked
53
- # up front rather than failing partway through a batch.
54
- #: (untyped) -> untyped
55
- def validated_scope(scope)
56
- return scope if scope.nil? || scope.respond_to?(:call)
57
-
58
- raise ConfigurationError, 'version_scope: must respond to call'
59
- end
60
-
61
52
  #: () -> Array[untyped]
62
53
  def validated_records
63
54
  raise ConfigurationError, 'records: must be an array' unless @records.is_a?(Array)
64
55
 
65
- @records.each { |record| Endpoint.validate!(record) }
56
+ @records.each do |record|
57
+ Endpoint.validate_root!(record)
58
+ Endpoint.validate!(record)
59
+ end
66
60
  identities = @records.map { |record| Endpoint.identity(record) }
67
61
  return @records if identities.uniq.length == identities.length
68
62
 
@@ -74,8 +68,8 @@ module PaperTrailDiff
74
68
  # The roots are preloaded first, because preparation reads their current
75
69
  # association state as a fallback and would otherwise walk it one root at a
76
70
  # time.
77
- #: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped]) -> void
78
- def prepare(records, selected, loaded)
71
+ #: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], untyped], Hash[Array[String], TimeRange]) -> void
72
+ def prepare(records, selected, loaded, windows)
79
73
  records.group_by(&:class).each_value do |grouped|
80
74
  versions = grouped.flat_map do |record|
81
75
  selected.fetch(Endpoint.identity(record), RootVersionPlan.empty).reconstruction_versions
@@ -84,7 +78,7 @@ module PaperTrailDiff
84
78
 
85
79
  roots = grouped.map { |record| loaded.fetch(Endpoint.identity(record), record) }
86
80
  live = closes_live?(grouped, selected)
87
- @history_preparer.call(roots, versions, **preparation_range(versions, live: live))
81
+ @history_preparer.call(roots, versions, **preparation_range(versions, windows, live: live))
88
82
  end
89
83
  end
90
84
 
@@ -97,21 +91,21 @@ module PaperTrailDiff
97
91
 
98
92
  # A plan closing on the live record reads history up to now; the others
99
93
  # stop at the window, so a past window does not load everything since.
100
- #: (Array[untyped], ?live: bool) -> Hash[Symbol, untyped]
101
- def preparation_range(versions, live: @close_on_current)
102
- last = versions.map(&:created_at).max
103
- end_at = [last, @time_range&.end_time, (Time.now.utc if live)].compact.max
104
- { start_at: @time_range&.begin_time || versions.map(&:created_at).min, end_at: end_at }
94
+ #: (Array[untyped], Hash[Array[String], TimeRange], live: bool) -> Hash[Symbol, untyped]
95
+ def preparation_range(versions, windows, live:)
96
+ times = versions.map(&:created_at)
97
+ end_at = [times.max, @time_range&.end_time, (Time.now.utc if live)].compact.max
98
+ { start_at: TransactionCut.earliest_start(@time_range, windows) || times.min, end_at: end_at }
105
99
  end
106
100
 
107
101
  # A root with no versions in range has nothing to report, which is an empty
108
102
  # result rather than a failed request -- unless an activity view closes it
109
103
  # on current state, where its descendants may still have moved.
110
- #: (untyped, RootVersionPlan) -> Analysis
111
- def analysis_for(record, plan)
104
+ #: (untyped, RootVersionPlan, TimeRange?) -> Analysis
105
+ def analysis_for(record, plan, window)
112
106
  return Analysis.empty if plan.empty? && !plan.closing_record
113
107
 
114
- @analyzer.call(record, plan)
108
+ @analyzer.call(record, plan, window)
115
109
  end
116
110
  end
117
111
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # The requested paths prepared history cannot read exactly, each with why.
6
+ # PT-AT reads such a path instead, one record at a time, when the caller
7
+ # passes `allow_approximate: true`.
8
+ module ApproximatePaths
9
+ #: (AssociationTraversal, untyped) -> Array[[String, String]]
10
+ def self.call(traversal, model)
11
+ traversal.selected_reflections(model).filter_map do |path, reflection|
12
+ reason = fallback_reason(reflection, ranked: true)
13
+ [path, reason] if reason
14
+ end
15
+ end
16
+
17
+ # A has_many through reads its parts' conditions only, at any depth. A
18
+ # has_one through over a ranked part is refused whole, so its parts, too,
19
+ # are checked for conditions alone.
20
+ #: (untyped, ranked: bool) -> String?
21
+ def self.fallback_reason(reflection, ranked:)
22
+ reason = reason_for(reflection, ranked)
23
+ return reason if reason || !reflection.through_reflection?
24
+
25
+ [reflection.through_reflection, reflection.source_reflection].each do |part|
26
+ part_reason = fallback_reason(part, ranked: false)
27
+ return part_reason if part_reason
28
+ end
29
+ nil
30
+ end
31
+ private_class_method :fallback_reason
32
+
33
+ # A scope prepared history can read as conditions on stored attributes
34
+ # is batched like any other path. A polymorphic target's class, and so
35
+ # its scope, is known only per record.
36
+ #: (untyped, bool) -> String?
37
+ def self.reason_for(reflection, ranked)
38
+ return ('association scope' if reflection.scope) if reflection.polymorphic?
39
+ if ranked && ScopeCondition.ranked_through?(reflection)
40
+ return 'limit, offset or has_one order inside a has_one through'
41
+ end
42
+ return if ScopeCondition.build(reflection, ranked: ranked)
43
+
44
+ reflection.scope ? 'association scope' : 'default-scoped target'
45
+ end
46
+ private_class_method :reason_for
47
+ end
48
+ end
@@ -74,10 +74,10 @@ module PaperTrailDiff
74
74
  #: (untyped) -> untyped
75
75
  def relation(filter)
76
76
  versions = { item_type: @reflection.klass.base_class.name, item_id: filter }
77
- relation = @associations.joins(:version)
78
- .where(foreign_key_name: @reflection.foreign_key,
79
- foreign_type: owner_types)
80
- .where(@versions.table_name => versions)
77
+ relation = VersionTable.joined(@associations, @versions)
78
+ .where(foreign_key_name: @reflection.foreign_key,
79
+ foreign_type: owner_types)
80
+ .where(@versions.table_name => versions)
81
81
  candidates(@versions, relation)
82
82
  end
83
83
 
@@ -136,8 +136,8 @@ module PaperTrailDiff
136
136
 
137
137
  #: (Hash[Symbol, untyped]) -> untyped
138
138
  def owner_rows(conditions)
139
- @associations.joins(:version).where(conditions)
140
- .where(@versions.table_name => { item_type: @owner.base_class.name })
139
+ VersionTable.joined(@associations, @versions).where(conditions)
140
+ .where(@versions.table_name => { item_type: @owner.base_class.name })
141
141
  end
142
142
  end
143
143
 
@@ -102,11 +102,19 @@ module PaperTrailDiff
102
102
  #: (untyped, root: bool) -> Array[untyped]
103
103
  def supported_reflections(model_class, root:)
104
104
  reflections = model_class.reflect_on_all_associations.reject do |reflection|
105
- AssociationTraversal.unsupported_reason(model_class, reflection, root: root)
105
+ AssociationTraversal.unsupported_reason(model_class, reflection, root: root) ||
106
+ unversioned?(reflection)
106
107
  end
107
108
  reflections.sort_by { |reflection| reflection.name.to_s }
108
109
  end
109
110
 
111
+ # Every request that reads history refuses a path whose target, or a
112
+ # model its has_many :through goes through, has no versions.
113
+ #: (untyped) -> bool
114
+ def unversioned?(reflection)
115
+ TraversalPreparer.unversioned_models(reflection).any?
116
+ end
117
+
110
118
  #: (untyped) -> untyped
111
119
  def reflection_target(reflection)
112
120
  reflection.klass unless reflection.polymorphic?
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # PT-AT records which record a child pointed at by that record's id, and
6
+ # history matches children to owners and targets by id. An association
7
+ # keyed on another column (`primary_key:`) would read other records'
8
+ # history, so it is refused.
9
+ module AssociationKey
10
+ # Why the association, or a part of a through, cannot be read by id.
11
+ #: (untyped) -> String?
12
+ def self.unsupported_reason(reflection)
13
+ part = keyed_part(reflection)
14
+ return unless part
15
+
16
+ "#{part.active_record.name}##{part.name} matches records by " \
17
+ "#{part.options[:primary_key]} rather than the primary key, and association " \
18
+ 'history is recorded by primary key'
19
+ end
20
+
21
+ # The reflection, or the first part of a through, keyed on a column other
22
+ # than its owner's or target's primary key.
23
+ #: (untyped) -> untyped
24
+ def self.keyed_part(reflection)
25
+ return reflection if other_key?(reflection)
26
+ return unless reflection.through_reflection?
27
+
28
+ [reflection.through_reflection, reflection.source_reflection].filter_map do |part|
29
+ keyed_part(part)
30
+ end.first
31
+ end
32
+ private_class_method :keyed_part
33
+
34
+ # A belongs_to names a key of its target; any other macro, one of its
35
+ # owner. A polymorphic target has no single primary key to compare with.
36
+ #: (untyped) -> bool
37
+ def self.other_key?(reflection)
38
+ key = reflection.options[:primary_key]
39
+ return false unless key
40
+ return true if reflection.polymorphic?
41
+
42
+ model = reflection.belongs_to? ? reflection.klass : reflection.active_record
43
+ !same_column?(key, model.primary_key)
44
+ end
45
+ private_class_method :other_key?
46
+
47
+ # Column names are strings once the key is, so the one mutation left,
48
+ # `eql?` for `==`, answers the same.
49
+ # mutant:disable
50
+ #: (untyped, untyped) -> bool
51
+ def self.same_column?(key, primary_key)
52
+ key.to_s == primary_key
53
+ end
54
+ private_class_method :same_column?
55
+ end
56
+ end
@@ -3,7 +3,7 @@
3
3
 
4
4
  module PaperTrailDiff
5
5
  # Resolves and validates ActiveRecord reflections for a bounded association tree.
6
- class AssociationTraversal
6
+ class AssociationTraversal # rubocop:disable Metrics/ClassLength
7
7
  # The class a `belongs_to` points at for this record, reading a polymorphic
8
8
  # type column when there is one. Nil when the record points at nothing.
9
9
  #: (untyped, untyped) -> untyped
@@ -66,15 +66,24 @@ module PaperTrailDiff
66
66
  def self.unsupported_reason(model_class, reflection, root:)
67
67
  return 'PaperTrail versions' if paper_trail_versions?(model_class, reflection)
68
68
  return reflection.macro.to_s unless SUPPORTED_ASSOCIATION_MACROS.include?(reflection.macro)
69
- return habtm_reason(reflection, root) if reflection.macro == :has_and_belongs_to_many
69
+ if reflection.macro == :has_and_belongs_to_many
70
+ return habtm_reason(reflection, root) || VersionTable.unsupported_reason(reflection)
71
+ end
70
72
 
71
73
  habtm = habtm_in_chain(reflection)
72
- return unless habtm
74
+ return storage_reason(reflection) unless habtm
73
75
 
74
76
  "it goes through #{habtm.active_record.name}##{habtm.name}, a has_and_belongs_to_many, " \
75
77
  'whose hidden join model has no history to compare'
76
78
  end
77
79
 
80
+ # History matches records by primary key, within one set of version tables.
81
+ #: (untyped) -> String?
82
+ def self.storage_reason(reflection)
83
+ AssociationKey.unsupported_reason(reflection) || VersionTable.unsupported_reason(reflection)
84
+ end
85
+ private_class_method :storage_reason
86
+
78
87
  # PT-AT records a HABTM's membership only when its owner saves a version,
79
88
  # in rows keyed by transaction and association name, not by owner. A root
80
89
  # checkpoint so holds the root's membership and no one else's: another
@@ -166,7 +175,7 @@ module PaperTrailDiff
166
175
  def ensure_habtm_history!(model_class, version)
167
176
  paths = habtm_paths(model_class)
168
177
  return if paths.empty?
169
- return if version.respond_to?(:transaction_id) && version.transaction_id
178
+ return if VersionBoundary.transaction_id(version)
170
179
 
171
180
  message = "HABTM history is incomplete at version #{version.id}: #{paths.join(', ')}"
172
181
  raise IncompleteAssociationHistoryError, message
@@ -113,10 +113,9 @@ module PaperTrailDiff
113
113
  # a tie -- refusing one that ids cannot order.
114
114
  #: (untyped, Array[untyped]) -> Hash[String, untyped]
115
115
  def first_versions(model_class, ids)
116
- version_class = model_class.paper_trail.version_class
117
- table = version_class.arel_table
118
- candidates = version_class.where(item_type: model_class.base_class.name.to_s, item_id: ids)
119
- .where(VersionQuery.no_earlier_version(table)).to_a
116
+ table = model_class.paper_trail.version_class.arel_table
117
+ candidates = VersionQuery.for_items(model_class, ids)
118
+ .where(VersionQuery.no_earlier_version(table)).to_a
120
119
  candidates.group_by { |version| version.item_id.to_s }
121
120
  .transform_values { |versions| Support.chronological_sort(versions).first }
122
121
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Which associations Active Record's preloader loads correctly for many
6
+ # owners at once.
7
+ module BatchPreload
8
+ # The preloader applies a limit or offset to all owners' records at once,
9
+ # not to each owner's, so such an association is loaded per record. A
10
+ # has_one is no exception. Through a part with a limit, an offset or a
11
+ # has_one cut, the preloader keeps what the part alone would load, while
12
+ # the through's own query keeps the part's conditions only.
13
+ #
14
+ # The declared scope is read, not a record's association scope: Active
15
+ # Record adds `limit(1)` to every has_one's, which would load every
16
+ # has_one per record.
17
+ #: (untyped) -> bool
18
+ def self.safe?(reflection)
19
+ scope = reflection.scope
20
+ return false if scope && !scope.arity.zero?
21
+ return true unless reflection.collection? || reflection.has_one?
22
+ return false if cut_part?(reflection)
23
+
24
+ relation = declared_relation(reflection)
25
+ relation.limit_value.nil? && relation.offset_value.nil?
26
+ end
27
+
28
+ #: (untyped) -> untyped
29
+ def self.declared_relation(reflection)
30
+ relation = reflection.klass.default_scoped
31
+ reflection.scope ? reflection.scope_for(relation) : relation
32
+ end
33
+ private_class_method :declared_relation
34
+
35
+ #: (untyped) -> bool
36
+ def self.cut_part?(reflection)
37
+ reflection.collection? &&
38
+ (ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
39
+ end
40
+ private_class_method :cut_part?
41
+
42
+ # Every part a through passes, at any depth.
43
+ #: (untyped) -> Array[untyped]
44
+ def self.through_parts(reflection)
45
+ return [] unless reflection.through_reflection?
46
+
47
+ [reflection.through_reflection, reflection.source_reflection].flat_map do |part|
48
+ [part, *through_parts(part)]
49
+ end
50
+ end
51
+ private_class_method :through_parts
52
+ end
53
+ end