paper_trail_diff 0.11.0 → 0.13.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 +144 -0
- data/QUICKSTART.md +149 -79
- data/README.md +179 -1367
- data/docs/architecture.md +88 -0
- data/docs/performance.md +309 -0
- data/docs/reference.md +977 -0
- data/docs/testing.md +146 -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 +9 -37
- data/lib/paper_trail_diff/branch_snapshot_refresher.rb +7 -8
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +93 -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 +181 -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 +59 -24
- 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 +25 -2
- 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 +9 -0
- data/lib/paper_trail_diff/snapshot_normalizer.rb +41 -2
- 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 +22 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -15
- data/lib/paper_trail_diff/timeline_builder.rb +5 -4
- data/lib/paper_trail_diff/timeline_range.rb +25 -43
- 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/value_objects.rb +66 -0
- 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 +33 -0
- data/lib/paper_trail_diff/version_range.rb +10 -38
- data/lib/paper_trail_diff.rb +81 -43
- 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/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 +31 -18
- 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 +15 -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 +13 -0
- 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 +10 -14
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +2 -0
- data/sig/generated/paper_trail_diff/value_objects.rbs +45 -0
- data/sig/generated/paper_trail_diff/version_boundary.rbs +23 -0
- data/sig/generated/paper_trail_diff/version_query.rbs +15 -0
- data/sig/generated/paper_trail_diff/version_range.rbs +5 -14
- data/sig/generated/paper_trail_diff.rbs +37 -27
- 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)
|
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
versions = @model.paper_trail.version_class.where(
|
|
68
|
+
item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
|
|
69
|
+
).reorder(created_at: :asc, id: :asc)
|
|
70
|
+
versions.group_by { |version| version.item_id.to_s }.values.map do |history|
|
|
71
|
+
Support.chronological_sort(history).last
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
#: (Array[untyped]) -> Hash[Symbol, untyped]
|
|
76
|
+
def preparation_range(versions)
|
|
77
|
+
{ start_at: @time_range&.begin_time,
|
|
78
|
+
end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
82
|
+
def prepare(records, selected)
|
|
83
|
+
records.group_by(&:class).each_value do |group|
|
|
84
|
+
versions = group.flat_map do |record|
|
|
85
|
+
selected.fetch(Endpoint.identity(record)).reconstruction_versions
|
|
86
|
+
end
|
|
87
|
+
next if versions.empty?
|
|
88
|
+
|
|
89
|
+
@history_preparer.call(group, versions, **preparation_range(versions))
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
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,181 @@
|
|
|
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.is_a?(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
|
+
versions = relation.reorder(created_at: :asc, id: :asc)
|
|
84
|
+
.limit(@max_versions - @loaded + 1).to_a
|
|
85
|
+
account_versions!(versions.length)
|
|
86
|
+
versions.group_by { |version| version.item_id.to_s }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
#: (Array[untyped], untyped) -> bool
|
|
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.to_s == 'destroy' }
|
|
101
|
+
return {} if open.empty?
|
|
102
|
+
|
|
103
|
+
trailing = trailing_versions(open)
|
|
104
|
+
trailing.merge(live_records(open - trailing.keys))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
#: (Array[String]) -> Hash[String, untyped]
|
|
108
|
+
def trailing_versions(ids)
|
|
109
|
+
range = @time_range
|
|
110
|
+
return {} unless range
|
|
111
|
+
|
|
112
|
+
relation = @model.paper_trail.version_class.where(item_type: @model.base_class.name,
|
|
113
|
+
item_id: ids)
|
|
114
|
+
trailing = range.first_trailing_versions(relation)
|
|
115
|
+
account_versions!(trailing.length)
|
|
116
|
+
trailing
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (Array[String]) -> Hash[String, untyped]
|
|
120
|
+
def live_records(ids)
|
|
121
|
+
return {} if ids.empty?
|
|
122
|
+
|
|
123
|
+
@model.base_class.unscoped.where(@model.primary_key => ids).to_a
|
|
124
|
+
.to_h { |record| [record.id.to_s, record] }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
#: (untyped) -> bool
|
|
128
|
+
def record_matches?(record)
|
|
129
|
+
return !!@predicate.call(record) unless @predicate.is_a?(Hash)
|
|
130
|
+
|
|
131
|
+
@predicate.all? { |name, expected| record.attributes.fetch(name) == expected }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
#: (Integer) -> void
|
|
135
|
+
def account_versions!(count)
|
|
136
|
+
@loaded += count
|
|
137
|
+
return unless @loaded > @max_versions
|
|
138
|
+
|
|
139
|
+
raise FilterLimitExceededError,
|
|
140
|
+
"historical_filter: exceeds max_filter_versions: #{@max_versions}; " \
|
|
141
|
+
'narrow the window or explicitly increase the version budget'
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
#: (untyped) -> bool
|
|
145
|
+
def matches?(version)
|
|
146
|
+
if @predicate.is_a?(Hash)
|
|
147
|
+
return false unless version.object
|
|
148
|
+
|
|
149
|
+
return attributes_match?(version)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
state = version.reify(dup: true)
|
|
153
|
+
!!(state && @predicate.call(state))
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
#: (untyped) -> bool
|
|
157
|
+
def attributes_match?(version)
|
|
158
|
+
state = @state_loader.call(version)
|
|
159
|
+
unless state
|
|
160
|
+
raise ConfigurationError,
|
|
161
|
+
'historical_filter: cannot read recorded attributes directly; use a bounded callable'
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
@predicate.all? do |name, expected|
|
|
165
|
+
unless state.attributes.key?(name)
|
|
166
|
+
raise ConfigurationError,
|
|
167
|
+
"historical_filter: #{name} was not recorded in version #{version.id}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
state.model_class.type_for_attribute(name).cast(state.attributes.fetch(name)) == expected
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
#: () -> void
|
|
175
|
+
def reject_root_limit!
|
|
176
|
+
raise BatchLimitExceededError,
|
|
177
|
+
"historical_filter: matched more than #{@limit} roots; narrow the window " \
|
|
178
|
+
'or the filter, or raise limit: to the population you intend to analyze'
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -10,6 +10,7 @@ module PaperTrailDiff
|
|
|
10
10
|
@traversal = traversal
|
|
11
11
|
@normalizer = normalizer
|
|
12
12
|
@preparer = preparer
|
|
13
|
+
@prepared_snapshotter = PreparedSnapshotBuilder.new(traversal)
|
|
13
14
|
@live_graph_collector = LiveGraphCollector.new(tree: tree, traversal: traversal)
|
|
14
15
|
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
15
16
|
@prepared_history = nil #: PreparedHistory?
|
|
@@ -24,8 +25,13 @@ module PaperTrailDiff
|
|
|
24
25
|
end_at: root_versions.last.created_at
|
|
25
26
|
)
|
|
26
27
|
return if @tree.empty?
|
|
28
|
+
|
|
27
29
|
# A batched preparation already covers these roots.
|
|
28
|
-
|
|
30
|
+
batched = root_versions.find { |version| @prepared_histories.key?(context_key(version)) }
|
|
31
|
+
if batched
|
|
32
|
+
@prepared_history = @prepared_histories.fetch(context_key(batched))
|
|
33
|
+
return
|
|
34
|
+
end
|
|
29
35
|
|
|
30
36
|
@prepared_history = PreparedHistoryLoader.new(
|
|
31
37
|
record,
|
|
@@ -37,15 +43,24 @@ module PaperTrailDiff
|
|
|
37
43
|
).call
|
|
38
44
|
end
|
|
39
45
|
|
|
40
|
-
#: (Array[untyped], Array[untyped]) -> void
|
|
41
|
-
def prepare_batch(records, root_versions)
|
|
46
|
+
#: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
47
|
+
def prepare_batch(records, root_versions, start_at: nil, end_at: nil)
|
|
42
48
|
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
43
49
|
|
|
44
50
|
Instrumentation.instrument('prepare_history', batch_payload(records, root_versions)) do
|
|
45
|
-
register_batch(records, root_versions)
|
|
51
|
+
register_batch(records, root_versions, start_at: start_at, end_at: end_at)
|
|
46
52
|
end
|
|
47
53
|
end
|
|
48
54
|
|
|
55
|
+
# Missing roots have no live graph to collect. Reading their associations
|
|
56
|
+
# here would issue a query per root and seed nonexistent rows as live state.
|
|
57
|
+
#: (Array[untyped], Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
58
|
+
def prepare_deleted_batch(records, root_versions, start_at: nil, end_at: nil)
|
|
59
|
+
return if @tree.empty? || records.empty? || root_versions.empty?
|
|
60
|
+
|
|
61
|
+
register_batch(records, root_versions, live_records: [], start_at: start_at, end_at: end_at)
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
#: (untyped, untyped) -> RecordSnapshot?
|
|
50
65
|
def call(root_endpoint, context_endpoint)
|
|
51
66
|
key = snapshot_key(root_endpoint, context_endpoint)
|
|
@@ -100,6 +115,11 @@ module PaperTrailDiff
|
|
|
100
115
|
)
|
|
101
116
|
end
|
|
102
117
|
|
|
118
|
+
#: () -> PreparedActivityIndex?
|
|
119
|
+
def activity_index
|
|
120
|
+
@prepared_history&.activity_index
|
|
121
|
+
end
|
|
122
|
+
|
|
103
123
|
#: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
104
124
|
def record_transition(version)
|
|
105
125
|
history = @prepared_history
|
|
@@ -108,12 +128,19 @@ module PaperTrailDiff
|
|
|
108
128
|
history.record_transition(Endpoint.model_class(version), version.item_id, version)
|
|
109
129
|
end
|
|
110
130
|
|
|
131
|
+
#: (untyped, ?direct: bool) -> [bool, untyped]
|
|
132
|
+
def record_after(version, direct: false)
|
|
133
|
+
@prepared_history&.record_after(Endpoint.model_class(version), version.item_id, version,
|
|
134
|
+
direct: direct) || [false, nil]
|
|
135
|
+
end
|
|
136
|
+
|
|
111
137
|
private
|
|
112
138
|
|
|
113
139
|
# @rbs @tree: AssociationTree
|
|
114
140
|
# @rbs @traversal: AssociationTraversal
|
|
115
141
|
# @rbs @normalizer: SnapshotNormalizer
|
|
116
142
|
# @rbs @preparer: untyped
|
|
143
|
+
# @rbs @prepared_snapshotter: PreparedSnapshotBuilder
|
|
117
144
|
# @rbs @live_graph_collector: LiveGraphCollector
|
|
118
145
|
# @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
119
146
|
# @rbs @prepared_history: PreparedHistory?
|
|
@@ -126,25 +153,22 @@ module PaperTrailDiff
|
|
|
126
153
|
|
|
127
154
|
#: (Array[untyped], Array[untyped]) -> Hash[Symbol, untyped]
|
|
128
155
|
def batch_payload(records, root_versions)
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
version_count: root_versions.length,
|
|
132
|
-
model_type: records.first.class.base_class.name.to_s,
|
|
133
|
-
batched: true
|
|
134
|
-
}
|
|
156
|
+
{ root_count: records.length, version_count: root_versions.length,
|
|
157
|
+
model_type: records.first.class.base_class.name.to_s, batched: true }
|
|
135
158
|
end
|
|
136
159
|
|
|
137
|
-
#: (Array[untyped], Array[untyped]) -> void
|
|
138
|
-
def register_batch(records, root_versions)
|
|
160
|
+
#: (Array[untyped], Array[untyped], ?live_records: Array[untyped], ?start_at: untyped, ?end_at: untyped) -> void
|
|
161
|
+
def register_batch(records, root_versions, live_records: @live_graph_collector.call(records),
|
|
162
|
+
start_at: nil, end_at: nil)
|
|
139
163
|
history = PreparedHistoryLoader.new(
|
|
140
164
|
records.first,
|
|
141
165
|
root_ids: records.map(&:id),
|
|
142
166
|
root_versions: root_versions,
|
|
143
|
-
start_at: root_versions.map(&:created_at).min,
|
|
144
|
-
end_at: root_versions.map(&:created_at).max,
|
|
167
|
+
start_at: start_at || root_versions.map(&:created_at).min,
|
|
168
|
+
end_at: end_at || root_versions.map(&:created_at).max,
|
|
145
169
|
tree: @tree,
|
|
146
170
|
traversal: @traversal,
|
|
147
|
-
live_records:
|
|
171
|
+
live_records: live_records
|
|
148
172
|
).call
|
|
149
173
|
root_versions.each { |version| @prepared_histories[context_key(version)] = history }
|
|
150
174
|
end
|
|
@@ -154,27 +178,24 @@ module PaperTrailDiff
|
|
|
154
178
|
model_class = Endpoint.model_class(root_version)
|
|
155
179
|
@preparer.call(model_class, historical: true)
|
|
156
180
|
@traversal.ensure_habtm_history!(model_class, root_version)
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
tree: tree,
|
|
162
|
-
normalizer: normalizer,
|
|
163
|
-
habtm_version: root_version
|
|
181
|
+
handled, snapshot = @prepared_snapshotter.call(
|
|
182
|
+
root_version, context_version, tree: tree, normalizer: normalizer,
|
|
183
|
+
history: @prepared_histories[context_key(context_version)] ||
|
|
184
|
+
@prepared_history
|
|
164
185
|
)
|
|
186
|
+
return snapshot if handled
|
|
187
|
+
|
|
188
|
+
record = root_version.reify(dup: true)
|
|
189
|
+
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
190
|
+
habtm_version: root_version)
|
|
165
191
|
end
|
|
166
192
|
|
|
167
193
|
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer) -> RecordSnapshot?
|
|
168
194
|
def snapshot_from_live_root(root_record, context_version, tree:, normalizer:)
|
|
169
195
|
record = Endpoint.reload_record(root_record)
|
|
170
196
|
@preparer.call(record.class, historical: true)
|
|
171
|
-
normalize(
|
|
172
|
-
|
|
173
|
-
context_version,
|
|
174
|
-
tree: tree,
|
|
175
|
-
normalizer: normalizer,
|
|
176
|
-
habtm_version: context_version
|
|
177
|
-
)
|
|
197
|
+
normalize(record, context_version, tree: tree, normalizer: normalizer,
|
|
198
|
+
habtm_version: context_version)
|
|
178
199
|
end
|
|
179
200
|
|
|
180
201
|
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, habtm_version: untyped) -> RecordSnapshot?
|
|
@@ -188,12 +209,7 @@ module PaperTrailDiff
|
|
|
188
209
|
|
|
189
210
|
#: (untyped, untyped) -> Array[untyped]
|
|
190
211
|
def snapshot_key(root_endpoint, context_endpoint)
|
|
191
|
-
|
|
192
|
-
root_endpoint.class.name,
|
|
193
|
-
root_endpoint.id,
|
|
194
|
-
context_endpoint.class.name,
|
|
195
|
-
context_endpoint.id
|
|
196
|
-
]
|
|
212
|
+
context_key(root_endpoint) + context_key(context_endpoint)
|
|
197
213
|
end
|
|
198
214
|
end
|
|
199
215
|
end
|
|
@@ -9,13 +9,13 @@ module PaperTrailDiff
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
11
|
#: (String | Symbol, Hash[Symbol, untyped]) { () -> untyped } -> untyped
|
|
12
|
-
def instrument(event, payload, &)
|
|
12
|
+
def instrument(event, payload, &block)
|
|
13
13
|
active_support = Object.const_get(:ActiveSupport) #: untyped
|
|
14
14
|
notifications = active_support.const_get(:Notifications) #: untyped
|
|
15
15
|
notifications.instrument(
|
|
16
16
|
"#{event}.paper_trail_diff",
|
|
17
17
|
payload,
|
|
18
|
-
&
|
|
18
|
+
&block
|
|
19
19
|
)
|
|
20
20
|
end
|
|
21
21
|
|