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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +287 -0
- data/QUICKSTART.md +9 -6
- data/README.md +84 -9
- data/docs/architecture.md +32 -9
- data/docs/performance.md +106 -13
- data/docs/reference.md +242 -29
- data/docs/testing.md +34 -11
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +19 -9
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +11 -1
- data/lib/paper_trail_diff/activity_collection_record_updater.rb +16 -2
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +20 -1
- data/lib/paper_trail_diff/activity_event_route_finder.rb +2 -1
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +1 -1
- data/lib/paper_trail_diff/activity_range.rb +14 -2
- data/lib/paper_trail_diff/activity_relationship.rb +12 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +5 -2
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +131 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +30 -24
- data/lib/paper_trail_diff/activity_version_collector.rb +31 -20
- data/lib/paper_trail_diff/association_activity_edge.rb +236 -0
- data/lib/paper_trail_diff/association_activity_selection.rb +197 -0
- data/lib/paper_trail_diff/association_discovery.rb +8 -13
- data/lib/paper_trail_diff/association_traversal.rb +169 -45
- data/lib/paper_trail_diff/batch_safety.rb +23 -15
- data/lib/paper_trail_diff/batched_root_versions.rb +21 -39
- data/lib/paper_trail_diff/comparison_batch.rb +37 -10
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +34 -16
- data/lib/paper_trail_diff/diagnostics.rb +26 -8
- data/lib/paper_trail_diff/direct_record_state_loader.rb +10 -1
- data/lib/paper_trail_diff/errors.rb +2 -1
- data/lib/paper_trail_diff/habtm_transaction_owners.rb +68 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +32 -2
- data/lib/paper_trail_diff/historical_snapshot_store.rb +6 -9
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +35 -2
- data/lib/paper_trail_diff/live_graph_collector.rb +1 -1
- data/lib/paper_trail_diff/paper_trail_adapter.rb +22 -6
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +1 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +110 -36
- data/lib/paper_trail_diff/prepared_edge_loader.rb +29 -45
- data/lib/paper_trail_diff/prepared_history.rb +63 -54
- data/lib/paper_trail_diff/prepared_history_loader.rb +33 -43
- data/lib/paper_trail_diff/prepared_record_index.rb +26 -0
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +1 -1
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +119 -29
- data/lib/paper_trail_diff/recorded_changes.rb +83 -0
- data/lib/paper_trail_diff/recorded_class.rb +53 -0
- data/lib/paper_trail_diff/scope_condition.rb +331 -0
- data/lib/paper_trail_diff/scope_ranking.rb +196 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +4 -3
- data/lib/paper_trail_diff/scoped_root_selection.rb +93 -43
- data/lib/paper_trail_diff/snapshot_normalizer.rb +10 -3
- data/lib/paper_trail_diff/support.rb +9 -4
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -1
- data/lib/paper_trail_diff/time_range.rb +9 -12
- data/lib/paper_trail_diff/time_version_range.rb +5 -5
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +13 -3
- data/lib/paper_trail_diff/version_range.rb +8 -15
- data/lib/paper_trail_diff/version_scope_filter.rb +11 -3
- data/lib/paper_trail_diff.rb +31 -2
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +10 -2
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_collection_record_updater.rbs +5 -0
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +7 -0
- data/sig/generated/paper_trail_diff/activity_range.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_relationship.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_root_snapshot_refresher.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_target_candidate_loader.rbs +79 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +11 -5
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +10 -4
- data/sig/generated/paper_trail_diff/association_activity_edge.rbs +148 -0
- data/sig/generated/paper_trail_diff/association_activity_selection.rbs +112 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +3 -5
- data/sig/generated/paper_trail_diff/association_traversal.rbs +84 -9
- data/sig/generated/paper_trail_diff/batch_safety.rbs +10 -4
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +2 -8
- data/sig/generated/paper_trail_diff/comparison_batch.rbs +22 -4
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +16 -4
- data/sig/generated/paper_trail_diff/diagnostics.rbs +7 -2
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +6 -0
- data/sig/generated/paper_trail_diff/errors.rbs +2 -1
- data/sig/generated/paper_trail_diff/habtm_transaction_owners.rbs +40 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +13 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +3 -2
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +17 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +52 -10
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +4 -12
- data/sig/generated/paper_trail_diff/prepared_history.rbs +33 -22
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +15 -13
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +15 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +56 -11
- data/sig/generated/paper_trail_diff/recorded_changes.rbs +47 -0
- data/sig/generated/paper_trail_diff/recorded_class.rbs +22 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +185 -0
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +117 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +41 -7
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +6 -0
- data/sig/generated/paper_trail_diff/support.rbs +5 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +4 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +4 -4
- data/sig/generated/paper_trail_diff/version_association_candidate_scope.rbs +7 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +4 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -0
- data/sig/generated/paper_trail_diff.rbs +21 -2
- 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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
69
|
+
related = @related&.call || []
|
|
70
|
+
queries = subqueries(related)
|
|
71
|
+
return database_selection(queries) if queries
|
|
62
72
|
|
|
63
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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: #{
|
|
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.
|
|
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)
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
227
|
+
def record_page(relation, consumed)
|
|
183
228
|
count = page_limit(consumed)
|
|
184
229
|
return [] if count.zero?
|
|
185
230
|
|
|
186
|
-
|
|
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
|
|
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
|
-
|
|
278
|
+
model.base_class
|
|
229
279
|
end
|
|
230
280
|
|
|
231
281
|
#: () -> untyped
|
|
232
282
|
def primary_key
|
|
233
|
-
|
|
283
|
+
model.primary_key
|
|
234
284
|
end
|
|
235
285
|
|
|
236
286
|
#: () -> untyped
|
|
237
287
|
def version_class
|
|
238
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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))
|
|
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
|
|
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,
|
|
8
|
-
def initialize(record, time_range:, version_scope
|
|
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
|
-
#: (
|
|
16
|
-
def select_plan(context_required:
|
|
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)
|
|
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),
|
|
@@ -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
|
|
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
|
|
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.
|
|
107
|
+
sql.split.join(' ')
|
|
98
108
|
end
|
|
99
109
|
end
|
|
100
110
|
end
|