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
|
@@ -91,7 +91,7 @@ module PaperTrailDiff
|
|
|
91
91
|
selected = traversal.selected_reflections(model_class)
|
|
92
92
|
inspect_targets(selected)
|
|
93
93
|
inspect_checkpoint_timestamp(model_class)
|
|
94
|
-
inspect_habtm(selected)
|
|
94
|
+
inspect_habtm(model_class, selected)
|
|
95
95
|
DiagnosticReport.new(issues: @issues)
|
|
96
96
|
end
|
|
97
97
|
|
|
@@ -126,14 +126,32 @@ module PaperTrailDiff
|
|
|
126
126
|
end
|
|
127
127
|
end
|
|
128
128
|
|
|
129
|
-
#: (Array[Array[untyped]]) -> void
|
|
130
|
-
def inspect_habtm(selected)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
129
|
+
#: (untyped, Array[Array[untyped]]) -> void
|
|
130
|
+
def inspect_habtm(model_class, selected)
|
|
131
|
+
habtm = selected.select { |_, reflection| reflection.macro == :has_and_belongs_to_many }
|
|
132
|
+
return if habtm.empty?
|
|
133
|
+
|
|
134
|
+
inspect_transaction_metadata(habtm.map(&:first))
|
|
135
|
+
inspect_shared_transactions(model_class, habtm)
|
|
136
|
+
end
|
|
135
137
|
|
|
136
|
-
|
|
138
|
+
# PT-AT does not record which owner a HABTM member belongs to, so a
|
|
139
|
+
# transaction that more than one owner recorded members in cannot be read.
|
|
140
|
+
#: (untyped, Array[Array[untyped]]) -> void
|
|
141
|
+
def inspect_shared_transactions(model_class, habtm)
|
|
142
|
+
versions = [@from_version, @to_version].select do |version|
|
|
143
|
+
version.respond_to?(:transaction_id) && version.transaction_id
|
|
144
|
+
end
|
|
145
|
+
habtm.each do |path, reflection|
|
|
146
|
+
owners = HabtmTransactionOwners.new(model_class, reflection)
|
|
147
|
+
versions.each do |version|
|
|
148
|
+
next unless owners.ambiguous?(version.transaction_id)
|
|
149
|
+
|
|
150
|
+
add_error(:habtm_transaction_shared,
|
|
151
|
+
'HABTM endpoint shares its transaction with another owner of the association',
|
|
152
|
+
path, version.id)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
137
155
|
end
|
|
138
156
|
|
|
139
157
|
#: () -> void
|
|
@@ -45,13 +45,22 @@ module PaperTrailDiff
|
|
|
45
45
|
|
|
46
46
|
#: (untyped) -> bool
|
|
47
47
|
def plain_model?(model)
|
|
48
|
-
!model.primary_key.is_a?(Array) && !model
|
|
48
|
+
!model.primary_key.is_a?(Array) && !scope_assigns_attributes?(model) &&
|
|
49
49
|
model._initialize_callbacks.empty? &&
|
|
50
50
|
%i[initialize attributes []= write_attribute id].all? do |name|
|
|
51
51
|
framework_class?(model.instance_method(name).owner)
|
|
52
52
|
end
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# A default scope's equality conditions are assigned to every new record,
|
|
56
|
+
# through the model's writers, before a reified version's attributes are.
|
|
57
|
+
# A subclass's own type is assigned with or without one.
|
|
58
|
+
#: (untyped) -> bool
|
|
59
|
+
def scope_assigns_attributes?(model)
|
|
60
|
+
model.default_scopes? &&
|
|
61
|
+
model.default_scoped.scope_for_create != model.unscoped.scope_for_create
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
#: (untyped) -> bool
|
|
56
65
|
def plain_attributes?(model)
|
|
57
66
|
return false if encrypted?(model)
|
|
@@ -58,7 +58,8 @@ module PaperTrailDiff
|
|
|
58
58
|
# Raised when association comparison is requested without an available PT-AT setup.
|
|
59
59
|
class AssociationTrackingUnavailableError < Error; end
|
|
60
60
|
|
|
61
|
-
# Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association
|
|
61
|
+
# Raised when recorded PT-AT metadata cannot reliably reconstruct a selected association,
|
|
62
|
+
# or a polymorphic type names no class.
|
|
62
63
|
class IncompleteAssociationHistoryError < Error; end
|
|
63
64
|
|
|
64
65
|
# Raised when current-ended activity cannot be reconstructed for a selected association.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# PT-AT records HABTM members under a transaction id and the association's
|
|
6
|
+
# name, with nothing naming the record they belong to. When more than one
|
|
7
|
+
# record that can own that name saved a version in the transaction, their
|
|
8
|
+
# members are mixed together and none of them can be read back.
|
|
9
|
+
class HabtmTransactionOwners
|
|
10
|
+
#: (untyped, untyped) -> void
|
|
11
|
+
def initialize(owner_class, reflection)
|
|
12
|
+
@versions = owner_class.paper_trail.version_class
|
|
13
|
+
@associations = owner_class.paper_trail.version_association_class
|
|
14
|
+
@name = reflection.name.to_s
|
|
15
|
+
@owner_types = {} #: Hash[String, bool]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The given transactions (one id or several) in which more than one
|
|
19
|
+
# possible owner saved a version, as strings.
|
|
20
|
+
#: (untyped) -> Array[String]
|
|
21
|
+
def shared(transaction_ids)
|
|
22
|
+
items = @versions.where(transaction_id: transaction_ids).distinct
|
|
23
|
+
.pluck(:transaction_id, :item_type, :item_id)
|
|
24
|
+
owners = items.select { |_, type, _| owner_type?(type) }
|
|
25
|
+
owners.group_by { |transaction_id, _, _| transaction_id.to_s }
|
|
26
|
+
.filter_map { |transaction_id, rows| transaction_id if rows.size > 1 }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Members recorded in a shared transaction cannot be told apart. With no
|
|
30
|
+
# members recorded, every owner had none, so that reads correctly.
|
|
31
|
+
#: (untyped) -> bool
|
|
32
|
+
def ambiguous?(transaction_id)
|
|
33
|
+
shared(transaction_id).any? &&
|
|
34
|
+
@associations.exists?(version_id: transaction_id, foreign_key_name: @name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
#: (untyped) -> void
|
|
38
|
+
def ensure_single_owner!(transaction_id)
|
|
39
|
+
return unless ambiguous?(transaction_id)
|
|
40
|
+
|
|
41
|
+
message = "HABTM history is ambiguous for #{@name} at transaction #{transaction_id}: " \
|
|
42
|
+
"more than one record with a #{@name} association saved a version in it, " \
|
|
43
|
+
'and PT-AT does not record which members belong to which record'
|
|
44
|
+
raise IncompleteAssociationHistoryError, message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# @rbs @versions: untyped
|
|
50
|
+
# @rbs @associations: untyped
|
|
51
|
+
# @rbs @name: String
|
|
52
|
+
# @rbs @owner_types: Hash[String, bool]
|
|
53
|
+
|
|
54
|
+
# Versions name the base class, so a HABTM declared on a subclass counts.
|
|
55
|
+
# A type that names no model may have recorded members when it existed.
|
|
56
|
+
#: (String) -> bool
|
|
57
|
+
def owner_type?(type)
|
|
58
|
+
@owner_types.fetch(type) do
|
|
59
|
+
model_class = AssociationTraversal.named_class(type)
|
|
60
|
+
@owner_types[type] =
|
|
61
|
+
!model_class.respond_to?(:reflect_on_association) ||
|
|
62
|
+
[model_class, *model_class.descendants].any? do |candidate|
|
|
63
|
+
candidate.reflect_on_association(@name)&.macro == :has_and_belongs_to_many
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -37,7 +37,7 @@ module PaperTrailDiff
|
|
|
37
37
|
reified[reflection.name] = true
|
|
38
38
|
case reflection.macro
|
|
39
39
|
when :belongs_to
|
|
40
|
-
|
|
40
|
+
reify_belongs_to(record, reflection)
|
|
41
41
|
when :has_one
|
|
42
42
|
reifier(:HasOne).reify(reflection, record, options, @transaction_id)
|
|
43
43
|
when :has_many
|
|
@@ -47,6 +47,26 @@ module PaperTrailDiff
|
|
|
47
47
|
end
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
#: (untyped, untyped) -> void
|
|
51
|
+
def reify_belongs_to(record, reflection)
|
|
52
|
+
ensure_versioned_target!(record, reflection) if reflection.polymorphic?
|
|
53
|
+
reifier(:BelongsTo).reify(reflection, record, options, @transaction_id)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# A polymorphic target's class is known only from the record's type
|
|
57
|
+
# column, so TraversalPreparer cannot check it is versioned up front.
|
|
58
|
+
#: (untyped, untyped) -> void
|
|
59
|
+
def ensure_versioned_target!(record, reflection)
|
|
60
|
+
model_class = AssociationTraversal.belongs_to_target(reflection, record)
|
|
61
|
+
return if model_class.nil? || Support.versioned?(model_class)
|
|
62
|
+
|
|
63
|
+
raise UnversionedAssociationError,
|
|
64
|
+
"association #{record.class.name}##{reflection.name} cannot be compared " \
|
|
65
|
+
"historically: it points at #{model_class.name}, which is not versioned. Add " \
|
|
66
|
+
"`has_paper_trail` to #{model_class.name}, or mirror the fields you need onto a " \
|
|
67
|
+
'model that has it.'
|
|
68
|
+
end
|
|
69
|
+
|
|
50
70
|
#: (untyped, untyped) -> void
|
|
51
71
|
def reify_has_many(record, reflection)
|
|
52
72
|
if reflection.options[:through]
|
|
@@ -70,7 +90,7 @@ module PaperTrailDiff
|
|
|
70
90
|
#: (untyped, untyped) -> void
|
|
71
91
|
def reify_through(record, reflection)
|
|
72
92
|
source = reflection.source_reflection
|
|
73
|
-
if
|
|
93
|
+
if pt_at_source?(reflection)
|
|
74
94
|
reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
|
|
75
95
|
return
|
|
76
96
|
end
|
|
@@ -81,9 +101,17 @@ module PaperTrailDiff
|
|
|
81
101
|
association.loaded!
|
|
82
102
|
end
|
|
83
103
|
|
|
104
|
+
# PT-AT's belongs_to source ignores `source_type:` and reads targets by id
|
|
105
|
+
# alone, so a typed through walks its join records instead.
|
|
106
|
+
#: (untyped) -> bool
|
|
107
|
+
def pt_at_source?(reflection)
|
|
108
|
+
reflection.source_reflection.belongs_to? && !reflection.options[:source_type]
|
|
109
|
+
end
|
|
110
|
+
|
|
84
111
|
#: (untyped, untyped, untyped) -> Array[untyped]
|
|
85
112
|
def through_members(record, reflection, source)
|
|
86
113
|
joins = Array(record.public_send(reflection.through_reflection.name)) #: Array[untyped]
|
|
114
|
+
joins = joins.select { |join| AssociationTraversal.source_type_join?(reflection, join) }
|
|
87
115
|
joins.flat_map do |join|
|
|
88
116
|
reify_association(join, source)
|
|
89
117
|
Array(join.public_send(source.name))
|
|
@@ -96,6 +124,8 @@ module PaperTrailDiff
|
|
|
96
124
|
message = 'HABTM reconstruction requires a transaction-backed endpoint version'
|
|
97
125
|
raise IncompleteAssociationHistoryError, message
|
|
98
126
|
end
|
|
127
|
+
HabtmTransactionOwners.new(record.class, reflection)
|
|
128
|
+
.ensure_single_owner!(@habtm_transaction_id)
|
|
99
129
|
|
|
100
130
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
101
131
|
target_versioned = paper_trail.request.enabled_for_model?(reflection.klass)
|
|
@@ -34,12 +34,8 @@ module PaperTrailDiff
|
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
@prepared_history = PreparedHistoryLoader.new(
|
|
37
|
-
record,
|
|
38
|
-
|
|
39
|
-
start_at: start_at,
|
|
40
|
-
end_at: end_at,
|
|
41
|
-
tree: @tree,
|
|
42
|
-
traversal: @traversal
|
|
37
|
+
record, root_ids: [record.id], root_versions: root_versions, start_at: start_at,
|
|
38
|
+
end_at: end_at, tree: @tree, traversal: @traversal, live_records: []
|
|
43
39
|
).call
|
|
44
40
|
end
|
|
45
41
|
|
|
@@ -52,8 +48,9 @@ module PaperTrailDiff
|
|
|
52
48
|
end
|
|
53
49
|
end
|
|
54
50
|
|
|
55
|
-
# Missing roots
|
|
56
|
-
#
|
|
51
|
+
# Missing roots, and roots compared only between versions, have no live
|
|
52
|
+
# graph to collect. Reading their associations here would issue a query
|
|
53
|
+
# per root and seed nonexistent rows as live state.
|
|
57
54
|
#: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
58
55
|
def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
|
|
59
56
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
@@ -175,7 +172,7 @@ module PaperTrailDiff
|
|
|
175
172
|
|
|
176
173
|
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
177
174
|
def snapshot_from_version(root_version, context_version, tree:, normalizer:)
|
|
178
|
-
model_class =
|
|
175
|
+
model_class = RecordedClass.for(root_version)
|
|
179
176
|
@preparer.call(model_class, historical: true)
|
|
180
177
|
@traversal.ensure_habtm_history!(model_class, root_version)
|
|
181
178
|
handled, snapshot = @prepared_snapshotter.call(
|
|
@@ -44,7 +44,9 @@ module PaperTrailDiff
|
|
|
44
44
|
|
|
45
45
|
#: (untyped, Array[untyped], AssociationTree) -> void
|
|
46
46
|
def preload_model_tree(model_class, records, tree)
|
|
47
|
-
|
|
47
|
+
reflections = selected_reflections(model_class, tree)
|
|
48
|
+
resolve_polymorphic_targets(records, reflections)
|
|
49
|
+
batch, individual = reflections.partition do |reflection, _|
|
|
48
50
|
batch_safe?(records.fetch(0), reflection)
|
|
49
51
|
end
|
|
50
52
|
preload_batch(records, batch.map { |reflection, _| reflection.name })
|
|
@@ -62,6 +64,15 @@ module PaperTrailDiff
|
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
|
|
67
|
+
# Active Record raises a bare NameError for a type that names no class, so
|
|
68
|
+
# each type is resolved first to raise one that names the record.
|
|
69
|
+
#: (Array[untyped], Array[[untyped, AssociationTree]]) -> void
|
|
70
|
+
def resolve_polymorphic_targets(records, reflections)
|
|
71
|
+
reflections.map(&:first).select(&:polymorphic?).each do |reflection|
|
|
72
|
+
records.each { |record| AssociationTraversal.belongs_to_target(reflection, record) }
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
65
76
|
#: (Array[untyped], Array[[untyped, AssociationTree]], individually: bool) -> void
|
|
66
77
|
def preload_subtrees(records, reflections, individually:)
|
|
67
78
|
reflections.each do |reflection, subtree|
|
|
@@ -90,16 +101,38 @@ module PaperTrailDiff
|
|
|
90
101
|
active_record.const_get(:Associations).const_get(:Preloader)
|
|
91
102
|
end
|
|
92
103
|
|
|
104
|
+
# The preloader applies a limit or offset to all owners' records at once,
|
|
105
|
+
# not to each owner's, so such an association is loaded per record. A
|
|
106
|
+
# has_one is no exception. Through a part with a limit, an offset or a
|
|
107
|
+
# has_one cut, the preloader keeps what the part alone would load, while
|
|
108
|
+
# the through's own query keeps the part's conditions only.
|
|
93
109
|
#: (untyped, untyped) -> bool
|
|
94
110
|
def batch_safe?(record, reflection)
|
|
95
111
|
scope = reflection.scope
|
|
96
112
|
return false if scope && !scope.arity.zero?
|
|
97
|
-
return true unless reflection.collection?
|
|
113
|
+
return true unless reflection.collection? || reflection.has_one?
|
|
114
|
+
return false if cut_part?(reflection)
|
|
98
115
|
|
|
99
116
|
relation = record.association(reflection.name).scope
|
|
100
117
|
relation.limit_value.nil? && relation.offset_value.nil?
|
|
101
118
|
end
|
|
102
119
|
|
|
120
|
+
#: (untyped) -> bool
|
|
121
|
+
def cut_part?(reflection)
|
|
122
|
+
reflection.collection? &&
|
|
123
|
+
(ScopeCondition.ranked_parts?(reflection) || through_parts(reflection).any?(&:has_one?))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Every part a through passes, at any depth.
|
|
127
|
+
#: (untyped) -> Array[untyped]
|
|
128
|
+
def through_parts(reflection)
|
|
129
|
+
return [] unless reflection.through_reflection?
|
|
130
|
+
|
|
131
|
+
[reflection.through_reflection, reflection.source_reflection].flat_map do |part|
|
|
132
|
+
[part, *through_parts(part)]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
103
136
|
#: (Array[untyped], untyped) -> Array[untyped]
|
|
104
137
|
def load_individually(records, reflection)
|
|
105
138
|
unique_records(records.flat_map do |record|
|
|
@@ -36,7 +36,7 @@ module PaperTrailDiff
|
|
|
36
36
|
subtree = tree.child(reflection.name)
|
|
37
37
|
next unless subtree
|
|
38
38
|
|
|
39
|
-
child_path = Support.association_path(path, reflection.name
|
|
39
|
+
child_path = Support.association_path(path, reflection.name)
|
|
40
40
|
collect(children_for(records, reflection), subtree, path: child_path, into: into)
|
|
41
41
|
end
|
|
42
42
|
end
|
|
@@ -38,8 +38,9 @@ module PaperTrailDiff
|
|
|
38
38
|
if @association_tree.empty?
|
|
39
39
|
Engine.compare(snapshot_for_endpoint(from_endpoint), snapshot_for_endpoint(to_endpoint))
|
|
40
40
|
else
|
|
41
|
-
# Prepared history orders versions that share a timestamp
|
|
42
|
-
#
|
|
41
|
+
# Prepared history orders versions that share a timestamp and finds
|
|
42
|
+
# children that later moved away; reading associations straight from
|
|
43
|
+
# PT-AT would resolve them by time alone and miss those children.
|
|
43
44
|
comparison_batch([{ from: from_endpoint, to: to_endpoint }],
|
|
44
45
|
preparer: @traversal_preparer.method(:call)).call.values.fetch(0)
|
|
45
46
|
end
|
|
@@ -133,7 +134,7 @@ module PaperTrailDiff
|
|
|
133
134
|
#: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?, ?historical_filter: untyped, ?max_filter_versions: Integer, ?include_deleted: bool) -> ScopedAnalysis
|
|
134
135
|
def analyze_scope(scope, limit:, within:, activity: false, version_scope: nil, close_on: nil, snapshots: false, group: nil, historical_filter: nil, max_filter_versions: HistoricalFilter::DEFAULT_VERSION_LIMIT, include_deleted: false) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
135
136
|
ActivityGrouping.validate!(group, activity: activity, snapshots: snapshots)
|
|
136
|
-
|
|
137
|
+
check_scope(scope, activity)
|
|
137
138
|
# @type var options: report_options
|
|
138
139
|
options = { within: within, activity: activity, version_scope: version_scope,
|
|
139
140
|
close_on: close_on, snapshots: snapshots, group: group }
|
|
@@ -143,18 +144,32 @@ module PaperTrailDiff
|
|
|
143
144
|
analyzer: ->(records) { analyze_many(records, **options, within: within) },
|
|
144
145
|
deleted_analyzer: lambda { |identities|
|
|
145
146
|
analyze_deleted(scope.all.model, identities, options)
|
|
146
|
-
}
|
|
147
|
+
},
|
|
148
|
+
# Checkpoints compare a root's own versions, so only activity can
|
|
149
|
+
# report a window in which just its associations changed.
|
|
150
|
+
activity_tree: (@association_tree if activity)
|
|
147
151
|
).call
|
|
148
152
|
end
|
|
149
153
|
|
|
150
154
|
private
|
|
151
155
|
|
|
156
|
+
# Selecting by activity reads association history, so it must be available
|
|
157
|
+
# before the first selection query rather than once analysis starts.
|
|
158
|
+
#: (untyped, bool) -> void
|
|
159
|
+
def check_scope(scope, activity)
|
|
160
|
+
return unless scope.respond_to?(:all) && scope.respond_to?(:where)
|
|
161
|
+
|
|
162
|
+
@batch_safety.call(scope.all.model)
|
|
163
|
+
@traversal_preparer.call(scope.all.model, historical: true) if activity
|
|
164
|
+
end
|
|
165
|
+
|
|
152
166
|
#: (Array[comparison_input], preparer: untyped) -> ComparisonBatch
|
|
153
167
|
def comparison_batch(comparisons, preparer:)
|
|
154
168
|
ComparisonBatch.new(
|
|
155
169
|
comparisons,
|
|
156
170
|
live_loader: @live_endpoints, preparer: preparer,
|
|
157
171
|
history_preparer: @historical_store.method(:prepare_batch),
|
|
172
|
+
version_history_preparer: @historical_store.method(:prepare_deleted_batch),
|
|
158
173
|
historical_snapshotter: method(:historical_snapshot),
|
|
159
174
|
live_normalizer: method(:normalize_live_snapshot)
|
|
160
175
|
)
|
|
@@ -168,7 +183,7 @@ module PaperTrailDiff
|
|
|
168
183
|
|
|
169
184
|
#: (untyped, historical: bool) -> void
|
|
170
185
|
def prepare_batch_model(model, historical:)
|
|
171
|
-
@batch_safety.call(model
|
|
186
|
+
@batch_safety.call(model)
|
|
172
187
|
@traversal_preparer.call(model, historical: historical)
|
|
173
188
|
end
|
|
174
189
|
|
|
@@ -185,7 +200,8 @@ module PaperTrailDiff
|
|
|
185
200
|
model, identities: identities, time_range: within.nil? ? nil : TimeRange.new(within),
|
|
186
201
|
version_scope: options.fetch(:version_scope),
|
|
187
202
|
history_preparer: @historical_store.method(:prepare_deleted_batch),
|
|
188
|
-
analyzer: analyzer
|
|
203
|
+
analyzer: analyzer,
|
|
204
|
+
context_required: options.fetch(:activity) && !@association_tree.empty?
|
|
189
205
|
).call
|
|
190
206
|
end
|
|
191
207
|
|
|
@@ -30,7 +30,7 @@ module PaperTrailDiff
|
|
|
30
30
|
|
|
31
31
|
#: (untyped, AssociationTree, String, untyped) -> void
|
|
32
32
|
def validate_association(record, tree, path, reflection)
|
|
33
|
-
association_path = Support.association_path(path, reflection.name
|
|
33
|
+
association_path = Support.association_path(path, reflection.name)
|
|
34
34
|
association = record.association(reflection.name)
|
|
35
35
|
unless association.loaded?
|
|
36
36
|
message = "current endpoint association is not preloaded: #{association_path}"
|
|
@@ -10,39 +10,93 @@ module PaperTrailDiff
|
|
|
10
10
|
@range = ActivityRange.new(start_at, end_at)
|
|
11
11
|
@roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
|
|
12
12
|
@nodes = {} #: Hash[String, Array[untyped]]
|
|
13
|
-
|
|
14
|
-
@
|
|
13
|
+
# Rows are cached by reflection, which a subclass shares with its base.
|
|
14
|
+
@children = {} #: Hash[untyped, untyped]
|
|
15
|
+
@targets = {} #: Hash[untyped, untyped]
|
|
16
|
+
@rows = {} #: Hash[untyped, Hash[String, Array[untyped]]]
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
#: (untyped, Array[untyped]) -> void
|
|
18
20
|
def register(model, ids)
|
|
19
|
-
name = model.base_class.name
|
|
21
|
+
name = model.base_class.name
|
|
20
22
|
@nodes[name] = (@nodes.fetch(name, []) | ids)
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
24
26
|
def versions(model, ids)
|
|
25
27
|
ids.flat_map do |id|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
@roots[[model.base_class.name, id.to_s]] || @records.versions_for_id(model, id)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Children are found by key across the whole table, so an association to
|
|
33
|
+
# an STI subclass finds the rows of its siblings too. Only a row that was
|
|
34
|
+
# of the class at some point belongs to it, and of that row only the
|
|
35
|
+
# versions it had the class before or after.
|
|
36
|
+
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
37
|
+
def member_ids(model, ids)
|
|
38
|
+
return ids if model.descends_from_active_record?
|
|
39
|
+
|
|
40
|
+
ids.select { |id| member_class?(model, @records.model_classes(model, id)) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped, Array[untyped], ActivityRange) -> Array[untyped]
|
|
44
|
+
def member_versions(model, ids, range)
|
|
45
|
+
all = versions(model, ids).select { |version| range.include?(version) }
|
|
46
|
+
return all if model.descends_from_active_record?
|
|
47
|
+
|
|
48
|
+
all.select do |version|
|
|
49
|
+
member_class?(model, @records.model_classes(model, version.item_id, version))
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The versions of a through's join rows that are on its path. Under
|
|
54
|
+
# `source_type:`, a row is on it only while its type column names that
|
|
55
|
+
# class, so only the versions it had that type before or after count. A
|
|
56
|
+
# version saved before the type column existed has no type to read.
|
|
57
|
+
#: (Hash[Symbol, untyped], untyped) -> Array[untyped]
|
|
58
|
+
def join_versions(group, reflection)
|
|
59
|
+
versions = group.fetch(:versions)
|
|
60
|
+
source_type = reflection.options[:source_type]
|
|
61
|
+
return versions unless source_type
|
|
62
|
+
|
|
63
|
+
column = reflection.foreign_type
|
|
64
|
+
versions.select do |version|
|
|
65
|
+
@records.states_around(group.fetch(:model), version.item_id, version).any? do |state|
|
|
66
|
+
state.attributes[column] == source_type.to_s
|
|
67
|
+
end
|
|
28
68
|
end
|
|
29
69
|
end
|
|
30
70
|
|
|
31
71
|
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
32
72
|
def child_ids(model, ids, reflection, range)
|
|
33
|
-
|
|
34
|
-
historical, live = @children[key] ||= child_rows(model, reflection)
|
|
73
|
+
historical, live = @children[reflection] ||= child_rows(model, reflection)
|
|
35
74
|
empty = [] #: Array[untyped]
|
|
36
75
|
selected = historical_ids(reflection.klass, ids, historical, range)
|
|
37
76
|
selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
|
|
38
77
|
selected.uniq
|
|
39
78
|
end
|
|
40
79
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
80
|
+
# The `belongs_to` targets each owner pointed at in the range: at its start
|
|
81
|
+
# and after each change inside it, plus the live row at a current end.
|
|
82
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[ActivityTargetCandidateLoader::Row]
|
|
83
|
+
def target_rows(model, ids, reflection, range)
|
|
84
|
+
historical, live = @targets[reflection] ||= target_candidates(model, reflection)
|
|
85
|
+
rows = eligible_rows(model, rows_for(historical, ids), range)
|
|
86
|
+
range.current_end? ? rows + rows_for(live, ids) : rows
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: (untyped, untyped) -> bool
|
|
90
|
+
def loaded?(model, id)
|
|
91
|
+
@records.loaded?(model, id)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# PT-AT records HABTM membership once per transaction, under the
|
|
95
|
+
# association's name, so the rows are keyed by transaction id.
|
|
96
|
+
#: (untyped, Array[untyped], untyped) -> Array[untyped]
|
|
97
|
+
def habtm_rows(model, selected_versions, reflection)
|
|
98
|
+
rows = @rows[reflection] ||= load_habtm_rows(model, reflection)
|
|
99
|
+
transaction_ids(selected_versions).flat_map { |id| rows.fetch(id.to_s, []) }
|
|
46
100
|
end
|
|
47
101
|
|
|
48
102
|
private
|
|
@@ -51,22 +105,29 @@ module PaperTrailDiff
|
|
|
51
105
|
# @rbs @range: ActivityRange
|
|
52
106
|
# @rbs @roots: Hash[Array[String], Array[untyped]]
|
|
53
107
|
# @rbs @nodes: Hash[String, Array[untyped]]
|
|
54
|
-
# @rbs @children: Hash[
|
|
55
|
-
# @rbs @
|
|
108
|
+
# @rbs @children: Hash[untyped, untyped]
|
|
109
|
+
# @rbs @targets: Hash[untyped, untyped]
|
|
110
|
+
# @rbs @rows: Hash[untyped, Hash[String, Array[untyped]]]
|
|
111
|
+
|
|
112
|
+
#: (untyped, Array[untyped]) -> bool
|
|
113
|
+
def member_class?(model, classes)
|
|
114
|
+
classes.any? { |model_class| model_class <= model }
|
|
115
|
+
end
|
|
56
116
|
|
|
57
117
|
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
58
118
|
def historical_ids(model, ids, historical, range)
|
|
59
119
|
empty = [] #: Array[untyped]
|
|
60
120
|
ids.flat_map do |id|
|
|
61
121
|
historical.fetch(id.to_s, empty).filter_map do |row|
|
|
62
|
-
|
|
122
|
+
_owner, child_id, timestamp, event = row
|
|
123
|
+
child_id if eligible_version?(model, child_id, timestamp, event, range)
|
|
63
124
|
end
|
|
64
125
|
end
|
|
65
126
|
end
|
|
66
127
|
|
|
67
128
|
#: (untyped) -> Array[untyped]
|
|
68
129
|
def node_ids(model)
|
|
69
|
-
name = model.base_class.name
|
|
130
|
+
name = model.base_class.name
|
|
70
131
|
@nodes.fetch(name) do
|
|
71
132
|
raise ConfigurationError,
|
|
72
133
|
"prepared activity graph is missing #{name}; " \
|
|
@@ -84,12 +145,31 @@ module PaperTrailDiff
|
|
|
84
145
|
.transform_values { |rows| rows.map(&:last) }]
|
|
85
146
|
end
|
|
86
147
|
|
|
148
|
+
#: (Hash[String, Array[ActivityTargetCandidateLoader::Row]], Array[untyped]) -> Array[ActivityTargetCandidateLoader::Row]
|
|
149
|
+
def rows_for(grouped, ids)
|
|
150
|
+
ids.flat_map { |id| grouped[id.to_s] || [] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
#: (untyped, Array[ActivityTargetCandidateLoader::Row], ActivityRange) -> Array[ActivityTargetCandidateLoader::Row]
|
|
154
|
+
def eligible_rows(model, rows, range)
|
|
155
|
+
rows.select do |row|
|
|
156
|
+
eligible_version?(model, row.owner_id, row.created_at, row.event, range) &&
|
|
157
|
+
range.recorded_before_end?(row.created_at, row.version_id)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
#: (untyped, untyped) -> untyped
|
|
162
|
+
def target_candidates(model, reflection)
|
|
163
|
+
loader = ActivityTargetCandidateLoader.new(model, node_ids(model), reflection, @range)
|
|
164
|
+
[loader.historical_rows.group_by { |row| row.owner_id.to_s },
|
|
165
|
+
loader.current_rows.group_by { |row| row.owner_id.to_s }]
|
|
166
|
+
end
|
|
167
|
+
|
|
87
168
|
# Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
|
|
88
169
|
# series includes changes to other owners too, so an old membership cannot
|
|
89
170
|
# survive a later pre-window reassignment just because that owner is absent.
|
|
90
|
-
#: (untyped,
|
|
91
|
-
def
|
|
92
|
-
_owner, id, timestamp, event = row
|
|
171
|
+
#: (untyped, untyped, untyped, String?, ActivityRange) -> bool
|
|
172
|
+
def eligible_version?(model, id, timestamp, event, range)
|
|
93
173
|
return timestamp <= range.end_time if timestamp >= range.start_time
|
|
94
174
|
return false if event == 'destroy'
|
|
95
175
|
|
|
@@ -98,24 +178,18 @@ module PaperTrailDiff
|
|
|
98
178
|
!later || later.created_at >= range.start_time
|
|
99
179
|
end
|
|
100
180
|
|
|
101
|
-
#: (untyped, untyped
|
|
102
|
-
def
|
|
103
|
-
all_versions = versions(model, node_ids(model))
|
|
104
|
-
foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
|
|
181
|
+
#: (untyped, untyped) -> Hash[String, Array[untyped]]
|
|
182
|
+
def load_habtm_rows(model, reflection)
|
|
105
183
|
model.paper_trail.version_association_class.where(
|
|
106
|
-
version_id:
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
end.uniq
|
|
116
|
-
else
|
|
117
|
-
versions.map(&:id)
|
|
118
|
-
end
|
|
184
|
+
version_id: transaction_ids(versions(model, node_ids(model))),
|
|
185
|
+
foreign_key_name: reflection.name
|
|
186
|
+
).group_by { |row| row.version_id.to_s }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# A version without a transaction id names no rows, so it can stay in.
|
|
190
|
+
#: (Array[untyped]) -> Array[untyped]
|
|
191
|
+
def transaction_ids(versions)
|
|
192
|
+
versions.map { |version| version.transaction_id if version.respond_to?(:transaction_id) }.uniq
|
|
119
193
|
end
|
|
120
194
|
end
|
|
121
195
|
end
|