paper_trail_diff 0.13.1 → 0.14.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +287 -0
  3. data/QUICKSTART.md +9 -6
  4. data/README.md +84 -9
  5. data/docs/architecture.md +32 -9
  6. data/docs/performance.md +106 -13
  7. data/docs/reference.md +242 -29
  8. data/docs/testing.md +34 -11
  9. data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
  10. data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
  11. data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
  12. data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
  13. data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
  14. data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
  15. data/lib/paper_trail_diff/activity_range.rb +14 -2
  16. data/lib/paper_trail_diff/activity_relationship.rb +12 -0
  17. data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
  18. data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
  19. data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
  20. data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
  21. data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
  22. data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
  23. data/lib/paper_trail_diff/association_discovery.rb +8 -13
  24. data/lib/paper_trail_diff/association_traversal.rb +169 -45
  25. data/lib/paper_trail_diff/batch_safety.rb +23 -15
  26. data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
  27. data/lib/paper_trail_diff/comparison_batch.rb +37 -10
  28. data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
  29. data/lib/paper_trail_diff/diagnostics.rb +26 -8
  30. data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
  31. data/lib/paper_trail_diff/errors.rb +2 -1
  32. data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
  33. data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
  34. data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
  35. data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
  36. data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
  37. data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
  38. data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
  39. data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
  40. data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
  41. data/lib/paper_trail_diff/prepared_history.rb +63 -54
  42. data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
  43. data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
  44. data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
  45. data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
  46. data/lib/paper_trail_diff/recorded_changes.rb +83 -0
  47. data/lib/paper_trail_diff/recorded_class.rb +53 -0
  48. data/lib/paper_trail_diff/scope_condition.rb +331 -0
  49. data/lib/paper_trail_diff/scope_ranking.rb +196 -0
  50. data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
  51. data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
  52. data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
  53. data/lib/paper_trail_diff/support.rb +9 -4
  54. data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
  55. data/lib/paper_trail_diff/time_range.rb +9 -12
  56. data/lib/paper_trail_diff/time_version_range.rb +5 -5
  57. data/lib/paper_trail_diff/version.rb +1 -1
  58. data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
  59. data/lib/paper_trail_diff/version_range.rb +8 -15
  60. data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
  61. data/lib/paper_trail_diff.rb +31 -2
  62. data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
  63. data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
  64. data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
  65. data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
  66. data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
  67. data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
  68. data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
  69. data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
  70. data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
  71. data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
  72. data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
  73. data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
  74. data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
  75. data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
  76. data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
  77. data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
  78. data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
  79. data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
  80. data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
  81. data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
  82. data/sig/generated/paper_trail_diff/errors.rbs +2 -1
  83. data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
  84. data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
  85. data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
  86. data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
  87. data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
  88. data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
  89. data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
  90. data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
  91. data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
  92. data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
  93. data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
  94. data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
  95. data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
  96. data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
  97. data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
  98. data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
  99. data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
  100. data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
  101. data/sig/generated/paper_trail_diff/support.rbs +5 -2
  102. data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
  103. data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
  104. data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
  105. data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
  106. data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
  107. data/sig/generated/paper_trail_diff.rbs +21 -2
  108. metadata +20 -4
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # The order, offset and limit by which a scope picks its members from the
6
+ # records that meet its conditions, as the database would rank them. It is
7
+ # read only when the order is total and every key compares in Ruby as SQL
8
+ # compares it; otherwise build returns nil and the caller keeps its slower
9
+ # path.
10
+ class ScopeRanking
11
+ DIRECTIONS = { 'Arel::Nodes::Ascending' => false, 'Arel::Nodes::Descending' => true }.freeze
12
+ ATTRIBUTE = 'Arel::Attributes::Attribute'
13
+ ENUM_TYPE = 'ActiveRecord::Enum::EnumType'
14
+ # Integers, booleans and dates sort alike everywhere. Strings follow a
15
+ # collation, so they are not read.
16
+ ORDER_TYPES = %w[ActiveModel::Type::Integer ActiveModel::Type::Boolean
17
+ ActiveModel::Type::Date].freeze
18
+ # Times sort alike only at the precision both the column and history
19
+ # keep. A time of day is not read: in memory it keeps the date it was
20
+ # given, which the database drops.
21
+ DATETIME_TYPE = 'ActiveModel::Type::DateTime'
22
+ # Where each adapter sorts NULL in an ascending order by default; a
23
+ # descending order reverses it. Another adapter's ranking is not read.
24
+ NULLS_LAST = { 'postgresql' => true, 'sqlite3' => false, 'mysql2' => false,
25
+ 'trilogy' => false, 'sqlserver' => false }.freeze
26
+ # Places in a sort key, of which only the order counts: a value between a
27
+ # NULL sorted first and one sorted last, and false before true. Every
28
+ # value is a number, so that a descending key can negate it.
29
+ NULL_PLACE = { false => -1, true => 1 }.freeze
30
+ VALUE_PLACE = 0
31
+ BOOLEAN_VALUE = { false => 0, true => 1 }.freeze
32
+
33
+ # Whether the relation picks members by rank rather than by condition
34
+ # alone. A has_one takes the first record its order names.
35
+ #: (untyped, untyped) -> bool
36
+ def self.ranks?(relation, reflection)
37
+ !relation.limit_value.nil? || !relation.offset_value.nil? ||
38
+ (reflection.has_one? && !relation.order_values.empty?)
39
+ end
40
+
41
+ # A has_many, through or not, or a direct has_one is ranked per owner as
42
+ # its own query ranks it. A has_many through ranks the rows its join
43
+ # yields, a record reached twice counting twice, by its own order first.
44
+ # Returns nil when the ranking cannot be read exactly.
45
+ #: (untyped, untyped, untyped) -> ScopeRanking?
46
+ def self.build(relation, reflection, model_class)
47
+ return unless own?(reflection) && plain_offset?(relation)
48
+
49
+ keys = keys(relation.order_values, model_class)
50
+ return unless keys
51
+
52
+ nulls_last = NULLS_LAST[model_class.connection_db_config.adapter]
53
+ return if nulls_last.nil?
54
+
55
+ new(model_class, keys, nulls_last, relation.limit_value, relation.offset_value)
56
+ end
57
+
58
+ #: (untyped) -> bool
59
+ def self.own?(reflection)
60
+ reflection.macro == :has_many || (reflection.has_one? && !reflection.through_reflection?)
61
+ end
62
+ private_class_method :own?
63
+
64
+ # Active Record casts a limit to an integer but keeps an offset as given,
65
+ # which may be a String or SQL.
66
+ #: (untyped) -> bool
67
+ def self.plain_offset?(relation)
68
+ !relation.offset_value.is_a?(String)
69
+ end
70
+ private_class_method :plain_offset?
71
+
72
+ # The order's keys up to the primary key, which makes it total. Keys
73
+ # after it never decide anything.
74
+ #: (Array[untyped], untyped) -> Array[[String, bool]]?
75
+ def self.keys(nodes, model_class)
76
+ names = nodes.map { |node| column(node, model_class) }
77
+ total = names.index(model_class.primary_key)
78
+ return unless total
79
+
80
+ deciding = nodes.first(total + 1).zip(names)
81
+ return unless deciding.all? { |_node, name| name }
82
+
83
+ deciding.map do |node, name|
84
+ column = name #: String
85
+ [column, DIRECTIONS.fetch(node.class.name)]
86
+ end
87
+ end
88
+ private_class_method :keys
89
+
90
+ # The record's own column, sorted plainly in one direction.
91
+ #: (untyped, untyped) -> String?
92
+ def self.column(node, model_class)
93
+ return unless DIRECTIONS.key?(node.class.name)
94
+
95
+ attribute = node.expr
96
+ return unless ATTRIBUTE.eql?(attribute.class.name)
97
+
98
+ table = attribute.relation
99
+ return unless table.name == model_class.table_name && table.table_alias.nil?
100
+
101
+ name = attribute.name
102
+ name if orderable?(model_class, name)
103
+ end
104
+ private_class_method :column
105
+
106
+ #: (untyped, String) -> bool
107
+ def self.orderable?(model_class, name)
108
+ return false unless model_class.columns_hash.key?(name)
109
+
110
+ type = model_class.type_for_attribute(name)
111
+ type = type.subtype if ENUM_TYPE.eql?(type.class.name)
112
+ kinds = type.class.ancestors.map(&:name)
113
+ ORDER_TYPES.intersect?(kinds) ||
114
+ (kinds.include?(DATETIME_TYPE) && exact_time?(model_class, type))
115
+ end
116
+ private_class_method :orderable?
117
+
118
+ # Active Record truncates a time to its column's precision before writing
119
+ # it, so history holds the stored value when it keeps that many digits.
120
+ # Times the type stores in UTC sort as the instants they name; local
121
+ # times repeat an hour when clocks go back.
122
+ #: (untyped, untyped) -> bool
123
+ def self.exact_time?(model_class, type)
124
+ precision = type.precision
125
+ digits = StoredTimePrecision.digits(model_class)
126
+ !precision.nil? && (digits.nil? || digits >= precision) && type.default_timezone == :utc
127
+ end
128
+ private_class_method :exact_time?
129
+
130
+ #: (untyped, Array[[String, bool]], bool, Integer?, Integer?) -> void
131
+ def initialize(model_class, keys, nulls_last, limit, offset)
132
+ @model_class = model_class
133
+ @keys = keys
134
+ @nulls_last = nulls_last
135
+ @limit = limit
136
+ @offset = offset
137
+ freeze
138
+ end
139
+
140
+ # The members among records that met the scope's conditions, in order.
141
+ #: (Array[untyped]) -> Array[untyped]
142
+ def call(records)
143
+ offset = @offset
144
+ limit = @limit
145
+ ranked = records.sort_by { |record| sort_key(record.attributes) }
146
+ ranked = ranked.drop(offset) if offset
147
+ limit ? ranked.first(limit) : ranked
148
+ end
149
+
150
+ private
151
+
152
+ # @rbs @model_class: untyped
153
+ # @rbs @keys: Array[[String, bool]]
154
+ # @rbs @nulls_last: bool
155
+ # @rbs @limit: Integer?
156
+ # @rbs @offset: Integer?
157
+
158
+ # Keys compare in turn, and the primary key always decides. A descending
159
+ # key negates its places, which also moves NULL to the other end, as each
160
+ # adapter does by default.
161
+ #: (Hash[String, untyped]) -> Array[Array[Numeric]]
162
+ def sort_key(attributes)
163
+ @keys.map do |name, descending|
164
+ place = place(stored(name, attributes))
165
+ descending ? place.map(&:-@) : place
166
+ end
167
+ end
168
+
169
+ # NULL holds the adapter's place alone, so it never compares as a value.
170
+ # A version saved before its column existed holds NULL there too.
171
+ #: (untyped) -> Array[Numeric]
172
+ def place(value)
173
+ return [NULL_PLACE.fetch(@nulls_last)] if value.nil?
174
+
175
+ [VALUE_PLACE, number(value)]
176
+ end
177
+
178
+ # A time as its seconds, exactly, and a date as its day number.
179
+ #: (untyped) -> Numeric
180
+ def number(value)
181
+ value = BOOLEAN_VALUE.fetch(value, value)
182
+ value = value.jd if value.respond_to?(:jd)
183
+ value.to_r
184
+ end
185
+
186
+ # The value the database sorts by, as the key's type casts it. An enum
187
+ # sorts by the integer it stores; other types are compared cast, since an
188
+ # adapter may serialize a time or date as a wrapper, as SQL Server does.
189
+ #: (String, Hash[String, untyped]) -> untyped
190
+ def stored(name, attributes)
191
+ type = @model_class.type_for_attribute(name)
192
+ value = type.cast(attributes[name])
193
+ ENUM_TYPE.eql?(type.class.name) ? type.serialize(value) : value
194
+ end
195
+ end
196
+ end
@@ -4,10 +4,10 @@
4
4
  module PaperTrailDiff
5
5
  # Selects both populations and checks their shared ceiling before analysis.
6
6
  class ScopedAnalysisBuilder
7
- #: (untyped, limit: Integer?, within: untyped, historical_filter: untyped, max_filter_versions: Integer, include_deleted: bool, analyzer: untyped, deleted_analyzer: untyped) -> void
7
+ #: (untyped, limit: Integer?, within: untyped, historical_filter: untyped, max_filter_versions: Integer, include_deleted: bool, analyzer: untyped, deleted_analyzer: untyped, ?activity_tree: AssociationTree?) -> void
8
8
  def initialize( # rubocop:disable Metrics/ParameterLists
9
9
  scope, limit:, within:, historical_filter:, max_filter_versions:, include_deleted:,
10
- analyzer:, deleted_analyzer:
10
+ analyzer:, deleted_analyzer:, activity_tree: nil
11
11
  )
12
12
  raise ConfigurationError, 'limit: is required when selecting roots by scope' if limit.nil?
13
13
  unless [true, false].include?(include_deleted)
@@ -16,7 +16,8 @@ module PaperTrailDiff
16
16
 
17
17
  @selection = ScopedRootSelection.new(
18
18
  scope, limit: limit, time_range: within.nil? ? nil : TimeRange.new(within),
19
- historical_filter: historical_filter, max_filter_versions: max_filter_versions
19
+ historical_filter: historical_filter, max_filter_versions: max_filter_versions,
20
+ activity_tree: activity_tree
20
21
  )
21
22
  @limit = limit
22
23
  @include_deleted = include_deleted
@@ -43,12 +43,20 @@ module PaperTrailDiff
43
43
  end
44
44
  end
45
45
 
46
- #: (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer) -> void
47
- def initialize(scope, time_range:, limit:, historical_filter: nil,
48
- max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT)
46
+ # `activity_tree` names the association paths whose changes also select a
47
+ # root, as an activity analysis reports them. Without one, only a root's
48
+ # own versions select it.
49
+ #: (untyped, time_range: TimeRange?, limit: Integer, ?historical_filter: untyped, ?max_filter_versions: Integer, ?activity_tree: AssociationTree?) -> void
50
+ def initialize(scope, time_range:, limit:, historical_filter: nil, # rubocop:disable Metrics/ParameterLists
51
+ max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, activity_tree: nil)
49
52
  @scope = normalize_scope(scope)
53
+ reject_unversioned!
50
54
  @time_range = time_range
51
55
  @limit = validate_limit(limit)
56
+ @related = if activity_tree
57
+ AssociationActivitySelection.new(model, tree: activity_tree,
58
+ time_range: time_range)
59
+ end
52
60
  @historical_filter = unless historical_filter.nil?
53
61
  HistoricalFilter.new(base_class, predicate: historical_filter,
54
62
  time_range: time_range, limit: @limit,
@@ -58,17 +66,11 @@ module PaperTrailDiff
58
66
 
59
67
  #: () -> Result
60
68
  def call
61
- return database_selection if @historical_filter.nil? && comparable_keys?
69
+ related = @related&.call || []
70
+ queries = subqueries(related)
71
+ return database_selection(queries) if queries
62
72
 
63
- candidates = versioned_ids
64
- candidates = @historical_filter.call(candidates) if @historical_filter && candidates.any?
65
- return Result.new(records: [], unreachable: []) if candidates.empty?
66
-
67
- live = live_ids(candidates)
68
- Result.new(
69
- records: selected_records(live),
70
- unreachable: (candidates - live).map { |id| identity(id) }.freeze
71
- )
73
+ plucked_selection(versioned_ids | AssociationActivitySelection.values(related).map(&:to_s))
72
74
  end
73
75
 
74
76
  private
@@ -77,38 +79,67 @@ module PaperTrailDiff
77
79
  # @rbs @time_range: TimeRange?
78
80
  # @rbs @limit: Integer
79
81
  # @rbs @historical_filter: untyped
82
+ # @rbs @related: AssociationActivitySelection?
80
83
 
81
84
  # Leaves the candidate set in the database. Plucking every root changed in
82
85
  # the window would cost memory and a statement proportional to all activity
83
86
  # in the window -- not to the relation or `limit:` -- so a report on one
84
87
  # record could read every id in a busy table. A subquery keeps the work
85
88
  # bounded by what the relation and the deletions actually contain.
86
- #: () -> Result
87
- def database_selection
88
- candidates = window_versions.select(:item_id)
89
+ #: (Array[AssociationActivitySelection::Query]) -> Result
90
+ def database_selection(related)
91
+ candidates = [window_versions.select(:item_id)] + related.map(&:relation)
92
+ missing = (missing_ids + related_missing_ids(related)).uniq.sort
89
93
  Result.new(
90
94
  records: selected_records(candidates),
91
- unreachable: missing_ids.map { |id| identity(id) }.freeze
95
+ unreachable: missing.map { |id| identity(id) }.freeze
96
+ )
97
+ end
98
+
99
+ # Comparing in SQL needs every subquery's column to share the key's type.
100
+ #: (Array[AssociationActivitySelection::source]) -> Array[AssociationActivitySelection::Query]?
101
+ def subqueries(related)
102
+ return unless @historical_filter.nil? && comparable_keys?
103
+
104
+ queries = related.grep(AssociationActivitySelection::Query) #: Array[AssociationActivitySelection::Query]
105
+ matching = queries.select { |ids| ids.type == key_type }
106
+ matching if matching.length == related.length
107
+ end
108
+
109
+ #: (Array[String]) -> Result
110
+ def plucked_selection(candidates)
111
+ candidates = @historical_filter.call(candidates) if @historical_filter
112
+ live = live_ids(candidates)
113
+ Result.new(
114
+ records: selected_records([live]),
115
+ unreachable: (candidates - live).sort.map { |id| identity(id) }.freeze
92
116
  )
93
117
  end
94
118
 
95
119
  # Comparing the two columns in SQL needs them to share a type: PostgreSQL
96
- # rejects an integer key against a string item_id outright.
120
+ # rejects an integer key against a string item_id outright. A composite
121
+ # key names no single attribute, so it has no type and never matches.
97
122
  #: () -> bool
98
123
  def comparable_keys?
99
- return false unless primary_key.is_a?(String)
124
+ version_class.type_for_attribute('item_id').type == key_type
125
+ end
126
+
127
+ #: () -> Symbol?
128
+ def key_type
129
+ base_class.type_for_attribute(primary_key).type
130
+ end
100
131
 
101
- item_id = version_class.columns_hash['item_id']&.type
102
- key = base_class.columns_hash[primary_key]&.type
103
- !item_id.nil? && item_id == key
132
+ #: (Array[AssociationActivitySelection::Query]) -> Array[String]
133
+ def related_missing_ids(related)
134
+ live = base_class.unscoped.select(primary_key).arel
135
+ related.flat_map { |ids| ids.missing(live) }.map(&:to_s)
104
136
  end
105
137
 
106
138
  # Changed in the window but without a row, found with an anti-join instead
107
139
  # of comparing two plucked lists.
108
140
  #: () -> Array[String]
109
141
  def missing_ids
110
- live = base_class.unscoped.select(primary_key)
111
- window_versions.where.not(item_id: live).distinct.pluck(:item_id).map(&:to_s).uniq.sort
142
+ window_versions.where.not(item_id: base_class.unscoped).distinct.pluck(:item_id).map(&:to_s)
112
143
  end
113
144
 
114
145
  #: () -> untyped
@@ -127,16 +158,21 @@ module PaperTrailDiff
127
158
  raise ConfigurationError, 'scope: must be an ActiveRecord relation or model class'
128
159
  end
129
160
 
130
- relation = scope.all
131
- return relation if Support.versioned?(relation.model)
161
+ scope.all
162
+ end
163
+
164
+ #: () -> void
165
+ def reject_unversioned!
166
+ return if Support.versioned?(model)
132
167
 
133
168
  raise UnversionedAssociationError,
134
- "scope: #{relation.model.name} is not versioned, so it has no history to select from"
169
+ "scope: #{model} is not versioned, so it has no history to select from"
135
170
  end
136
171
 
172
+ # No Integer subclass has instances, so this is `is_a?` by another name.
137
173
  #: (Integer) -> Integer
138
174
  def validate_limit(limit)
139
- return limit if limit.is_a?(Integer) && limit.positive?
175
+ return limit if limit.instance_of?(Integer) && limit.positive?
140
176
 
141
177
  raise ConfigurationError, 'limit: must be a positive Integer'
142
178
  end
@@ -146,7 +182,7 @@ module PaperTrailDiff
146
182
  # version table alone so that it does not depend on rows still existing.
147
183
  #: () -> Array[String]
148
184
  def versioned_ids
149
- window_versions.distinct.pluck(:item_id).map(&:to_s).uniq
185
+ window_versions.distinct.pluck(:item_id).map(&:to_s)
150
186
  end
151
187
 
152
188
  # Which of those candidates still have a row, asked without the relation's
@@ -161,14 +197,15 @@ module PaperTrailDiff
161
197
  # Deduplicate records rather than adding SQL DISTINCT: an otherwise valid
162
198
  # relation may order by joined columns or select a PostgreSQL json column.
163
199
  # Read bounded pages until the relation ends or too many unique roots appear.
164
- #: (untyped) -> Array[untyped]
165
- def selected_records(live)
166
- return [] if live.is_a?(Array) && live.empty?
167
-
200
+ # Each candidate source is an id list or a subquery; a record matching any
201
+ # of them is selected.
202
+ #: (Array[untyped]) -> Array[untyped]
203
+ def selected_records(candidates)
204
+ relation = candidate_relation(candidates)
168
205
  records = {} #: Hash[untyped, untyped]
169
206
  consumed = 0
170
207
  loop do
171
- page = record_page(live, consumed)
208
+ page = record_page(relation, consumed)
172
209
  page.each { |record| records[record.id] ||= record }
173
210
  reject_oversized!(records.length)
174
211
  break if page.length < PAGE_SIZE
@@ -178,15 +215,20 @@ module PaperTrailDiff
178
215
  records.values.freeze
179
216
  end
180
217
 
218
+ #: (Array[untyped]) -> untyped
219
+ def candidate_relation(candidates)
220
+ candidates.map do |ids|
221
+ conditions = { primary_key => ids } #: Hash[untyped, untyped]
222
+ ordered_scope.where(conditions)
223
+ end.reduce(:or)
224
+ end
225
+
181
226
  #: (untyped, Integer) -> Array[untyped]
182
- def record_page(live, consumed)
227
+ def record_page(relation, consumed)
183
228
  count = page_limit(consumed)
184
229
  return [] if count.zero?
185
230
 
186
- # An id list or a candidate subquery; both read the same way in SQL.
187
- conditions = { primary_key => live } #: Hash[untyped, untyped]
188
- ordered_scope.where(conditions)
189
- .offset((@scope.offset_value || 0) + consumed).limit(count).to_a
231
+ relation.offset((@scope.offset_value || 0) + consumed).limit(count)
190
232
  end
191
233
 
192
234
  # Preserve explicit ordering: SQL Server rejects duplicate ORDER BY columns,
@@ -220,22 +262,30 @@ module PaperTrailDiff
220
262
 
221
263
  #: () -> String
222
264
  def item_type
223
- base_class.name.to_s
265
+ base_class.name
266
+ end
267
+
268
+ # A relation delegates class methods to its model, so every mutation here,
269
+ # which drops `.model`, answers the same.
270
+ # mutant:disable
271
+ #: () -> untyped
272
+ def model
273
+ @scope.model
224
274
  end
225
275
 
226
276
  #: () -> untyped
227
277
  def base_class
228
- @scope.model.base_class
278
+ model.base_class
229
279
  end
230
280
 
231
281
  #: () -> untyped
232
282
  def primary_key
233
- @scope.model.primary_key
283
+ model.primary_key
234
284
  end
235
285
 
236
286
  #: () -> untyped
237
287
  def version_class
238
- @scope.model.paper_trail.version_class
288
+ model.paper_trail.version_class
239
289
  end
240
290
  end
241
291
  end
@@ -200,9 +200,8 @@ module PaperTrailDiff
200
200
  path,
201
201
  reifier
202
202
  )
203
- # A reified history can hold a member PaperTrail could not rebuild.
204
- records = Array(record.public_send(reflection.name)).compact
205
- child_path = Support.association_path(path, reflection.name.to_s)
203
+ records = members(record, reflection)
204
+ child_path = Support.association_path(path, reflection.name)
206
205
  @structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
207
206
  snapshot = AssociationSnapshot.new(
208
207
  kind: reflection.macro,
@@ -211,6 +210,14 @@ module PaperTrailDiff
211
210
  @pool.association(child_path, snapshot)
212
211
  end
213
212
 
213
+ # A reified history can hold a member PaperTrail could not rebuild. A
214
+ # through loads a record once per join that reaches it; it is one member,
215
+ # as Active Record compares records by class and id.
216
+ #: (untyped, untyped) -> Array[untyped]
217
+ def members(record, reflection)
218
+ Array(record.public_send(reflection.name)).compact.uniq
219
+ end
220
+
214
221
  #: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
215
222
  def normalize_children(records, tree, path, reifier)
216
223
  reflections_by_class = {} #: Hash[untyped, Array[untyped]]
@@ -3,7 +3,7 @@
3
3
 
4
4
  module PaperTrailDiff
5
5
  # Internal helpers for isolating and serializing values held by result objects.
6
- module Support
6
+ module Support # rubocop:disable Metrics/ModuleLength
7
7
  module_function
8
8
 
9
9
  #: (untyped) -> untyped
@@ -46,7 +46,12 @@ module PaperTrailDiff
46
46
 
47
47
  #: (untyped) -> Array[untyped]
48
48
  def chronological_version_key(version)
49
- [version.created_at, version.id.to_s.rjust(32, '0')]
49
+ version_key(version.created_at, version.id)
50
+ end
51
+
52
+ #: (untyped, untyped) -> Array[untyped]
53
+ def version_key(created_at, id)
54
+ [created_at, id.to_s.rjust(32, '0')]
50
55
  end
51
56
 
52
57
  # Ordering falls back to the id whenever timestamps tie, which recovers the
@@ -124,9 +129,9 @@ module PaperTrailDiff
124
129
  raise(ConfigurationError, 'versions have incomparable timestamps')
125
130
  end
126
131
 
127
- #: (String, String) -> String
132
+ #: (String, String | Symbol) -> String
128
133
  def association_path(parent, name)
129
- parent.empty? ? name : "#{parent}.#{name}"
134
+ parent.empty? ? name.to_s : "#{parent}.#{name}"
130
135
  end
131
136
 
132
137
  #: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
@@ -194,7 +194,6 @@ module PaperTrailDiff
194
194
  @record,
195
195
  root_versions: root_versions,
196
196
  tree: @tree,
197
- traversal: AssociationTraversal.new(@tree),
198
197
  prepared: (@snapshotter.activity_index if @snapshotter.respond_to?(:activity_index)),
199
198
  range_start: @range.begin_time,
200
199
  range_end: range_end || root_versions.last
@@ -40,8 +40,7 @@ module PaperTrailDiff
40
40
 
41
41
  #: (untyped) -> untyped
42
42
  def trailing_scope(relation)
43
- column = relation.klass.arel_table[:created_at]
44
- relation.where(after_end(column))
43
+ relation.where(after_end(relation.arel_table[:created_at]))
45
44
  end
46
45
 
47
46
  # Each item's earliest version after the range, keyed by item id. One row
@@ -49,11 +48,11 @@ module PaperTrailDiff
49
48
  # the query stays portable; a timestamp tie keeps the lower id.
50
49
  #: (untyped) -> Hash[String, untyped]
51
50
  def first_trailing_versions(relation)
52
- table = relation.klass.arel_table
51
+ table = relation.arel_table
53
52
  first_after = VersionQuery.no_earlier_version(table) do |earlier|
54
53
  after_end(earlier[:created_at])
55
54
  end
56
- versions = VersionQuery.chronological(trailing_scope(relation).where(first_after)).to_a
55
+ versions = VersionQuery.chronological(trailing_scope(relation).where(first_after))
57
56
  first = {} #: Hash[String, untyped]
58
57
  versions.each { |version| first[version.item_id.to_s] ||= version }
59
58
  first
@@ -75,17 +74,15 @@ module PaperTrailDiff
75
74
 
76
75
  #: (untyped, boundary: Symbol) -> Time
77
76
  def coerce_time(value, boundary:)
78
- unless value.respond_to?(:to_time)
79
- raise InvalidTimeRangeError, "`within` #{boundary} must be time-like"
80
- end
81
-
82
77
  time_value(value).getutc.freeze
83
- rescue ArgumentError, NoMethodError, TypeError => e
84
- raise InvalidTimeRangeError,
85
- "`within` #{boundary} must be time-like",
86
- cause: e
78
+ rescue ArgumentError, NoMethodError, TypeError
79
+ raise InvalidTimeRangeError, "`within` #{boundary} must be time-like"
87
80
  end
88
81
 
82
+ # Active Support 8.0 and older warn, once per process, that `to_time` will
83
+ # change on a zoned time, so read zoned values without it. Every branch
84
+ # gives the same instant, so no spec (and no mutation) can tell them apart.
85
+ # mutant:disable
89
86
  #: (untyped) -> Time
90
87
  def time_value(value)
91
88
  return value if value.is_a?(Time)
@@ -4,18 +4,18 @@
4
4
  module PaperTrailDiff
5
5
  # Selects in-range root versions plus one later reconstruction boundary.
6
6
  class TimeVersionRange
7
- #: (untyped, time_range: TimeRange, ?version_scope: untyped, ?live_endpoint: untyped) -> void
8
- def initialize(record, time_range:, version_scope: nil, live_endpoint: nil)
7
+ #: (untyped, time_range: TimeRange, version_scope: untyped, live_endpoint: untyped) -> void
8
+ def initialize(record, time_range:, version_scope:, live_endpoint:)
9
9
  @record = record
10
10
  @time_range = time_range
11
11
  @version_scope = version_scope
12
12
  @live_endpoint = live_endpoint
13
13
  end
14
14
 
15
- #: (?context_required: bool) -> RootVersionPlan
16
- def select_plan(context_required: false)
15
+ #: (context_required: bool) -> RootVersionPlan
16
+ def select_plan(context_required:)
17
17
  relation = Endpoint.versions(@record)
18
- in_range = ordered(@time_range.scope(relation).to_a)
18
+ in_range = ordered(@time_range.scope(relation))
19
19
  RootVersionSelection.new(
20
20
  in_range: in_range,
21
21
  selected: VersionScopeFilter.new(@version_scope).call(@time_range.scope(relation),
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.13.1'
5
+ VERSION = '0.14.0'
6
6
  end
@@ -44,7 +44,7 @@ module PaperTrailDiff
44
44
  def condition_sql(connection) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
45
45
  outer = connection.quote_table_name(@version_class.table_name)
46
46
  later = connection.quote_table_name('paper_trail_diff_later_versions')
47
- start_at = connection.quote(@start_at)
47
+ start_at = quote_time(connection, @start_at)
48
48
  columns = quoted_columns(connection, outer, later)
49
49
  compact_sql(<<~SQL)
50
50
  (
@@ -84,7 +84,17 @@ module PaperTrailDiff
84
84
  condition = "#{created_at} >= #{start_at}"
85
85
  return condition unless @end_at
86
86
 
87
- "(#{condition} AND #{created_at} <= #{connection.quote(@end_at)})"
87
+ "(#{condition} AND #{created_at} <= #{quote_time(connection, @end_at)})"
88
+ end
89
+
90
+ # A time quoted on its own takes the adapter's default precision, which on
91
+ # SQL Server is `datetime` and rounds to 1/300 second. Serializing through
92
+ # the column's type quotes it as a bound value would be, at the precision
93
+ # `created_at` is stored with.
94
+ #: (untyped, untyped) -> String
95
+ def quote_time(connection, time)
96
+ type = @version_class.type_for_attribute('created_at')
97
+ connection.quote(type.serialize(type.cast(time)))
88
98
  end
89
99
 
90
100
  #: (untyped, String, String) -> String
@@ -94,7 +104,7 @@ module PaperTrailDiff
94
104
 
95
105
  #: (String) -> String
96
106
  def compact_sql(sql)
97
- sql.gsub(/\s+/, ' ').strip
107
+ sql.split.join(' ')
98
108
  end
99
109
  end
100
110
  end