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
@@ -2,22 +2,35 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Gives TimelineBuilder one-argument access to a range-prepared snapshot store.
5
+ # Prepares the snapshot store for TimelineBuilder, one root at a time.
6
6
  class TimelineSnapshotProvider
7
7
  #: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
8
8
  def initialize(store, live_snapshotter:)
9
9
  @store = store
10
10
  @live_snapshotter = live_snapshotter
11
- @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
12
11
  end
13
12
 
14
- #: (untyped, Array[untyped]) -> void
13
+ #: (untyped, Array[untyped]) -> PreparedTimelineSnapshots
15
14
  def prepare(record, versions)
16
- # Adjacent steps share endpoints. Keep their immutable snapshots for this
17
- # root only, including nil create states, and release them before the next
18
- # root so batch memory does not retain every reconstructed graph.
19
- @snapshots.clear
20
- @store.prepare(record, versions)
15
+ history = @store.prepare(record, versions)
16
+ PreparedTimelineSnapshots.new(history, store: @store, live_snapshotter: @live_snapshotter)
17
+ end
18
+
19
+ # @rbs @store: HistoricalSnapshotStore
20
+ # @rbs @live_snapshotter: untyped
21
+ end
22
+
23
+ # One root's timeline snapshots, read with the history prepared for it.
24
+ # Adjacent steps share endpoints, so each immutable snapshot, including a
25
+ # nil create state, is kept for this root only and released with it, and
26
+ # batch memory does not retain every reconstructed graph.
27
+ class PreparedTimelineSnapshots
28
+ #: (PreparedHistory?, store: HistoricalSnapshotStore, live_snapshotter: untyped) -> void
29
+ def initialize(history, store:, live_snapshotter:)
30
+ @history = history
31
+ @store = store
32
+ @live_snapshotter = live_snapshotter
33
+ @snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
21
34
  end
22
35
 
23
36
  # A window closing on current state ends at the live record rather than at a
@@ -28,19 +41,13 @@ module PaperTrailDiff
28
41
  return @snapshots[key] if @snapshots.key?(key)
29
42
 
30
43
  @snapshots[key] = if Endpoint.record?(endpoint)
31
- live_snapshot(endpoint)
44
+ @live_snapshotter.call(endpoint)
32
45
  else
33
- @store.uncached(endpoint, endpoint)
46
+ @store.uncached(endpoint, endpoint, history: @history)
34
47
  end
35
48
  end
36
49
 
37
- private
38
-
39
- #: (untyped) -> RecordSnapshot?
40
- def live_snapshot(record)
41
- @live_snapshotter.call(record)
42
- end
43
-
50
+ # @rbs @history: PreparedHistory?
44
51
  # @rbs @store: HistoricalSnapshotStore
45
52
  # @rbs @live_snapshotter: untyped
46
53
  # @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Moves each edge of a wall-clock window that falls inside one of a root's
6
+ # transactions back to where that transaction began, so the transaction is
7
+ # wholly in the later window.
8
+ #
9
+ # PT-AT reads a root's associations at the start of the transaction of the
10
+ # root version it reifies. A window starting just after a child saved its
11
+ # part of such a transaction therefore starts from the state before that
12
+ # child's change, and one ending there ends at it too, so the change belonged
13
+ # to no window and surfaced at the next transaction, under its author.
14
+ #
15
+ # Only the root's own versions on either side of an edge are consulted:
16
+ # rows locked by an update keep two transactions of one root from
17
+ # interleaving, so a transaction holding a root version and crossing the edge
18
+ # holds the root's nearest version on one side of it. Each edge is moved from
19
+ # where it was asked for, never from where another edge moved, so adjacent
20
+ # windows move their shared edge alike.
21
+ module TransactionCut
22
+ # One edge of a window. A strict edge keeps versions recorded at its
23
+ # instant on the left, as an inclusive end does. A plain class rather than
24
+ # Data.define, which arrived in Ruby 3.2 while this gem supports 3.1.
25
+ class Edge
26
+ #: (Time, bool) -> void
27
+ def initialize(time, strict)
28
+ @time = time
29
+ @strict = strict
30
+ freeze
31
+ end
32
+
33
+ #: (untyped) -> untyped
34
+ def left(column)
35
+ @strict ? column.lteq(@time) : column.lt(@time)
36
+ end
37
+
38
+ #: (untyped) -> untyped
39
+ def right(column)
40
+ @strict ? column.gt(@time) : column.gteq(@time)
41
+ end
42
+
43
+ #: (untyped, later: bool) -> untyped
44
+ def side(column, later:)
45
+ later ? right(column) : left(column)
46
+ end
47
+
48
+ # @rbs @time: Time
49
+ # @rbs @strict: bool
50
+ end
51
+
52
+ # Roots per statement: two lookups each stays under SQLite's limit of 500
53
+ # terms in a compound SELECT.
54
+ SLICE = 200
55
+ private_constant :SLICE
56
+
57
+ # The same as `windows` for a batch of roots, keyed by identity.
58
+ #: (Array[untyped], TimeRange?) -> Hash[Array[String], TimeRange]
59
+ def self.batch_windows(records, range)
60
+ return {} unless range
61
+
62
+ moved = {} #: Hash[Array[String], TimeRange]
63
+ records.group_by(&:class).each_with_object(moved) do |(model_class, group), by_identity|
64
+ identities = group.to_h { |record| [record.id.to_s, Endpoint.identity(record)] }
65
+ windows(model_class, group, range).each do |id, window|
66
+ by_identity[identities.fetch(id)] = window
67
+ end
68
+ end
69
+ end
70
+
71
+ # Where history for a batch must be read from: the earliest moved start.
72
+ #: (TimeRange?, Hash[Array[String], TimeRange]) -> Time?
73
+ def self.earliest_start(range, windows)
74
+ return unless range
75
+
76
+ [range.begin_time, *windows.each_value.map(&:begin_time)].min
77
+ end
78
+
79
+ # The windows that move, keyed by root id. A root missing from the result
80
+ # keeps the window as asked.
81
+ #: (untyped, Array[untyped], TimeRange) -> Hash[String, TimeRange]
82
+ def self.windows(model_class, records, range)
83
+ versions = transaction_versions(model_class)
84
+ return {} unless versions
85
+
86
+ starts = moved_edges(versions, records, Edge.new(range.begin_time, false))
87
+ ends = moved_edges(versions, records, Edge.new(range.end_time, !range.exclude_end?))
88
+ (starts.keys | ends.keys).to_h { |id| [id, moved(range, starts[id], ends[id])] }
89
+ end
90
+
91
+ #: (untyped) -> untyped
92
+ def self.transaction_versions(model_class)
93
+ return unless model_class.paper_trail.version_class.column_names.include?('transaction_id')
94
+
95
+ VersionQuery.for_model(model_class)
96
+ end
97
+ private_class_method :transaction_versions
98
+
99
+ # Each root's earliest start among its transactions that cross the edge.
100
+ #: (untyped, Array[untyped], Edge) -> Hash[String, Time]
101
+ def self.moved_edges(versions, records, edge)
102
+ roots = nearest_roots(versions, records, edge)
103
+ moved = {} #: Hash[String, Time]
104
+ starts = crossing_starts(versions.klass, roots.keys, edge)
105
+ starts.each_with_object(moved) do |(transaction, start), edges|
106
+ roots.fetch(transaction).each { |id| edges[id] = [edges[id], start].compact.min }
107
+ end
108
+ end
109
+ private_class_method :moved_edges
110
+
111
+ # The roots whose versions next to the edge each transaction holds.
112
+ #: (untyped, Array[untyped], Edge) -> Hash[untyped, Array[String]]
113
+ def self.nearest_roots(versions, records, edge)
114
+ records.each_slice(SLICE).flat_map { |slice| nearest(versions, slice, edge) }
115
+ .group_by(&:last).transform_values { |pairs| pairs.map(&:first) }
116
+ end
117
+ private_class_method :nearest_roots
118
+
119
+ # Each root's nearest versions on either side of an edge, as [id,
120
+ # transaction] pairs; versions tied at the nearest instant are all kept.
121
+ # One indexed lookup per root and side, where a single query over the
122
+ # roots would read every version before the edge. The lookups are written
123
+ # once with the root's id left open, as building a relation for each root
124
+ # costs more than the query. Rows read as SQL text come back as the adapter
125
+ # returns them, so the transaction is cast as the grouped query's keys are.
126
+ #: (untyped, Array[untyped], Edge) -> Array[[String, untyped]]
127
+ def self.nearest(versions, records, edge)
128
+ lookup = nearest_lookups(versions, edge)
129
+ item_id = versions.type_for_attribute('item_id')
130
+ lookups = records.map do |record|
131
+ versions.sanitize_sql_array([lookup, *Array.new(4, item_id.serialize(record.id))])
132
+ end
133
+ transaction = versions.type_for_attribute('transaction_id')
134
+ versions.connection.select_rows(lookups.join(' UNION ALL ')).map do |id, value|
135
+ [id.to_s, transaction.cast(value)]
136
+ end
137
+ end
138
+ private_class_method :nearest
139
+
140
+ # Both sides' lookups for a root whose id is each `?`.
141
+ #: (untyped, Edge) -> String
142
+ def self.nearest_lookups(versions, edge)
143
+ own = versions.where("#{versions.quoted_table_name}.item_id = ?")
144
+ "#{nearest_lookup(own, edge, false)} UNION ALL #{nearest_lookup(own, edge, true)}"
145
+ end
146
+ private_class_method :nearest_lookups
147
+
148
+ #: (untyped, Edge, bool) -> String
149
+ def self.nearest_lookup(own, edge, later)
150
+ column = own.arel_table[:created_at]
151
+ instant = own.where(edge.side(column, later: later))
152
+ .reorder(created_at: later ? :asc : :desc).limit(1).select(:created_at)
153
+ own.where(column.eq(instant.arel)).where.not(transaction_id: nil)
154
+ .select(:item_id, :transaction_id).to_sql
155
+ end
156
+ private_class_method :nearest_lookup
157
+
158
+ # Where each transaction that has versions on both sides of the edge began.
159
+ #: (untyped, Array[untyped], Edge) -> Hash[untyped, Time]
160
+ def self.crossing_starts(version_class, transactions, edge)
161
+ return {} if transactions.empty?
162
+
163
+ column = version_class.arel_table[:created_at]
164
+ scope = version_class.where(transaction_id: transactions)
165
+ scope.where(edge.left(column))
166
+ .where(transaction_id: scope.where(edge.right(column)).select(:transaction_id))
167
+ .group(:transaction_id).minimum(:created_at)
168
+ end
169
+ private_class_method :crossing_starts
170
+
171
+ #: (TimeRange, Time?, Time?) -> TimeRange
172
+ def self.moved(range, start, finish)
173
+ begin_time = start || range.begin_time
174
+ return TimeRange.new(begin_time...[finish, begin_time].max) if finish
175
+ return TimeRange.new(begin_time...range.end_time) if range.exclude_end?
176
+
177
+ TimeRange.new(begin_time..range.end_time)
178
+ end
179
+ private_class_method :moved
180
+ end
181
+ end
@@ -4,62 +4,167 @@
4
4
  module PaperTrailDiff
5
5
  # Checks that a model can be traversed before any reconstruction starts, and
6
6
  # that association tracking is actually available when history is involved.
7
+ # `diagnose` reports the same refusals instead of raising the first.
7
8
  class TraversalPreparer
8
- #: (tree: AssociationTree, traversal: AssociationTraversal) -> void
9
- def initialize(tree:, traversal:)
9
+ # One reason to refuse a request: the error `call` raises, and the code
10
+ # and path `diagnose` reports.
11
+ class Refusal
12
+ attr_reader :error #: untyped
13
+ attr_reader :code #: Symbol
14
+ attr_reader :message #: String
15
+ attr_reader :path #: String?
16
+
17
+ #: (untyped, Symbol, String, ?String?) -> void
18
+ def initialize(error, code, message, path = nil)
19
+ @error = error
20
+ @code = code
21
+ @message = message
22
+ @path = path
23
+ freeze
24
+ end
25
+ end
26
+
27
+ PROBLEMS = {
28
+ unversioned_association_target: '%<model>s is not versioned',
29
+ unversioned_through_model: 'it goes through %<model>s, which is not versioned'
30
+ }.freeze
31
+
32
+ # Each model a path's history needs versions of and has none, with the
33
+ # refusal's code: the target, then each model a has_many :through goes
34
+ # through. A polymorphic target is not known until a row names it;
35
+ # `diagnose` reports that separately rather than guessing here.
36
+ #: (untyped) -> Array[[Symbol, untyped]]
37
+ def self.unversioned_models(reflection)
38
+ return [] if reflection.polymorphic?
39
+
40
+ target = [:unversioned_association_target, reflection.klass] #: [Symbol, untyped]
41
+ through = through_models(reflection).map do |model|
42
+ [:unversioned_through_model, model] #: [Symbol, untyped]
43
+ end
44
+ [target, *through].reject { |_code, model| Support.versioned?(model) }
45
+ end
46
+
47
+ # A has_many :through reads past membership from the versions of the
48
+ # models it goes through, so each of them needs history too.
49
+ #: (untyped) -> Array[untyped]
50
+ def self.through_models(reflection)
51
+ return [] unless reflection.through_reflection?
52
+
53
+ [reflection.through_reflection, reflection.source_reflection].flat_map do |part|
54
+ part.polymorphic? ? through_models(part) : [part.klass, *through_models(part)]
55
+ end.uniq - [reflection.klass]
56
+ end
57
+ private_class_method :through_models
58
+
59
+ #: (tree: AssociationTree, traversal: AssociationTraversal, ?allow_approximate: bool) -> void
60
+ def initialize(tree:, traversal:, allow_approximate: false)
61
+ unless [true, false].include?(allow_approximate)
62
+ raise ConfigurationError, 'allow_approximate: must be true or false'
63
+ end
64
+
10
65
  @tree = tree
11
66
  @traversal = traversal
67
+ @allow_approximate = allow_approximate
68
+ @refusals = {} #: Hash[Array[untyped], Array[Refusal]]
12
69
  end
13
70
 
14
71
  #: (untyped, historical: bool) -> void
15
72
  def call(model_class, historical:)
16
- return if @tree.empty?
73
+ refusal = refusals(model_class, historical: historical).first
74
+ raise refusal.error, refusal.message if refusal
75
+ end
17
76
 
18
- ensure_association_tracking! if historical
19
- @traversal.validate!(model_class)
20
- ensure_versioned_targets!(model_class) if historical
77
+ # Every reason to refuse a request over the model, in the order `call`
78
+ # raises them. Live-to-live comparison reads current state, so only the
79
+ # traversal itself is checked for it.
80
+ #: (untyped, historical: bool) -> Array[Refusal]
81
+ def refusals(model_class, historical:)
82
+ return [] if @tree.empty?
83
+
84
+ @refusals[[model_class, historical]] ||= collect(model_class, historical).freeze
21
85
  end
22
86
 
23
87
  private
24
88
 
25
89
  # @rbs @tree: AssociationTree
26
90
  # @rbs @traversal: AssociationTraversal
91
+ # @rbs @allow_approximate: bool
92
+ # @rbs @refusals: Hash[Array[untyped], Array[Refusal]]
93
+
94
+ #: (untyped, bool) -> Array[Refusal]
95
+ def collect(model_class, historical)
96
+ found = [] #: Array[Refusal]
97
+ found << tracking_refusal if historical && !association_tracking?
98
+ unresolved = traversal_refusal(model_class)
99
+ return found << unresolved if unresolved
100
+ return found unless historical
101
+
102
+ found.concat(unversioned_refusals(model_class), approximate_refusals(model_class))
103
+ end
104
+
105
+ #: (untyped) -> Refusal?
106
+ def traversal_refusal(model_class)
107
+ @traversal.validate!(model_class)
108
+ nil
109
+ rescue UnknownAssociationError, UnsupportedAssociationError => e
110
+ code = e.is_a?(UnknownAssociationError) ? :unknown_association : :unsupported_association
111
+ Refusal.new(e.class, code, e.message)
112
+ end
113
+
114
+ # A path prepared history cannot read is read with PT-AT, which rebuilds
115
+ # an association from its current members, swapping in older versions of
116
+ # them. A record that entered the association since, such as one moved
117
+ # into a scope or restored from a soft delete, then reads as a member it
118
+ # was not, and one that left reads as missing. Only a caller who accepts
119
+ # that may read such a path.
120
+ #: (untyped) -> Array[Refusal]
121
+ def approximate_refusals(model_class)
122
+ return [] if @allow_approximate
123
+
124
+ ApproximatePaths.call(@traversal, model_class).map do |path, reason|
125
+ message = "association #{path} cannot be read exactly from history (#{reason}): " \
126
+ 'PT-AT rebuilds it from its current members, so a record that entered or ' \
127
+ 'left it since is reported wrongly; remove this path, make its scope plain ' \
128
+ 'conditions on the target\'s own columns, or pass allow_approximate: true'
129
+ Refusal.new(UnsupportedAssociationError, :approximate_association, message, path)
130
+ end
131
+ end
27
132
 
28
133
  # A model PaperTrail never versioned has no history to reconstruct, so a
29
134
  # comparison over it can only ever answer "nothing changed" -- which is a
30
135
  # wrong answer rather than an empty one. Live-to-live comparison reads
31
136
  # current state and is unaffected, so this applies to historical work only.
32
- #: (untyped) -> void
33
- def ensure_versioned_targets!(model_class)
34
- @traversal.selected_reflections(model_class).each do |path, reflection|
35
- # A polymorphic target is not known until a row names it; `diagnose`
36
- # reports that separately rather than guessing here.
37
- next if reflection.polymorphic?
38
- next if versioned?(reflection.klass)
39
-
40
- raise UnversionedAssociationError,
41
- "association #{path} cannot be compared historically: " \
42
- "#{reflection.klass.name} is not versioned. Add `has_paper_trail` to it, " \
43
- 'or mirror the fields you need onto a model that has it.'
137
+ #: (untyped) -> Array[Refusal]
138
+ def unversioned_refusals(model_class)
139
+ @traversal.selected_reflections(model_class).flat_map do |path, reflection|
140
+ self.class.unversioned_models(reflection).map do |code, model|
141
+ unversioned(path, model, code)
142
+ end
44
143
  end
45
144
  end
46
145
 
47
- #: (untyped) -> bool
48
- def versioned?(model_class)
49
- Support.versioned?(model_class)
146
+ #: (String, untyped, Symbol) -> Refusal
147
+ def unversioned(path, model_class, code)
148
+ problem = format(PROBLEMS.fetch(code), model: model_class.name)
149
+ message = "association #{path} cannot be compared historically: #{problem}. " \
150
+ "Add `has_paper_trail` to #{model_class.name}, or mirror the fields you need " \
151
+ 'onto a model that has it.'
152
+ Refusal.new(UnversionedAssociationError, code, message, path)
50
153
  end
51
154
 
52
- #: () -> void
53
- def ensure_association_tracking!
155
+ #: () -> bool
156
+ def association_tracking?
54
157
  paper_trail = Object.const_get(:PaperTrail) #: untyped
55
158
  config = paper_trail.config #: untyped
56
- available = defined?(::PaperTrailAssociationTracking) &&
57
- config.respond_to?(:track_associations?) &&
58
- config.track_associations?
59
- return if available
159
+ !!(defined?(::PaperTrailAssociationTracking) &&
160
+ config.respond_to?(:track_associations?) && config.track_associations?)
161
+ end
60
162
 
61
- message = 'association tracking must be loaded and enabled to compare historical associations'
62
- raise AssociationTrackingUnavailableError, message
163
+ #: () -> Refusal
164
+ def tracking_refusal
165
+ Refusal.new(AssociationTrackingUnavailableError, :association_tracking_unavailable,
166
+ 'association tracking must be loaded and enabled to compare historical ' \
167
+ 'associations')
63
168
  end
64
169
  end
65
170
  end
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.14.0'
5
+ VERSION = '0.15.0'
6
6
  end
@@ -18,13 +18,49 @@ module PaperTrailDiff
18
18
  return true unless version_record?(boundary)
19
19
  return true if same_version?(candidate, boundary)
20
20
 
21
- unless orderable_tie?(candidate, boundary)
21
+ unless Support.orderable_tie?(candidate, boundary)
22
22
  raise AmbiguousVersionOrderError, Support.ambiguous_message([boundary, candidate])
23
23
  end
24
24
 
25
25
  Support.compare_versions(candidate, boundary) >= 0
26
26
  end
27
27
 
28
+ # A range starting at a version starts before that version's transaction,
29
+ # as PT-AT reads associations there. A version saved earlier in the same
30
+ # transaction, such as the child update behind a `touch: true` parent
31
+ # version, can be recorded before the start, so the start moves back to
32
+ # the earliest version of each root's first transaction in the range.
33
+ # History loaded from the start alone would read that child after its
34
+ # change.
35
+ #: (Array[untyped], untyped) -> untyped
36
+ def transaction_start(root_versions, start_at)
37
+ opening = opening_transactions(root_versions, start_at)
38
+ return start_at if opening.empty?
39
+
40
+ earliest = root_versions.first.class.where(transaction_id: opening).minimum(:created_at)
41
+ earliest && earliest < start_at ? earliest : start_at
42
+ end
43
+
44
+ # The transaction of each root's first version at or after the start.
45
+ #: (Array[untyped], untyped) -> Array[untyped]
46
+ def opening_transactions(root_versions, start_at)
47
+ return [] unless start_at
48
+
49
+ root_versions.reject { |version| version.created_at < start_at }
50
+ .sort_by { |version| Support.chronological_version_key(version) }
51
+ .uniq { |version| [version.item_type.to_s, version.item_id.to_s] }
52
+ .filter_map { |version| transaction_id(version) }.uniq
53
+ end
54
+ private_class_method :opening_transactions
55
+
56
+ # A custom version class need not carry the column, and PaperTrail leaves
57
+ # it nil outside a transaction. Both read as "no transaction here", which
58
+ # groups with nothing.
59
+ #: (untyped) -> untyped
60
+ def transaction_id(version)
61
+ version.transaction_id if version.respond_to?(:transaction_id)
62
+ end
63
+
28
64
  #: (untyped) -> bool
29
65
  def version_record?(value)
30
66
  value.respond_to?(:id) && value.respond_to?(:item_type) &&
@@ -37,12 +73,5 @@ module PaperTrailDiff
37
73
  left.class.table_name == right.class.table_name && left.id.to_s == right.id.to_s
38
74
  end
39
75
  private_class_method :same_version?
40
-
41
- #: (untyped, untyped) -> bool
42
- def orderable_tie?(left, right)
43
- left.class.table_name == right.class.table_name &&
44
- Support.sequential_id?(left.id) && Support.sequential_id?(right.id)
45
- end
46
- private_class_method :orderable_tie?
47
76
  end
48
77
  end
@@ -14,28 +14,52 @@ module PaperTrailDiff
14
14
  relation.reorder(created_at: :asc, id: :asc)
15
15
  end
16
16
 
17
+ # Every version of a model's items. They are read from the version class
18
+ # rather than through the model's versions association, whose scope may
19
+ # leave some out: a history replayed without them credits their changes to
20
+ # the next version kept.
21
+ #: (untyped) -> untyped
22
+ def for_model(model_class)
23
+ model_class.paper_trail.version_class.where(item_type: model_class.base_class.name)
24
+ end
25
+
26
+ # The versions of the given items, read the same way.
27
+ #: (untyped, untyped) -> untyped
28
+ def for_items(model_class, ids)
29
+ for_model(model_class).where(item_id: ids)
30
+ end
31
+
17
32
  # Keeps each item's earliest version among those the block also accepts:
18
33
  # no other version of the same item that the block accepts is earlier. A
19
34
  # correlated NOT EXISTS stays portable where window functions are not.
20
35
  #: (untyped) ?{ (untyped) -> untyped } -> untyped
21
36
  def no_earlier_version(table, &block)
37
+ no_nearer_version(table, later: true, &block)
38
+ end
39
+
40
+ # The same for either side of an instant: each item's earliest version
41
+ # when the versions lie after it, and its latest when they lie before.
42
+ #: (untyped, later: bool) ?{ (untyped) -> untyped } -> untyped
43
+ def no_nearer_version(table, later:, &block)
22
44
  arel = Object.const_get(:Arel) #: untyped
23
- earlier = table.alias('paper_trail_diff_earlier_versions')
45
+ other = table.alias('paper_trail_diff_earlier_versions')
24
46
  arel.const_get(:SelectManager).new
25
- .from(earlier)
47
+ .from(other)
26
48
  .project(arel.sql('1'))
27
- .where(earlier_version(table, earlier, &block))
49
+ .where(nearer_version(table, other, later: later, &block))
28
50
  .exists
29
51
  .not
30
52
  end
31
53
 
32
- #: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
33
- def earlier_version(table, earlier)
34
- same_item = earlier[:item_type].eq(table[:item_type])
35
- .and(earlier[:item_id].eq(table[:item_id]))
36
- .and(earlier[:created_at].lt(table[:created_at]))
37
- block_given? ? same_item.and(yield(earlier)) : same_item
54
+ #: (untyped, untyped, later: bool) ?{ (untyped) -> untyped } -> untyped
55
+ def nearer_version(table, other, later:)
56
+ created_at = other[:created_at]
57
+ nearer = later ? created_at.lt(table[:created_at]) : created_at.gt(table[:created_at])
58
+ same_item = other[:item_type].eq(table[:item_type])
59
+ .and(other[:item_id].eq(table[:item_id]))
60
+ .and(nearer)
61
+ block_given? ? same_item.and(yield(other)) : same_item
38
62
  end
39
- private_class_method :earlier_version
63
+ private_class_method :nearer_version
40
64
  end
41
65
  end
@@ -7,9 +7,17 @@ module PaperTrailDiff
7
7
  # caller, because the one that reveals the last selected mutation is drawn
8
8
  # from them.
9
9
  class VersionScopeFilter
10
+ # A filter is a callable that narrows the version relation.
11
+ #: (untyped) -> untyped
12
+ def self.validated(scope)
13
+ return scope if scope.nil? || scope.respond_to?(:call)
14
+
15
+ raise ConfigurationError, 'version_scope: must respond to call'
16
+ end
17
+
10
18
  #: (untyped) -> void
11
19
  def initialize(scope)
12
- @scope = validated(scope)
20
+ @scope = self.class.validated(scope)
13
21
  end
14
22
 
15
23
  #: (untyped, Array[untyped]) -> Array[untyped]
@@ -31,15 +39,6 @@ module PaperTrailDiff
31
39
  Set.new(scope.call(relation).pluck(:id))
32
40
  end
33
41
 
34
- private
35
-
36
42
  # @rbs @scope: untyped
37
-
38
- #: (untyped) -> untyped
39
- def validated(scope)
40
- return scope if scope.nil? || scope.respond_to?(:call)
41
-
42
- raise ConfigurationError, 'version_scope: must respond to call'
43
- end
44
43
  end
45
44
  end