paper_trail_diff 0.12.0 → 0.13.1
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 +158 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1388
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +169 -0
- data/lib/paper_trail_diff/activity_belongs_to_event_applier.rb +2 -12
- data/lib/paper_trail_diff/activity_boundary.rb +20 -8
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +29 -0
- data/lib/paper_trail_diff/activity_collection_event_applier.rb +10 -7
- data/lib/paper_trail_diff/activity_collection_route_updater.rb +1 -11
- data/lib/paper_trail_diff/activity_event_record_normalizer.rb +4 -0
- data/lib/paper_trail_diff/activity_event_record_resolver.rb +66 -6
- data/lib/paper_trail_diff/activity_event_snapshot_refresher.rb +6 -4
- data/lib/paper_trail_diff/activity_grouping.rb +12 -0
- data/lib/paper_trail_diff/activity_history.rb +9 -3
- data/lib/paper_trail_diff/activity_relationship.rb +2 -0
- data/lib/paper_trail_diff/activity_root_snapshot_refresher.rb +1 -0
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +9 -2
- data/lib/paper_trail_diff/activity_timeline_builder.rb +39 -35
- data/lib/paper_trail_diff/activity_version_collector.rb +27 -18
- data/lib/paper_trail_diff/analysis.rb +9 -4
- data/lib/paper_trail_diff/analysis_batch.rb +28 -7
- data/lib/paper_trail_diff/association_traversal.rb +23 -0
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +18 -26
- data/lib/paper_trail_diff/batch_safety.rb +55 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +14 -5
- data/lib/paper_trail_diff/batched_root_versions.rb +10 -38
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/collection_comparator.rb +18 -20
- data/lib/paper_trail_diff/collection_identity_index.rb +8 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +94 -0
- data/lib/paper_trail_diff/direct_record_state_loader.rb +81 -0
- data/lib/paper_trail_diff/endpoint.rb +10 -0
- data/lib/paper_trail_diff/engine.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +9 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +26 -1
- data/lib/paper_trail_diff/historical_filter.rb +176 -0
- data/lib/paper_trail_diff/historical_snapshot_store.rb +51 -35
- data/lib/paper_trail_diff/instrumentation.rb +2 -2
- data/lib/paper_trail_diff/nested_comparator.rb +39 -15
- data/lib/paper_trail_diff/paper_trail_adapter.rb +109 -55
- data/lib/paper_trail_diff/prepared_activity_index.rb +121 -0
- data/lib/paper_trail_diff/prepared_edge_loader.rb +11 -11
- data/lib/paper_trail_diff/prepared_history.rb +21 -30
- data/lib/paper_trail_diff/prepared_history_loader.rb +14 -2
- data/lib/paper_trail_diff/prepared_record_index.rb +102 -29
- data/lib/paper_trail_diff/prepared_snapshot_builder.rb +64 -0
- data/lib/paper_trail_diff/prepared_state_association_reader.rb +77 -0
- data/lib/paper_trail_diff/root_version_plan.rb +35 -3
- data/lib/paper_trail_diff/root_version_selection.rb +16 -5
- data/lib/paper_trail_diff/scoped_analysis.rb +17 -2
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +47 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +106 -13
- data/lib/paper_trail_diff/snapshot.rb +14 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +75 -40
- data/lib/paper_trail_diff/step.rb +10 -5
- data/lib/paper_trail_diff/stored_time_precision.rb +68 -0
- data/lib/paper_trail_diff/support.rb +13 -3
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +19 -7
- data/lib/paper_trail_diff/time_range.rb +21 -2
- data/lib/paper_trail_diff/time_version_range.rb +2 -16
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +29 -46
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +13 -2
- data/lib/paper_trail_diff/traversal.rb +1 -1
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_association_candidate_scope.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +48 -0
- data/lib/paper_trail_diff/version_query.rb +41 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +78 -42
- data/sig/generated/paper_trail_diff/activity_belongs_to_event_applier.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +9 -4
- data/sig/generated/paper_trail_diff/activity_child_candidate_loader.rbs +11 -0
- data/sig/generated/paper_trail_diff/activity_collection_event_applier.rbs +4 -4
- data/sig/generated/paper_trail_diff/activity_collection_route_updater.rbs +0 -3
- data/sig/generated/paper_trail_diff/activity_event_record_resolver.rbs +19 -3
- data/sig/generated/paper_trail_diff/activity_event_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +3 -0
- data/sig/generated/paper_trail_diff/activity_history.rbs +6 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +7 -2
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +8 -3
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +4 -8
- data/sig/generated/paper_trail_diff/analysis.rbs +2 -2
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +14 -3
- data/sig/generated/paper_trail_diff/association_traversal.rbs +11 -0
- data/sig/generated/paper_trail_diff/batch_boundary_resolver.rbs +7 -10
- data/sig/generated/paper_trail_diff/batch_safety.rbs +24 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +8 -2
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +4 -16
- data/sig/generated/paper_trail_diff/branch_snapshot_refresher.rbs +2 -2
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +6 -3
- data/sig/generated/paper_trail_diff/collection_identity_index.rbs +3 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +44 -0
- data/sig/generated/paper_trail_diff/direct_record_state_loader.rbs +42 -0
- data/sig/generated/paper_trail_diff/endpoint.rbs +5 -0
- data/sig/generated/paper_trail_diff/engine.rbs +4 -2
- data/sig/generated/paper_trail_diff/errors.rbs +12 -0
- data/sig/generated/paper_trail_diff/historical_association_reifier.rbs +8 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +75 -0
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +17 -4
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +15 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +36 -15
- data/sig/generated/paper_trail_diff/prepared_activity_index.rbs +56 -0
- data/sig/generated/paper_trail_diff/prepared_edge_loader.rbs +3 -3
- data/sig/generated/paper_trail_diff/prepared_history.rbs +12 -14
- data/sig/generated/paper_trail_diff/prepared_history_loader.rbs +2 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +43 -6
- data/sig/generated/paper_trail_diff/prepared_snapshot_builder.rbs +24 -0
- data/sig/generated/paper_trail_diff/prepared_state_association_reader.rbs +33 -0
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +22 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +8 -1
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +14 -2
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +22 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +46 -6
- data/sig/generated/paper_trail_diff/snapshot.rbs +4 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +34 -11
- data/sig/generated/paper_trail_diff/step.rbs +8 -2
- data/sig/generated/paper_trail_diff/stored_time_precision.rbs +36 -0
- data/sig/generated/paper_trail_diff/support.rbs +7 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +7 -2
- data/sig/generated/paper_trail_diff/time_range.rbs +9 -0
- data/sig/generated/paper_trail_diff/time_version_range.rbs +0 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +12 -15
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +21 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +31 -25
- metadata +31 -5
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Reconstructs selected root branches and merges them into a prior immutable snapshot.
|
|
6
6
|
class BranchSnapshotRefresher
|
|
7
|
-
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped) -> void
|
|
7
|
+
#: (tree: AssociationTree, ignore_policy: IgnorePolicy, traversal: AssociationTraversal, pool: SnapshotPool, normalizer: SnapshotNormalizer, full_snapshotter: untyped, partial_snapshotter: untyped, association_reader: untyped, record_transition: untyped, ?prepared_record_after: untyped) -> void
|
|
8
8
|
def initialize( # rubocop:disable Metrics/MethodLength, Metrics/ParameterLists
|
|
9
9
|
tree:,
|
|
10
10
|
ignore_policy:,
|
|
@@ -14,7 +14,8 @@ module PaperTrailDiff
|
|
|
14
14
|
full_snapshotter:,
|
|
15
15
|
partial_snapshotter:,
|
|
16
16
|
association_reader:,
|
|
17
|
-
record_transition
|
|
17
|
+
record_transition:,
|
|
18
|
+
prepared_record_after: nil
|
|
18
19
|
)
|
|
19
20
|
@tree = tree
|
|
20
21
|
@ignore_policy = ignore_policy
|
|
@@ -24,16 +25,14 @@ module PaperTrailDiff
|
|
|
24
25
|
@partial_snapshotter = partial_snapshotter
|
|
25
26
|
@components = {} #: Hash[Array[String], [AssociationTree, SnapshotNormalizer]]
|
|
26
27
|
@event_refresher = ActivityEventSnapshotRefresher.new(
|
|
27
|
-
traversal: traversal,
|
|
28
|
-
pool: pool,
|
|
28
|
+
traversal: traversal, pool: pool,
|
|
29
29
|
components: method(:components),
|
|
30
30
|
association_reader: association_reader,
|
|
31
|
-
record_transition: record_transition
|
|
31
|
+
record_transition: record_transition,
|
|
32
|
+
prepared_record_after: prepared_record_after
|
|
32
33
|
)
|
|
33
34
|
@root_refresher = ActivityRootSnapshotRefresher.new(
|
|
34
|
-
tree: tree,
|
|
35
|
-
traversal: traversal,
|
|
36
|
-
normalizer: normalizer,
|
|
35
|
+
tree: tree, traversal: traversal, normalizer: normalizer,
|
|
37
36
|
record_after: @event_refresher.method(:record_after),
|
|
38
37
|
changeset: @event_refresher.method(:deserialized_changeset),
|
|
39
38
|
partial_snapshotter: method(:partial_snapshot)
|
|
@@ -28,25 +28,21 @@ module PaperTrailDiff
|
|
|
28
28
|
# @rbs @to: AssociationSnapshot
|
|
29
29
|
# @rbs @record_comparer: untyped
|
|
30
30
|
|
|
31
|
+
# A transition names the one record that changed between adjacent
|
|
32
|
+
# snapshots. With matching identities it is an update; otherwise the
|
|
33
|
+
# before record left and the after record joined.
|
|
31
34
|
#: (CollectionTransition) -> CollectionAssociationDiff
|
|
32
35
|
def transition_difference(transition)
|
|
33
36
|
@from.validate_unique_identities!
|
|
34
37
|
@to.validate_unique_identities!
|
|
35
38
|
before = transition.before
|
|
36
39
|
after = transition.after
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
added:
|
|
40
|
-
|
|
41
|
-
changed: changed ? [changed] : []
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
#: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
|
|
46
|
-
def compare_transition_records(before, after)
|
|
47
|
-
return unless before && after && before.identity == after.identity
|
|
40
|
+
if before && after && before.identity == after.identity
|
|
41
|
+
change = @record_comparer.call(before, after)
|
|
42
|
+
return difference(added: [], removed: [], changed: change ? [change] : [])
|
|
43
|
+
end
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
difference(added: [after].compact, removed: [before].compact, changed: [])
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
#: () -> Array[RecordChange]?
|
|
@@ -70,17 +66,16 @@ module PaperTrailDiff
|
|
|
70
66
|
|
|
71
67
|
#: (Array[RecordSnapshot], Array[RecordSnapshot]) -> Array[[RecordSnapshot, RecordSnapshot]]?
|
|
72
68
|
def collect_aligned_pairs(from_records, to_records)
|
|
73
|
-
seen =
|
|
69
|
+
seen = Set.new #: Set[identity]
|
|
74
70
|
changed = [] #: Array[[RecordSnapshot, RecordSnapshot]]
|
|
75
71
|
index = 0
|
|
76
72
|
while index < from_records.length
|
|
77
73
|
from_record = from_records.fetch(index)
|
|
78
74
|
to_record = to_records.fetch(index)
|
|
79
75
|
identity = from_record.identity
|
|
80
|
-
raise_duplicate_identity!(identity)
|
|
76
|
+
raise_duplicate_identity!(identity) unless seen.add?(identity)
|
|
81
77
|
return unless identity == to_record.identity
|
|
82
78
|
|
|
83
|
-
seen[identity] = true
|
|
84
79
|
changed << [from_record, to_record] unless from_record.equal?(to_record)
|
|
85
80
|
index += 1
|
|
86
81
|
end
|
|
@@ -148,21 +143,24 @@ module PaperTrailDiff
|
|
|
148
143
|
# result is rendered.
|
|
149
144
|
#: (identity) -> Array[untyped]
|
|
150
145
|
def sortable_identity(identity)
|
|
151
|
-
[identity.fetch(0),
|
|
146
|
+
[identity.fetch(0), sortable_id(identity.fetch(1))]
|
|
152
147
|
end
|
|
153
148
|
|
|
149
|
+
# Only the order of the group numbers matters, so moving the lowest one
|
|
150
|
+
# down or the highest one up changes nothing; the spec checks the order.
|
|
151
|
+
# mutant:disable
|
|
154
152
|
#: (untyped) -> Array[untyped]
|
|
155
153
|
def sortable_id(id)
|
|
156
154
|
case id
|
|
157
|
-
when Numeric then [0, id
|
|
158
|
-
when String, Symbol then [1,
|
|
159
|
-
else [2,
|
|
155
|
+
when Numeric then [0, id]
|
|
156
|
+
when String, Symbol then [1, id.to_s]
|
|
157
|
+
else [2, id.inspect]
|
|
160
158
|
end
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
#: (identity) -> void
|
|
164
162
|
def raise_duplicate_identity!(identity)
|
|
165
|
-
raise ArgumentError, "duplicate record identity: #{identity
|
|
163
|
+
raise ArgumentError, "duplicate record identity: #{identity}"
|
|
166
164
|
end
|
|
167
165
|
end
|
|
168
166
|
end
|
|
@@ -6,19 +6,16 @@ module PaperTrailDiff
|
|
|
6
6
|
class CollectionIdentityIndex
|
|
7
7
|
#: (Array[untyped]) -> void
|
|
8
8
|
def initialize(records)
|
|
9
|
-
@
|
|
9
|
+
@identities = Set.new #: Set[Array[untyped]]
|
|
10
10
|
@serialized_positions = {} #: Hash[Array[String], Integer]
|
|
11
11
|
@id_positions = {} #: Hash[String, Integer?]
|
|
12
12
|
records.each_with_index { |record, index| register(record, index) }
|
|
13
|
-
@positions.freeze
|
|
14
|
-
@serialized_positions.freeze
|
|
15
|
-
@id_positions.freeze
|
|
16
13
|
freeze
|
|
17
14
|
end
|
|
18
15
|
|
|
19
|
-
#: (
|
|
16
|
+
#: (String, untyped) -> Integer?
|
|
20
17
|
def position(type, id)
|
|
21
|
-
@serialized_positions[[type
|
|
18
|
+
@serialized_positions[[type, id.to_s]]
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
#: (untyped) -> Integer?
|
|
@@ -28,26 +25,25 @@ module PaperTrailDiff
|
|
|
28
25
|
|
|
29
26
|
private
|
|
30
27
|
|
|
31
|
-
# @rbs @
|
|
28
|
+
# @rbs @identities: Set[Array[untyped]]
|
|
32
29
|
# @rbs @serialized_positions: Hash[Array[String], Integer]
|
|
33
30
|
# @rbs @id_positions: Hash[String, Integer?]
|
|
34
31
|
|
|
35
32
|
#: (untyped, Integer) -> void
|
|
36
33
|
def register(record, index)
|
|
37
34
|
identity = record.identity
|
|
38
|
-
|
|
39
|
-
raise ArgumentError, "duplicate record identity: #{identity
|
|
35
|
+
unless @identities.add?(identity)
|
|
36
|
+
raise ArgumentError, "duplicate record identity: #{identity}"
|
|
40
37
|
end
|
|
41
38
|
|
|
42
|
-
@
|
|
43
|
-
@serialized_positions[[record.type.to_s, record.id.to_s]] = index
|
|
39
|
+
@serialized_positions[[record.type, record.id.to_s]] = index
|
|
44
40
|
register_id(record.id, index)
|
|
45
41
|
end
|
|
46
42
|
|
|
47
43
|
#: (untyped, Integer) -> void
|
|
48
44
|
def register_id(id, index)
|
|
49
45
|
key = id.to_s
|
|
50
|
-
@id_positions[key] = @id_positions.key?(key)
|
|
46
|
+
@id_positions[key] = (index unless @id_positions.key?(key))
|
|
51
47
|
end
|
|
52
48
|
end
|
|
53
49
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Analyzes missing roots together without pretending that they have live rows.
|
|
6
|
+
class DeletedAnalysisBatch
|
|
7
|
+
#: (untyped, identities: Array[Array[String]], time_range: TimeRange?, analyzer: untyped, history_preparer: untyped, ?version_scope: untyped) -> void
|
|
8
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
9
|
+
model, identities:, time_range:, analyzer:, history_preparer:, version_scope: nil
|
|
10
|
+
)
|
|
11
|
+
@model = model
|
|
12
|
+
@identities = identities
|
|
13
|
+
@time_range = time_range
|
|
14
|
+
@analyzer = analyzer
|
|
15
|
+
@history_preparer = history_preparer
|
|
16
|
+
@version_scope = version_scope
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
#: () -> Hash[Array[String], Analysis?]
|
|
20
|
+
def call
|
|
21
|
+
empty = {} #: Hash[Array[String], Analysis?]
|
|
22
|
+
return empty.freeze if @identities.empty?
|
|
23
|
+
|
|
24
|
+
records = historical_roots
|
|
25
|
+
selected = BatchedRootVersions.new(
|
|
26
|
+
records, time_range: @time_range, version_scope: @version_scope
|
|
27
|
+
).call
|
|
28
|
+
prepare(records, selected)
|
|
29
|
+
analyses = records.to_h do |record|
|
|
30
|
+
key = Endpoint.identity(record)
|
|
31
|
+
plan = selected.fetch(key)
|
|
32
|
+
[key, plan.empty? ? Analysis.empty : @analyzer.call(record, plan)]
|
|
33
|
+
end
|
|
34
|
+
with_unavailable(analyses)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
#: (Hash[Array[String], Analysis]) -> Hash[Array[String], Analysis?]
|
|
40
|
+
def with_unavailable(analyses)
|
|
41
|
+
@identities.to_h { |key| [Support.immutable_copy(key), analyses[key]] }.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @rbs @model: untyped
|
|
45
|
+
# @rbs @identities: Array[Array[String]]
|
|
46
|
+
# @rbs @time_range: TimeRange?
|
|
47
|
+
# @rbs @analyzer: untyped
|
|
48
|
+
# @rbs @history_preparer: untyped
|
|
49
|
+
# @rbs @version_scope: untyped
|
|
50
|
+
|
|
51
|
+
# These records supply identity and model metadata only. Every reported
|
|
52
|
+
# value comes from the batched historical plan; no per-root reify or live
|
|
53
|
+
# lookup is needed to get into the comparison engine.
|
|
54
|
+
#: () -> Array[untyped]
|
|
55
|
+
def historical_roots
|
|
56
|
+
loader = PreparedVersionStateLoader.new
|
|
57
|
+
destroy_versions.map do |version|
|
|
58
|
+
model = loader.call(version)&.model_class || Endpoint.model_class(version)
|
|
59
|
+
record = model.new
|
|
60
|
+
record[model.primary_key] = version.item_id
|
|
61
|
+
record
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
#: () -> Array[untyped]
|
|
66
|
+
def destroy_versions
|
|
67
|
+
destroys = @model.paper_trail.version_class.where(
|
|
68
|
+
item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
|
|
69
|
+
)
|
|
70
|
+
versions = VersionQuery.chronological(destroys)
|
|
71
|
+
versions.group_by { |version| version.item_id.to_s }.values.map do |history|
|
|
72
|
+
Support.chronological_sort(history).last
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (Array[untyped]) -> Hash[Symbol, untyped]
|
|
77
|
+
def preparation_range(versions)
|
|
78
|
+
{ start_at: @time_range&.begin_time,
|
|
79
|
+
end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
83
|
+
def prepare(records, selected)
|
|
84
|
+
records.group_by(&:class).each_value do |group|
|
|
85
|
+
versions = group.flat_map do |record|
|
|
86
|
+
selected.fetch(Endpoint.identity(record)).reconstruction_versions
|
|
87
|
+
end
|
|
88
|
+
next if versions.empty?
|
|
89
|
+
|
|
90
|
+
@history_preparer.call(group, versions, **preparation_range(versions))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Completes plain recorded state without invoking model initialization or writers.
|
|
6
|
+
# Decline model behavior whose values require an actual ActiveRecord instance.
|
|
7
|
+
class DirectRecordStateLoader
|
|
8
|
+
#: () -> void
|
|
9
|
+
def initialize
|
|
10
|
+
@loader = PreparedVersionStateLoader.new
|
|
11
|
+
@supported = {} #: Hash[untyped, bool]
|
|
12
|
+
@defaults = {} #: Hash[untyped, Hash[untyped, untyped]]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
#: (untyped) -> PreparedRecordState?
|
|
16
|
+
def call(version)
|
|
17
|
+
state = @loader.call(version)
|
|
18
|
+
complete(state) if state
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
#: (PreparedRecordState) -> PreparedRecordState?
|
|
22
|
+
def complete(state)
|
|
23
|
+
model = state.model_class
|
|
24
|
+
return unless supported?(model)
|
|
25
|
+
|
|
26
|
+
defaults = @defaults[model] ||= model.column_defaults
|
|
27
|
+
attributes = defaults.merge(state.attributes).to_h do |name, value|
|
|
28
|
+
[name, model.type_for_attribute(name).cast(value)]
|
|
29
|
+
end
|
|
30
|
+
PreparedRecordState.from_attributes(model, attributes)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# @rbs @loader: PreparedVersionStateLoader
|
|
36
|
+
# @rbs @supported: Hash[untyped, bool]
|
|
37
|
+
# @rbs @defaults: Hash[untyped, Hash[untyped, untyped]]
|
|
38
|
+
|
|
39
|
+
#: (untyped) -> bool
|
|
40
|
+
def supported?(model)
|
|
41
|
+
return @supported[model] if @supported.key?(model)
|
|
42
|
+
|
|
43
|
+
@supported[model] = plain_model?(model) && plain_attributes?(model)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (untyped) -> bool
|
|
47
|
+
def plain_model?(model)
|
|
48
|
+
!model.primary_key.is_a?(Array) && !model.default_scopes? &&
|
|
49
|
+
model._initialize_callbacks.empty? &&
|
|
50
|
+
%i[initialize attributes []= write_attribute id].all? do |name|
|
|
51
|
+
framework_class?(model.instance_method(name).owner)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
#: (untyped) -> bool
|
|
56
|
+
def plain_attributes?(model)
|
|
57
|
+
return false if encrypted?(model)
|
|
58
|
+
return false unless model.attribute_types.keys.sort == model.column_names.sort
|
|
59
|
+
return false if application_defaults?(model)
|
|
60
|
+
|
|
61
|
+
model.attribute_types.values.all? { |type| framework_class?(type.class) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
#: (untyped) -> bool
|
|
65
|
+
def application_defaults?(model)
|
|
66
|
+
model._default_attributes.each_value.any? do |attribute|
|
|
67
|
+
attribute.class.name.to_s.end_with?('UserProvidedDefault')
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
#: (untyped) -> bool
|
|
72
|
+
def encrypted?(model)
|
|
73
|
+
!!(model.respond_to?(:encrypted_attributes) && model.encrypted_attributes&.any?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
#: (untyped) -> bool
|
|
77
|
+
def framework_class?(value)
|
|
78
|
+
value.name.to_s.start_with?('ActiveRecord::', 'ActiveModel::')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -82,6 +82,16 @@ module PaperTrailDiff
|
|
|
82
82
|
raise InvalidEndpointError, "cannot resolve endpoint model: #{model_type}", cause: e
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
+
# The record's own PaperTrail versions, under whatever name the model gave
|
|
86
|
+
# the association.
|
|
87
|
+
#: (untyped) -> untyped
|
|
88
|
+
def versions(record)
|
|
89
|
+
record.public_send(record.class.versions_association_name)
|
|
90
|
+
rescue NoMethodError => e
|
|
91
|
+
message = 'record does not expose a PaperTrail version history'
|
|
92
|
+
raise InvalidTimelineRangeError, message, cause: e
|
|
93
|
+
end
|
|
94
|
+
|
|
85
95
|
#: (untyped) -> untyped
|
|
86
96
|
def reload_record(record)
|
|
87
97
|
validate_record!(record)
|
|
@@ -71,6 +71,8 @@ module PaperTrailDiff
|
|
|
71
71
|
raise ArgumentError, 'association kind changed between snapshots'
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Kinds are symbols, so `!=`, `equal?`, and `eql?` all agree here.
|
|
75
|
+
# mutant:disable
|
|
74
76
|
#: (AssociationSnapshot?, AssociationSnapshot?) -> bool
|
|
75
77
|
def kinds_differ?(from_association, to_association)
|
|
76
78
|
return false unless from_association && to_association
|
|
@@ -131,9 +133,9 @@ module PaperTrailDiff
|
|
|
131
133
|
).call
|
|
132
134
|
end
|
|
133
135
|
|
|
134
|
-
#: (RecordSnapshot?, RecordSnapshot?) ->
|
|
136
|
+
#: (RecordSnapshot?, RecordSnapshot?) -> boolish
|
|
135
137
|
def same_identity?(from_record, to_record)
|
|
136
|
-
from_record && to_record
|
|
138
|
+
from_record && to_record && from_record.identity == to_record.identity
|
|
137
139
|
end
|
|
138
140
|
end
|
|
139
141
|
end
|
|
@@ -8,6 +8,9 @@ module PaperTrailDiff
|
|
|
8
8
|
# Raised when a public option has an invalid type, key, value, or path.
|
|
9
9
|
class ConfigurationError < Error; end
|
|
10
10
|
|
|
11
|
+
# Raised when nested data keys cannot be represented by unique string paths.
|
|
12
|
+
class AmbiguousNestedKeyError < Error; end
|
|
13
|
+
|
|
11
14
|
# Raised when compare endpoints do not belong to the same PaperTrail item.
|
|
12
15
|
class VersionMismatchError < Error; end
|
|
13
16
|
|
|
@@ -36,6 +39,12 @@ module PaperTrailDiff
|
|
|
36
39
|
# Truncating instead would hand back a report that is short without saying so.
|
|
37
40
|
class BatchLimitExceededError < Error; end
|
|
38
41
|
|
|
42
|
+
# Historical candidate loading exceeded its explicitly bounded work budget.
|
|
43
|
+
class FilterLimitExceededError < Error; end
|
|
44
|
+
|
|
45
|
+
# A batch selected a known expensive association without explicitly opting in.
|
|
46
|
+
class UnsupportedBatchPathError < ConfigurationError; end
|
|
47
|
+
|
|
39
48
|
# Raised when a requested ActiveRecord association does not exist.
|
|
40
49
|
class UnknownAssociationError < Error; end
|
|
41
50
|
|
|
@@ -51,7 +51,7 @@ module PaperTrailDiff
|
|
|
51
51
|
def reify_has_many(record, reflection)
|
|
52
52
|
if reflection.options[:through]
|
|
53
53
|
reify_association(record, reflection.through_reflection)
|
|
54
|
-
|
|
54
|
+
reify_through(record, reflection)
|
|
55
55
|
return
|
|
56
56
|
end
|
|
57
57
|
|
|
@@ -65,6 +65,31 @@ module PaperTrailDiff
|
|
|
65
65
|
)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
+
# PT-AT's collection-source helper reifies every has_many on each join
|
|
69
|
+
# record. Walk only the requested source instead, including nested throughs.
|
|
70
|
+
#: (untyped, untyped) -> void
|
|
71
|
+
def reify_through(record, reflection)
|
|
72
|
+
source = reflection.source_reflection
|
|
73
|
+
if source.belongs_to?
|
|
74
|
+
reifier(:HasManyThrough).reify(reflection, record, options, @transaction_id)
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
members = through_members(record, reflection, source)
|
|
79
|
+
association = record.association(reflection.name)
|
|
80
|
+
association.target = members.uniq { |member| [member.class.base_class.name, member.id] }
|
|
81
|
+
association.loaded!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
#: (untyped, untyped, untyped) -> Array[untyped]
|
|
85
|
+
def through_members(record, reflection, source)
|
|
86
|
+
joins = Array(record.public_send(reflection.through_reflection.name)) #: Array[untyped]
|
|
87
|
+
joins.flat_map do |join|
|
|
88
|
+
reify_association(join, source)
|
|
89
|
+
Array(join.public_send(source.name))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
68
93
|
#: (untyped, untyped) -> void
|
|
69
94
|
def reify_habtm(record, reflection)
|
|
70
95
|
unless @habtm_transaction_id
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Selects roots by the values they held at any point in the window, with an
|
|
6
|
+
# explicit ceiling on version loading.
|
|
7
|
+
#
|
|
8
|
+
# A version records the state *before* its own change, so the versions in
|
|
9
|
+
# the window supply the state at its start and after every change but the
|
|
10
|
+
# last. The state after the last change lives in the first version after
|
|
11
|
+
# the window, or in the live row when nothing follows; a destroy leaves none.
|
|
12
|
+
class HistoricalFilter
|
|
13
|
+
DEFAULT_VERSION_LIMIT = 10_000
|
|
14
|
+
CANDIDATE_BATCH = 250
|
|
15
|
+
|
|
16
|
+
#: (untyped, predicate: untyped, time_range: TimeRange?, limit: Integer, max_versions: Integer) -> void
|
|
17
|
+
def initialize(model, predicate:, time_range:, limit:, max_versions:)
|
|
18
|
+
@model = model
|
|
19
|
+
@predicate = validate_predicate(predicate)
|
|
20
|
+
@time_range = time_range
|
|
21
|
+
@limit = limit
|
|
22
|
+
@max_versions = validate_budget(max_versions)
|
|
23
|
+
@loaded = 0
|
|
24
|
+
@state_loader = PreparedVersionStateLoader.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
#: (Array[String]) -> Array[String]
|
|
28
|
+
def call(candidates)
|
|
29
|
+
matched = [] #: Array[String]
|
|
30
|
+
candidates.each_slice(CANDIDATE_BATCH) do |slice|
|
|
31
|
+
versions = candidate_versions(slice)
|
|
32
|
+
closing = closing_states(slice, versions)
|
|
33
|
+
slice.each do |id|
|
|
34
|
+
next unless held?(versions.fetch(id, []), closing[id])
|
|
35
|
+
|
|
36
|
+
matched << id
|
|
37
|
+
reject_root_limit! if matched.length > @limit
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
matched
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# @rbs @model: untyped
|
|
46
|
+
# @rbs @predicate: untyped
|
|
47
|
+
# @rbs @time_range: TimeRange?
|
|
48
|
+
# @rbs @limit: Integer
|
|
49
|
+
# @rbs @max_versions: Integer
|
|
50
|
+
# @rbs @loaded: Integer
|
|
51
|
+
# @rbs @state_loader: PreparedVersionStateLoader
|
|
52
|
+
|
|
53
|
+
#: (untyped) -> untyped
|
|
54
|
+
def validate_predicate(predicate)
|
|
55
|
+
return predicate if predicate.respond_to?(:call)
|
|
56
|
+
unless predicate.is_a?(Hash)
|
|
57
|
+
raise ConfigurationError, 'historical_filter: must respond to call or be an attribute hash'
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
attributes = predicate.transform_keys(&:to_s)
|
|
61
|
+
unknown = attributes.keys - @model.attribute_names
|
|
62
|
+
if unknown.any?
|
|
63
|
+
raise ConfigurationError,
|
|
64
|
+
"unknown historical attributes: #{unknown.join(', ')}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Support.immutable_copy(attributes)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
#: (untyped) -> Integer
|
|
71
|
+
def validate_budget(value)
|
|
72
|
+
return value if value.instance_of?(Integer) && value.positive?
|
|
73
|
+
|
|
74
|
+
raise ConfigurationError, 'max_filter_versions: must be a positive Integer'
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (Array[String]) -> Hash[String, Array[untyped]]
|
|
78
|
+
def candidate_versions(ids)
|
|
79
|
+
relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
|
|
80
|
+
item_id: ids)
|
|
81
|
+
range = @time_range
|
|
82
|
+
relation = range.scope(relation) if range
|
|
83
|
+
# One past the remaining budget is enough to know it was exceeded.
|
|
84
|
+
versions = VersionQuery.chronological(relation).limit(@max_versions - @loaded + 1)
|
|
85
|
+
account_versions!(versions.length)
|
|
86
|
+
versions.group_by { |version| version.item_id.to_s }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: (Array[untyped], untyped) -> boolish
|
|
90
|
+
def held?(versions, closing)
|
|
91
|
+
return true if versions.any? { |version| matches?(version) }
|
|
92
|
+
return false unless closing
|
|
93
|
+
|
|
94
|
+
Endpoint.version?(closing) ? matches?(closing) : record_matches?(closing)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# What each candidate held after its last change in the window.
|
|
98
|
+
#: (Array[String], Hash[String, Array[untyped]]) -> Hash[String, untyped]
|
|
99
|
+
def closing_states(ids, versions)
|
|
100
|
+
open = ids.reject { |id| versions.fetch(id, []).last&.event == 'destroy' }
|
|
101
|
+
trailing = trailing_versions(open)
|
|
102
|
+
trailing.merge(live_records(open - trailing.keys))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: (Array[String]) -> Hash[String, untyped]
|
|
106
|
+
def trailing_versions(ids)
|
|
107
|
+
range = @time_range
|
|
108
|
+
return {} unless range
|
|
109
|
+
|
|
110
|
+
relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
|
|
111
|
+
item_id: ids)
|
|
112
|
+
trailing = range.first_trailing_versions(relation)
|
|
113
|
+
account_versions!(trailing.length)
|
|
114
|
+
trailing
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
#: (Array[String]) -> Hash[String, untyped]
|
|
118
|
+
def live_records(ids)
|
|
119
|
+
@model.unscoped.where(@model.primary_key => ids).to_h { |record| [record.id.to_s, record] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
#: (untyped) -> boolish
|
|
123
|
+
def record_matches?(record)
|
|
124
|
+
return @predicate.call(record) unless @predicate.instance_of?(Hash)
|
|
125
|
+
|
|
126
|
+
@predicate.all? { |name, expected| record.attributes.fetch(name) == expected }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
#: (Integer) -> void
|
|
130
|
+
def account_versions!(count)
|
|
131
|
+
@loaded += count
|
|
132
|
+
return unless @loaded > @max_versions
|
|
133
|
+
|
|
134
|
+
raise FilterLimitExceededError,
|
|
135
|
+
"historical_filter: exceeds max_filter_versions: #{@max_versions}; " \
|
|
136
|
+
'narrow the window or explicitly increase the version budget'
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
#: (untyped) -> boolish
|
|
140
|
+
def matches?(version)
|
|
141
|
+
if @predicate.instance_of?(Hash)
|
|
142
|
+
return false unless version.object
|
|
143
|
+
|
|
144
|
+
return attributes_match?(version)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
state = version.reify(dup: true)
|
|
148
|
+
state && @predicate.call(state)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
#: (untyped) -> bool
|
|
152
|
+
def attributes_match?(version)
|
|
153
|
+
state = @state_loader.call(version)
|
|
154
|
+
unless state
|
|
155
|
+
raise ConfigurationError,
|
|
156
|
+
'historical_filter: cannot read recorded attributes directly; use a bounded callable'
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@predicate.all? do |name, expected|
|
|
160
|
+
unless state.attributes.key?(name)
|
|
161
|
+
raise ConfigurationError,
|
|
162
|
+
"historical_filter: #{name} was not recorded in version #{version.id}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
state.attributes.fetch(name) == expected
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
#: () -> void
|
|
170
|
+
def reject_root_limit!
|
|
171
|
+
raise BatchLimitExceededError,
|
|
172
|
+
"historical_filter: matched more than #{@limit} roots; narrow the window " \
|
|
173
|
+
'or the filter, or raise limit: to the population you intend to analyze'
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|