paper_trail_diff 0.14.0 → 0.15.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 +343 -0
- data/QUICKSTART.md +21 -4
- data/README.md +58 -55
- data/docs/architecture.md +32 -11
- data/docs/performance.md +116 -391
- data/docs/reference.md +345 -106
- data/docs/testing.md +63 -7
- data/lib/paper_trail_diff/activity_boundary.rb +83 -26
- data/lib/paper_trail_diff/activity_child_candidate_loader.rb +2 -1
- data/lib/paper_trail_diff/activity_event.rb +41 -10
- data/lib/paper_trail_diff/activity_history.rb +34 -9
- data/lib/paper_trail_diff/activity_range.rb +18 -3
- data/lib/paper_trail_diff/activity_snapshot_sequence.rb +76 -37
- data/lib/paper_trail_diff/activity_target_candidate_loader.rb +5 -5
- data/lib/paper_trail_diff/activity_timeline_builder.rb +266 -111
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +2 -1
- data/lib/paper_trail_diff/activity_tree_filter.rb +114 -0
- data/lib/paper_trail_diff/activity_version_collector.rb +7 -5
- data/lib/paper_trail_diff/activity_view.rb +69 -0
- data/lib/paper_trail_diff/analysis.rb +13 -1
- data/lib/paper_trail_diff/analysis_batch.rb +24 -30
- data/lib/paper_trail_diff/approximate_paths.rb +48 -0
- data/lib/paper_trail_diff/association_activity_edge.rb +6 -6
- data/lib/paper_trail_diff/association_discovery.rb +9 -1
- data/lib/paper_trail_diff/association_key.rb +56 -0
- data/lib/paper_trail_diff/association_traversal.rb +13 -4
- data/lib/paper_trail_diff/batch_boundary_resolver.rb +3 -4
- data/lib/paper_trail_diff/batch_preload.rb +53 -0
- data/lib/paper_trail_diff/batched_root_analyzer.rb +12 -14
- data/lib/paper_trail_diff/batched_root_versions.rb +38 -14
- data/lib/paper_trail_diff/change_row.rb +133 -0
- data/lib/paper_trail_diff/collection_comparator.rb +5 -32
- data/lib/paper_trail_diff/configuration.rb +35 -12
- data/lib/paper_trail_diff/deleted_analysis_batch.rb +18 -18
- data/lib/paper_trail_diff/deleted_root_filter.rb +79 -0
- data/lib/paper_trail_diff/deprecation.rb +61 -0
- data/lib/paper_trail_diff/diagnostics.rb +84 -99
- data/lib/paper_trail_diff/endpoint.rb +26 -3
- data/lib/paper_trail_diff/errors.rb +3 -1
- data/lib/paper_trail_diff/fallback_paths.rb +38 -0
- data/lib/paper_trail_diff/historical_association_reifier.rb +2 -4
- data/lib/paper_trail_diff/historical_filter.rb +19 -10
- data/lib/paper_trail_diff/historical_snapshot_store.rb +57 -67
- data/lib/paper_trail_diff/live_endpoint_batch_loader.rb +1 -35
- data/lib/paper_trail_diff/nested_comparator.rb +19 -18
- data/lib/paper_trail_diff/paper_trail_adapter.rb +122 -127
- data/lib/paper_trail_diff/preloaded_endpoint_batch_loader.rb +13 -1
- data/lib/paper_trail_diff/prepared_activity_index.rb +1 -1
- data/lib/paper_trail_diff/prepared_edge_loader.rb +2 -1
- data/lib/paper_trail_diff/prepared_history.rb +9 -1
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -0
- data/lib/paper_trail_diff/prepared_record_index.rb +38 -7
- data/lib/paper_trail_diff/root_version_selection.rb +4 -2
- data/lib/paper_trail_diff/scope_condition.rb +76 -21
- data/lib/paper_trail_diff/scope_ranking.rb +12 -2
- data/lib/paper_trail_diff/scoped_analysis.rb +11 -0
- data/lib/paper_trail_diff/scoped_analysis_builder.rb +16 -8
- data/lib/paper_trail_diff/scoped_root_selection.rb +18 -12
- data/lib/paper_trail_diff/snapshot.rb +16 -1
- data/lib/paper_trail_diff/support.rb +29 -2
- data/lib/paper_trail_diff/time_range.rb +32 -10
- data/lib/paper_trail_diff/timeline_builder.rb +7 -7
- data/lib/paper_trail_diff/timeline_range.rb +9 -1
- data/lib/paper_trail_diff/timeline_snapshot_provider.rb +24 -17
- data/lib/paper_trail_diff/transaction_cut.rb +181 -0
- data/lib/paper_trail_diff/traversal_preparer.rb +134 -29
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_boundary.rb +37 -8
- data/lib/paper_trail_diff/version_query.rb +34 -10
- data/lib/paper_trail_diff/version_scope_filter.rb +9 -10
- data/lib/paper_trail_diff/version_sequence_diagnostics.rb +31 -16
- data/lib/paper_trail_diff/version_table.rb +60 -0
- data/lib/paper_trail_diff.rb +98 -56
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +54 -19
- data/sig/generated/paper_trail_diff/activity_event.rbs +25 -4
- data/sig/generated/paper_trail_diff/activity_history.rbs +21 -6
- data/sig/generated/paper_trail_diff/activity_range.rbs +9 -2
- data/sig/generated/paper_trail_diff/activity_snapshot_sequence.rbs +48 -9
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +134 -30
- data/sig/generated/paper_trail_diff/activity_tree_filter.rbs +73 -0
- data/sig/generated/paper_trail_diff/activity_version_collector.rbs +2 -0
- data/sig/generated/paper_trail_diff/activity_view.rbs +42 -0
- data/sig/generated/paper_trail_diff/analysis.rbs +8 -1
- data/sig/generated/paper_trail_diff/analysis_batch.rbs +8 -15
- data/sig/generated/paper_trail_diff/approximate_paths.rbs +23 -0
- data/sig/generated/paper_trail_diff/association_discovery.rbs +5 -0
- data/sig/generated/paper_trail_diff/association_key.rbs +29 -0
- data/sig/generated/paper_trail_diff/association_traversal.rbs +4 -0
- data/sig/generated/paper_trail_diff/batch_preload.rbs +29 -0
- data/sig/generated/paper_trail_diff/batched_root_analyzer.rbs +9 -10
- data/sig/generated/paper_trail_diff/batched_root_versions.rbs +14 -4
- data/sig/generated/paper_trail_diff/change_row.rbs +86 -0
- data/sig/generated/paper_trail_diff/collection_comparator.rbs +2 -16
- data/sig/generated/paper_trail_diff/configuration.rbs +19 -3
- data/sig/generated/paper_trail_diff/deleted_analysis_batch.rbs +8 -8
- data/sig/generated/paper_trail_diff/deleted_root_filter.rbs +48 -0
- data/sig/generated/paper_trail_diff/deprecation.rbs +35 -0
- data/sig/generated/paper_trail_diff/diagnostics.rbs +37 -30
- data/sig/generated/paper_trail_diff/endpoint.rbs +11 -2
- data/sig/generated/paper_trail_diff/errors.rbs +3 -1
- data/sig/generated/paper_trail_diff/fallback_paths.rbs +22 -0
- data/sig/generated/paper_trail_diff/historical_filter.rbs +12 -2
- data/sig/generated/paper_trail_diff/historical_snapshot_store.rbs +33 -28
- data/sig/generated/paper_trail_diff/live_endpoint_batch_loader.rbs +0 -15
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +10 -7
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +54 -52
- data/sig/generated/paper_trail_diff/preloaded_endpoint_batch_loader.rbs +9 -1
- data/sig/generated/paper_trail_diff/prepared_history.rbs +5 -0
- data/sig/generated/paper_trail_diff/prepared_record_index.rbs +19 -2
- data/sig/generated/paper_trail_diff/root_version_selection.rbs +2 -0
- data/sig/generated/paper_trail_diff/scope_condition.rbs +43 -9
- data/sig/generated/paper_trail_diff/scope_ranking.rbs +8 -0
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +6 -0
- data/sig/generated/paper_trail_diff/scoped_analysis_builder.rbs +6 -1
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +8 -4
- data/sig/generated/paper_trail_diff/snapshot.rbs +8 -0
- data/sig/generated/paper_trail_diff/support.rbs +15 -0
- data/sig/generated/paper_trail_diff/time_range.rbs +20 -6
- data/sig/generated/paper_trail_diff/timeline_builder.rbs +2 -2
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -0
- data/sig/generated/paper_trail_diff/timeline_snapshot_provider.rbs +17 -7
- data/sig/generated/paper_trail_diff/transaction_cut.rbs +94 -0
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +66 -8
- data/sig/generated/paper_trail_diff/version_boundary.rbs +20 -3
- data/sig/generated/paper_trail_diff/version_query.rbs +18 -2
- data/sig/generated/paper_trail_diff/version_scope_filter.rbs +4 -5
- data/sig/generated/paper_trail_diff/version_sequence_diagnostics.rbs +16 -9
- data/sig/generated/paper_trail_diff/version_table.rbs +30 -0
- data/sig/generated/paper_trail_diff.rbs +29 -26
- metadata +46 -10
- data/lib/paper_trail_diff/activity_grouping.rb +0 -38
- data/lib/paper_trail_diff/batch_safety.rb +0 -63
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +0 -223
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +0 -22
- data/sig/generated/paper_trail_diff/batch_safety.rbs +0 -30
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +0 -92
|
@@ -5,22 +5,22 @@ module PaperTrailDiff
|
|
|
5
5
|
# Builds one root's Analysis inside a batch, from versions the batch already
|
|
6
6
|
# selected and history it already prepared.
|
|
7
7
|
class BatchedRootAnalyzer
|
|
8
|
-
#: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool,
|
|
9
|
-
def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, within: nil
|
|
8
|
+
#: (tree: AssociationTree, timeline_snapshotter: untyped, activity_snapshotter: untyped, preparer: untyped, activity: bool, view: ActivityView, ?within: untyped) -> void
|
|
9
|
+
def initialize(tree:, timeline_snapshotter:, activity_snapshotter:, preparer:, activity:, view:, within: nil) # rubocop:disable Metrics/ParameterLists, Layout/LineLength
|
|
10
10
|
@tree = tree
|
|
11
11
|
@timeline_snapshotter = timeline_snapshotter
|
|
12
12
|
@activity_snapshotter = activity_snapshotter
|
|
13
13
|
@preparer = preparer
|
|
14
14
|
@activity = activity
|
|
15
|
+
@view = view
|
|
15
16
|
@within = within
|
|
16
|
-
@snapshots = snapshots
|
|
17
|
-
@group = group
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
# `window` is the root's window as the batch moved it to transaction edges.
|
|
20
|
+
#: (untyped, RootVersionPlan, ?TimeRange?) -> Analysis
|
|
21
|
+
def call(record, plan, window = nil)
|
|
22
22
|
@preparer.call(record.class, historical: true)
|
|
23
|
-
return activity_analysis(record, plan) if @activity
|
|
23
|
+
return activity_analysis(record, plan, window) if @activity
|
|
24
24
|
|
|
25
25
|
TimelineBuilder.new(
|
|
26
26
|
record,
|
|
@@ -40,19 +40,17 @@ module PaperTrailDiff
|
|
|
40
40
|
# @rbs @preparer: untyped
|
|
41
41
|
# @rbs @activity: bool
|
|
42
42
|
# @rbs @within: untyped
|
|
43
|
-
# @rbs @
|
|
44
|
-
# @rbs @group: Symbol?
|
|
43
|
+
# @rbs @view: ActivityView
|
|
45
44
|
|
|
46
|
-
#: (untyped, RootVersionPlan) -> Analysis
|
|
47
|
-
def activity_analysis(record, plan)
|
|
45
|
+
#: (untyped, RootVersionPlan, TimeRange?) -> Analysis
|
|
46
|
+
def activity_analysis(record, plan, window)
|
|
48
47
|
range = TimelineRange.new(
|
|
49
48
|
record, from: (@within ? nil : plan.versions.first),
|
|
50
49
|
to: (@within ? nil : plan.closing_record || plan.versions.last),
|
|
51
|
-
within: @within, plan: plan
|
|
50
|
+
within: (window || @within if @within), plan: plan
|
|
52
51
|
)
|
|
53
52
|
ActivityTimelineBuilder.new(
|
|
54
|
-
record, range: range, tree: @tree, snapshotter: @activity_snapshotter,
|
|
55
|
-
snapshots: @snapshots, group: @group
|
|
53
|
+
record, range: range, tree: @tree, snapshotter: @activity_snapshotter, view: @view
|
|
56
54
|
).analyze
|
|
57
55
|
end
|
|
58
56
|
end
|
|
@@ -6,9 +6,13 @@ module PaperTrailDiff
|
|
|
6
6
|
# queries. Only the range forms that mean the same thing for every root are
|
|
7
7
|
# supported: a shared wall-clock window, or each root's own whole history.
|
|
8
8
|
class BatchedRootVersions
|
|
9
|
-
#: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool) -> void
|
|
10
|
-
def initialize(
|
|
9
|
+
#: (Array[untyped], time_range: TimeRange?, version_scope: untyped, live_endpoints: Hash[Array[String], untyped]?, context_required: bool, windows: Hash[Array[String], TimeRange]) -> void
|
|
10
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
11
|
+
records, time_range:, version_scope:, live_endpoints:, context_required:, windows:
|
|
12
|
+
)
|
|
11
13
|
@records = records
|
|
14
|
+
# Roots whose window moved to a transaction's start, by identity.
|
|
15
|
+
@windows = windows
|
|
12
16
|
# An activity view needs a root to reconstruct from even in a window with
|
|
13
17
|
# no root version, exactly as a single-record activity timeline does.
|
|
14
18
|
@context_required = context_required
|
|
@@ -34,32 +38,55 @@ module PaperTrailDiff
|
|
|
34
38
|
# @rbs @version_scope: untyped
|
|
35
39
|
# @rbs @live_endpoints: Hash[Array[String], untyped]?
|
|
36
40
|
# @rbs @context_required: bool
|
|
41
|
+
# @rbs @windows: Hash[Array[String], TimeRange]
|
|
37
42
|
|
|
38
43
|
#: (untyped, Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
39
44
|
def select_model(model_class, records, selected)
|
|
40
45
|
in_range, chosen, trailing = model_versions(model_class, records)
|
|
41
46
|
records.each do |record|
|
|
42
47
|
key = Endpoint.identity(record)
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
live_endpoint(key, record)
|
|
46
|
-
)
|
|
48
|
+
own = own_window(key, record.id.to_s, in_range, trailing)
|
|
49
|
+
selected[key] = versions_for(*own, chosen, live_endpoint(key, record))
|
|
47
50
|
end
|
|
48
51
|
end
|
|
49
52
|
|
|
53
|
+
# Moved windows only start and end earlier, so the versions read for the
|
|
54
|
+
# widest of them hold each root's own, and the first left past a moved end.
|
|
55
|
+
#: (Array[String], String, Hash[String, Array[untyped]], Hash[String, untyped]) -> [Array[untyped], untyped]
|
|
56
|
+
def own_window(key, id, in_range, trailing)
|
|
57
|
+
loaded = in_range.fetch(id, [])
|
|
58
|
+
return [loaded, trailing[id]] if @windows.empty?
|
|
59
|
+
|
|
60
|
+
# A batch moves windows only when it has one, so this is never nil.
|
|
61
|
+
window = @windows.fetch(key) { @time_range } #: untyped
|
|
62
|
+
own, rest = loaded.partition { |version| window.include?(version.created_at) }
|
|
63
|
+
[own, rest.find { |version| version.created_at >= window.end_time } || trailing[id]]
|
|
64
|
+
end
|
|
65
|
+
|
|
50
66
|
#: (untyped, Array[untyped]) -> [Hash[String, Array[untyped]], Set[untyped]?, Hash[String, untyped]]
|
|
51
67
|
def model_versions(model_class, records)
|
|
52
68
|
range = @time_range
|
|
53
69
|
empty = {} #: Hash[String, untyped]
|
|
70
|
+
widest = widest_range(records)
|
|
54
71
|
[
|
|
55
|
-
grouped_versions(model_class, records,
|
|
56
|
-
chosen_version_ids(model_class, records,
|
|
72
|
+
grouped_versions(model_class, records, widest),
|
|
73
|
+
chosen_version_ids(model_class, records, widest),
|
|
57
74
|
range ? trailing_versions(model_class, records, range) : empty
|
|
58
75
|
]
|
|
59
76
|
end
|
|
60
77
|
|
|
61
|
-
#: (Array[untyped]
|
|
62
|
-
def
|
|
78
|
+
#: (Array[untyped]) -> TimeRange?
|
|
79
|
+
def widest_range(records)
|
|
80
|
+
range = @time_range
|
|
81
|
+
return unless range
|
|
82
|
+
|
|
83
|
+
moved = records.filter_map { |record| @windows[Endpoint.identity(record)]&.begin_time }
|
|
84
|
+
start = [range.begin_time, *moved].min
|
|
85
|
+
TimeRange.new(range.exclude_end? ? (start...range.end_time) : (start..range.end_time))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
#: (Array[untyped], untyped, Set[untyped]?, untyped) -> RootVersionPlan
|
|
89
|
+
def versions_for(in_range, after_range, chosen, live_endpoint)
|
|
63
90
|
RootVersionSelection.new(
|
|
64
91
|
in_range: in_range,
|
|
65
92
|
selected: chosen ? in_range.select { |version| chosen.include?(version.id) } : in_range,
|
|
@@ -109,10 +136,7 @@ module PaperTrailDiff
|
|
|
109
136
|
#: (untyped, Array[untyped]) -> untyped
|
|
110
137
|
def base_scope(model_class, records)
|
|
111
138
|
# Active Record reads each record's id.
|
|
112
|
-
|
|
113
|
-
item_type: model_class.base_class.name,
|
|
114
|
-
item_id: records
|
|
115
|
-
)
|
|
139
|
+
VersionQuery.for_items(model_class, records)
|
|
116
140
|
end
|
|
117
141
|
end
|
|
118
142
|
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# One change from an activity step, flat enough for a history table or an
|
|
6
|
+
# export: a field that changed, or a record that appeared or disappeared,
|
|
7
|
+
# with the event the step attributes it to.
|
|
8
|
+
class ChangeRow
|
|
9
|
+
attr_reader :change #: Symbol
|
|
10
|
+
attr_reader :association_path #: Array[String]
|
|
11
|
+
attr_reader :association_kind #: Symbol?
|
|
12
|
+
# The record the row is about, or nil for the root itself.
|
|
13
|
+
attr_reader :record #: RecordReference?
|
|
14
|
+
# The field the row is about, or nil for a row about a whole record.
|
|
15
|
+
attr_reader :attribute #: String?
|
|
16
|
+
attr_reader :from #: untyped
|
|
17
|
+
attr_reader :to #: untyped
|
|
18
|
+
# The step's source boundary: for a transaction group, its first event.
|
|
19
|
+
attr_reader :source #: ActivityBoundary
|
|
20
|
+
|
|
21
|
+
#: (change: Symbol, association_path: Array[String], association_kind: Symbol?, record: RecordReference?, attribute: String?, from: untyped, to: untyped, source: ActivityBoundary) -> void
|
|
22
|
+
def initialize( # rubocop:disable Metrics/ParameterLists
|
|
23
|
+
change:, association_path:, association_kind:, record:, attribute:, from:, to:, source:
|
|
24
|
+
)
|
|
25
|
+
@change = change
|
|
26
|
+
@association_path = association_path
|
|
27
|
+
@association_kind = association_kind
|
|
28
|
+
@record = record
|
|
29
|
+
@attribute = attribute
|
|
30
|
+
@from = from
|
|
31
|
+
@to = to
|
|
32
|
+
@source = source
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The source's identifying fields sit beside the change rather than nested,
|
|
37
|
+
# so the hash is one row of a table as it stands.
|
|
38
|
+
#: () -> Hash[Symbol, untyped]
|
|
39
|
+
def to_h
|
|
40
|
+
{
|
|
41
|
+
change: change, association_path: association_path, association_kind: association_kind,
|
|
42
|
+
record: record&.to_h, attribute: attribute,
|
|
43
|
+
from: Support.serialize(from), to: Support.serialize(to)
|
|
44
|
+
}.merge(source_fields)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
#: () -> Hash[Symbol, untyped]
|
|
50
|
+
def source_fields
|
|
51
|
+
{ version_id: source.version_id, event: source.event, whodunnit: source.whodunnit,
|
|
52
|
+
recorded_at: source.recorded_at, transaction_id: source.transaction_id }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Flattens one step's diff into change rows, in the diff's walking order.
|
|
57
|
+
#
|
|
58
|
+
# A row is about one record. An edited field is `:changed`. A record that
|
|
59
|
+
# appeared or disappeared is one `:added` or `:removed` row, followed by a
|
|
60
|
+
# row for each of its fields and for each record it holds, so a table can
|
|
61
|
+
# list a new comment's body without a second walker. That covers a record
|
|
62
|
+
# joining or leaving a collection, a to-one target set, cleared or replaced
|
|
63
|
+
# (the old target removed, then the new one added), and the root's own
|
|
64
|
+
# creation or deletion.
|
|
65
|
+
#
|
|
66
|
+
# Two kinds of entry add nothing a row would show. One that only groups
|
|
67
|
+
# others (an edited record, an association, a relationship) is left out,
|
|
68
|
+
# because the rows under it name the same record. A blank field of an added
|
|
69
|
+
# or removed record is left out too, because the record's own row already
|
|
70
|
+
# says it appeared or disappeared.
|
|
71
|
+
class ChangeRows
|
|
72
|
+
RECORD_ENTRIES = %i[record_added record_removed record_included].freeze
|
|
73
|
+
private_constant :RECORD_ENTRIES
|
|
74
|
+
|
|
75
|
+
#: (Diff, source: ActivityBoundary) -> Array[ChangeRow]
|
|
76
|
+
def self.call(diff, source:)
|
|
77
|
+
new(source).call(diff)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
#: (ActivityBoundary) -> void
|
|
81
|
+
def initialize(source)
|
|
82
|
+
@source = source
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
#: (Diff) -> Array[ChangeRow]
|
|
86
|
+
def call(diff)
|
|
87
|
+
rows = [] #: Array[ChangeRow]
|
|
88
|
+
diff.each_entry do |entry|
|
|
89
|
+
row = row_for(entry)
|
|
90
|
+
rows << row if row
|
|
91
|
+
end
|
|
92
|
+
rows.freeze
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
# @rbs @source: ActivityBoundary
|
|
98
|
+
|
|
99
|
+
#: (TraversalEntry) -> ChangeRow?
|
|
100
|
+
def row_for(entry)
|
|
101
|
+
return changed_row(entry) if entry.kind == :attribute_changed
|
|
102
|
+
return presence_row(entry, nil) if RECORD_ENTRIES.include?(entry.kind)
|
|
103
|
+
return unless entry.kind == :attribute_included && !entry.value.nil?
|
|
104
|
+
|
|
105
|
+
presence_row(entry, entry.attribute)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
#: (TraversalEntry) -> ChangeRow
|
|
109
|
+
def changed_row(entry)
|
|
110
|
+
value = entry.value #: ValueChange
|
|
111
|
+
row(entry, :changed, entry.attribute, value.from, value.to)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# An added record's state is the after side and a removed one's the before
|
|
115
|
+
# side, so a field's value goes on the side that has it.
|
|
116
|
+
#: (TraversalEntry, String?) -> ChangeRow
|
|
117
|
+
def presence_row(entry, attribute)
|
|
118
|
+
value = attribute ? entry.value : nil
|
|
119
|
+
return row(entry, :added, attribute, nil, value) if entry.state == :after
|
|
120
|
+
|
|
121
|
+
row(entry, :removed, attribute, value, nil)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
#: (TraversalEntry, Symbol, String?, untyped, untyped) -> ChangeRow
|
|
125
|
+
def row(entry, change, attribute, from, to)
|
|
126
|
+
ChangeRow.new(
|
|
127
|
+
change: change, association_path: entry.association_path,
|
|
128
|
+
association_kind: entry.association_kind, record: entry.record,
|
|
129
|
+
attribute: attribute, from: from, to: to, source: @source
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Compares collection snapshots with a low-allocation path for stable membership.
|
|
6
|
+
# Snapshots hold their records in type and ID order, so results follow it
|
|
7
|
+
# without sorting here.
|
|
6
8
|
class CollectionComparator
|
|
7
9
|
#: (AssociationSnapshot, AssociationSnapshot, record_comparer: untyped) -> void
|
|
8
10
|
def initialize(from_association, to_association, record_comparer:)
|
|
@@ -50,9 +52,7 @@ module PaperTrailDiff
|
|
|
50
52
|
pairs = aligned_changed_pairs
|
|
51
53
|
return unless pairs
|
|
52
54
|
|
|
53
|
-
pairs.
|
|
54
|
-
@record_comparer.call(pair.fetch(0), pair.fetch(1))
|
|
55
|
-
end
|
|
55
|
+
pairs.filter_map { |pair| @record_comparer.call(pair.fetch(0), pair.fetch(1)) }
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
#: () -> Array[[RecordSnapshot, RecordSnapshot]]?
|
|
@@ -105,13 +105,12 @@ module PaperTrailDiff
|
|
|
105
105
|
|
|
106
106
|
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordSnapshot]
|
|
107
107
|
def records_missing_from(records, other_records)
|
|
108
|
-
|
|
109
|
-
identities.map { |identity| records.fetch(identity) }
|
|
108
|
+
(records.keys - other_records.keys).map { |identity| records.fetch(identity) }
|
|
110
109
|
end
|
|
111
110
|
|
|
112
111
|
#: (Hash[identity, RecordSnapshot], Hash[identity, RecordSnapshot]) -> Array[RecordChange]
|
|
113
112
|
def changed_records(from_records, to_records)
|
|
114
|
-
|
|
113
|
+
(from_records.keys & to_records.keys).filter_map do |identity|
|
|
115
114
|
from_record = from_records.fetch(identity)
|
|
116
115
|
to_record = to_records.fetch(identity)
|
|
117
116
|
next if from_record.equal?(to_record)
|
|
@@ -132,32 +131,6 @@ module PaperTrailDiff
|
|
|
132
131
|
index
|
|
133
132
|
end
|
|
134
133
|
|
|
135
|
-
#: (Array[identity]) -> Array[identity]
|
|
136
|
-
def sorted_identities(identities)
|
|
137
|
-
identities.sort_by { |identity| sortable_identity(identity) }
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
# Identities sort by type first so that mixed id types stay comparable, then
|
|
141
|
-
# naturally within one type. Ordering by the printed form instead would put
|
|
142
|
-
# id 10 before id 2, which is deterministic but reads as unsorted wherever a
|
|
143
|
-
# result is rendered.
|
|
144
|
-
#: (identity) -> Array[untyped]
|
|
145
|
-
def sortable_identity(identity)
|
|
146
|
-
[identity.fetch(0), sortable_id(identity.fetch(1))]
|
|
147
|
-
end
|
|
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
|
|
152
|
-
#: (untyped) -> Array[untyped]
|
|
153
|
-
def sortable_id(id)
|
|
154
|
-
case id
|
|
155
|
-
when Numeric then [0, id]
|
|
156
|
-
when String, Symbol then [1, id.to_s]
|
|
157
|
-
else [2, id.inspect]
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
|
|
161
134
|
#: (identity) -> void
|
|
162
135
|
def raise_duplicate_identity!(identity)
|
|
163
136
|
raise ArgumentError, "duplicate record identity: #{identity}"
|
|
@@ -7,19 +7,40 @@ module PaperTrailDiff
|
|
|
7
7
|
attr_reader :children #: Hash[String, AssociationTree]
|
|
8
8
|
|
|
9
9
|
class << self
|
|
10
|
+
# Takes what `includes` takes: a name, a dotted path, a hash of names to
|
|
11
|
+
# what to include below them, or an array of any of these.
|
|
10
12
|
#: (untyped) -> AssociationTree
|
|
11
13
|
def build(values)
|
|
12
|
-
unless valid_values?(values)
|
|
13
|
-
raise ConfigurationError, 'associations: must be an array of strings or symbols'
|
|
14
|
-
end
|
|
15
|
-
|
|
16
14
|
tree = {} #: Hash[String, untyped]
|
|
17
|
-
values.each { |
|
|
15
|
+
paths(values, nil).each { |path| add_path(tree, path) }
|
|
18
16
|
from_hash(tree)
|
|
19
17
|
end
|
|
20
18
|
|
|
21
19
|
private
|
|
22
20
|
|
|
21
|
+
#: (untyped, String?) -> Array[String]
|
|
22
|
+
def paths(value, prefix)
|
|
23
|
+
case value
|
|
24
|
+
when String, Symbol then [joined(prefix, value)]
|
|
25
|
+
when Array then value.flat_map { |item| paths(item, prefix) }
|
|
26
|
+
when Hash then value.flat_map { |name, nested| nested_paths(name, nested, prefix) }
|
|
27
|
+
else
|
|
28
|
+
raise ConfigurationError,
|
|
29
|
+
'associations: must be names, dotted paths, hashes of them, or arrays of these'
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `{ comments: :replies }` names comments as well as their replies.
|
|
34
|
+
#: (untyped, untyped, String?) -> Array[String]
|
|
35
|
+
def nested_paths(name, nested, prefix)
|
|
36
|
+
paths(name, prefix).flat_map { |path| [path, *paths(nested, path)] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
#: (String?, String | Symbol) -> String
|
|
40
|
+
def joined(prefix, name)
|
|
41
|
+
prefix ? "#{prefix}.#{name}" : name.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
23
44
|
#: (Hash[String, untyped], String) -> void
|
|
24
45
|
def add_path(tree, path)
|
|
25
46
|
segments = path.split('.', -1)
|
|
@@ -39,13 +60,6 @@ module PaperTrailDiff
|
|
|
39
60
|
end
|
|
40
61
|
new(children)
|
|
41
62
|
end
|
|
42
|
-
|
|
43
|
-
#: (untyped) -> bool
|
|
44
|
-
def valid_values?(values)
|
|
45
|
-
values.is_a?(Array) && values.all? do |value|
|
|
46
|
-
value.is_a?(String) || value.is_a?(Symbol)
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
63
|
end
|
|
50
64
|
|
|
51
65
|
#: (Hash[String, AssociationTree]) -> void
|
|
@@ -166,5 +180,14 @@ module PaperTrailDiff
|
|
|
166
180
|
external_path = path.empty? ? ROOT_PATH : path
|
|
167
181
|
(all + paths.fetch(external_path, [])).uniq.freeze
|
|
168
182
|
end
|
|
183
|
+
|
|
184
|
+
# The names to leave out of what a version recorded. A root version is
|
|
185
|
+
# known to be the root's, so its own path's names apply; any other version
|
|
186
|
+
# does not say which path reached it, so only the names ignored everywhere
|
|
187
|
+
# do.
|
|
188
|
+
#: (root: bool) -> Array[String]
|
|
189
|
+
def recorded_attributes(root:)
|
|
190
|
+
root ? attributes_for('') : all
|
|
191
|
+
end
|
|
169
192
|
end
|
|
170
193
|
end
|
|
@@ -24,12 +24,14 @@ module PaperTrailDiff
|
|
|
24
24
|
def call
|
|
25
25
|
destroys = destroy_versions
|
|
26
26
|
records = historical_roots(destroys)
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
windows = TransactionCut.batch_windows(records, @time_range)
|
|
28
|
+
selected = plans(records, destroys, windows)
|
|
29
|
+
prepare(records, selected, windows)
|
|
29
30
|
analyses = records.to_h do |record|
|
|
30
31
|
key = Endpoint.identity(record)
|
|
31
32
|
plan = selected.fetch(key)
|
|
32
|
-
|
|
33
|
+
window = windows.fetch(key, @time_range)
|
|
34
|
+
[key, plan.empty? ? Analysis.empty : @analyzer.call(record, plan, window)]
|
|
33
35
|
end
|
|
34
36
|
with_unavailable(analyses)
|
|
35
37
|
end
|
|
@@ -51,20 +53,20 @@ module PaperTrailDiff
|
|
|
51
53
|
|
|
52
54
|
# A root destroyed before the window had no state inside it to read, so
|
|
53
55
|
# only the others take the version after the window as context.
|
|
54
|
-
#: (Array[untyped], Array[untyped]) -> Hash[Array[String], RootVersionPlan]
|
|
55
|
-
def plans(records, destroys)
|
|
56
|
+
#: (Array[untyped], Array[untyped], Hash[Array[String], TimeRange]) -> Hash[Array[String], RootVersionPlan]
|
|
57
|
+
def plans(records, destroys, windows)
|
|
56
58
|
start = @time_range&.begin_time
|
|
57
59
|
early = destroys.select { |version| start && version.created_at < start }
|
|
58
60
|
.map { |version| version.item_id.to_s }
|
|
59
61
|
before, during = records.partition { |record| early.include?(record.id.to_s) }
|
|
60
|
-
plans_for(during, @context_required).merge(plans_for(before, false))
|
|
62
|
+
plans_for(during, @context_required, windows).merge(plans_for(before, false, windows))
|
|
61
63
|
end
|
|
62
64
|
|
|
63
|
-
#: (Array[untyped], bool) -> Hash[Array[String], RootVersionPlan]
|
|
64
|
-
def plans_for(records, context_required)
|
|
65
|
+
#: (Array[untyped], bool, Hash[Array[String], TimeRange]) -> Hash[Array[String], RootVersionPlan]
|
|
66
|
+
def plans_for(records, context_required, windows)
|
|
65
67
|
BatchedRootVersions.new(
|
|
66
68
|
records, time_range: @time_range, version_scope: @version_scope,
|
|
67
|
-
live_endpoints: nil, context_required: context_required
|
|
69
|
+
live_endpoints: nil, context_required: context_required, windows: windows
|
|
68
70
|
).call
|
|
69
71
|
end
|
|
70
72
|
|
|
@@ -84,28 +86,26 @@ module PaperTrailDiff
|
|
|
84
86
|
|
|
85
87
|
#: () -> Array[untyped]
|
|
86
88
|
def destroy_versions
|
|
87
|
-
destroys = @model.
|
|
88
|
-
item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
|
|
89
|
-
)
|
|
89
|
+
destroys = VersionQuery.for_items(@model, @identities.map(&:last)).where(event: 'destroy')
|
|
90
90
|
# Oldest first, so each item keeps its latest destroy.
|
|
91
91
|
VersionQuery.chronological(destroys).index_by(&:item_id).values
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
#: (Array[untyped]) -> Hash[Symbol, untyped]
|
|
95
|
-
def preparation_range(versions)
|
|
96
|
-
{ start_at: @time_range
|
|
94
|
+
#: (Array[untyped], Hash[Array[String], TimeRange]) -> Hash[Symbol, untyped]
|
|
95
|
+
def preparation_range(versions, windows)
|
|
96
|
+
{ start_at: TransactionCut.earliest_start(@time_range, windows),
|
|
97
97
|
end_at: [@time_range&.end_time, versions.map(&:created_at).max].compact.max }
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
-
#: (Array[untyped], Hash[Array[String], RootVersionPlan]) -> void
|
|
101
|
-
def prepare(records, selected)
|
|
100
|
+
#: (Array[untyped], Hash[Array[String], RootVersionPlan], Hash[Array[String], TimeRange]) -> void
|
|
101
|
+
def prepare(records, selected, windows)
|
|
102
102
|
records.group_by(&:class).each_value do |group|
|
|
103
103
|
versions = group.flat_map do |record|
|
|
104
104
|
selected.fetch(Endpoint.identity(record)).reconstruction_versions
|
|
105
105
|
end
|
|
106
106
|
next if versions.empty?
|
|
107
107
|
|
|
108
|
-
@history_preparer.call(group, versions, **preparation_range(versions))
|
|
108
|
+
@history_preparer.call(group, versions, **preparation_range(versions, windows))
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Keeps the deleted roots the relation would have selected, by the state
|
|
6
|
+
# each held when it was destroyed. A relation's conditions read the live
|
|
7
|
+
# table, where a destroyed root has no row, so without this a report on one
|
|
8
|
+
# account's records would include every account's deletions.
|
|
9
|
+
class DeletedRootFilter
|
|
10
|
+
#: (untyped) -> void
|
|
11
|
+
def initialize(relation)
|
|
12
|
+
@relation = relation
|
|
13
|
+
condition = ScopeCondition.for_relation(relation, model)
|
|
14
|
+
unless condition
|
|
15
|
+
raise ConfigurationError,
|
|
16
|
+
'include_deleted: cannot test deleted records against this relation: its ' \
|
|
17
|
+
"conditions must be plain conditions on #{model.name}'s own columns, as an " \
|
|
18
|
+
"association scope's must; select deleted records some other way"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
@condition = condition
|
|
22
|
+
@loader = DirectRecordStateLoader.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# A root whose destroy PaperTrail did not record has no state to test
|
|
26
|
+
# and stays, as it has no state to report either. One whose destroy
|
|
27
|
+
# recorded no state is left out.
|
|
28
|
+
#: (Array[Array[String]]) -> Array[Array[String]]
|
|
29
|
+
def call(identities)
|
|
30
|
+
states = destroyed_states(identities.map(&:last))
|
|
31
|
+
identities.select do |identity|
|
|
32
|
+
id = identity.last
|
|
33
|
+
!states.key?(id) || selected?(states.fetch(id))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# @rbs @relation: untyped
|
|
40
|
+
# @rbs @condition: ScopeCondition
|
|
41
|
+
# @rbs @loader: DirectRecordStateLoader
|
|
42
|
+
|
|
43
|
+
# A relation delegates class methods to its model, so every mutation here,
|
|
44
|
+
# which drops `.model`, answers the same.
|
|
45
|
+
# mutant:disable
|
|
46
|
+
#: () -> untyped
|
|
47
|
+
def model
|
|
48
|
+
@relation.model
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
#: (Hash[String, untyped]?) -> bool
|
|
52
|
+
def selected?(attributes)
|
|
53
|
+
return false unless attributes
|
|
54
|
+
|
|
55
|
+
AssociationTraversal.sti_member?(model, attributes) && @condition.matches?(attributes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The state each root held before its latest destroy.
|
|
59
|
+
#: (Array[String]) -> Hash[String, Hash[String, untyped]?]
|
|
60
|
+
def destroyed_states(ids)
|
|
61
|
+
destroys = VersionQuery.for_items(model, ids).where(event: 'destroy')
|
|
62
|
+
VersionQuery.chronological(destroys).to_h do |version|
|
|
63
|
+
[version.item_id.to_s, @loader.call(version)&.attributes || reified_attributes(version)]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Read directly, with a column `skip:` left out of history as its default,
|
|
68
|
+
# so a report checks its ceiling before reifying anything. A version the
|
|
69
|
+
# direct reader declines, such as one naming a column since removed, is
|
|
70
|
+
# reified. Only the record's own attributes are read, so the association
|
|
71
|
+
# options, which only spare queries, cannot change an answer.
|
|
72
|
+
# mutant:disable
|
|
73
|
+
#: (untyped) -> Hash[String, untyped]?
|
|
74
|
+
def reified_attributes(version)
|
|
75
|
+
version.reify(dup: true, has_many: false, has_one: false, belongs_to: false,
|
|
76
|
+
has_and_belongs_to_many: false)&.attributes
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Warns about options that do nothing any more and will be removed. In a
|
|
6
|
+
# Rails app, register the deprecator to configure it with the app's own:
|
|
7
|
+
#
|
|
8
|
+
# Rails.application.deprecators[:paper_trail_diff] = PaperTrailDiff.deprecator
|
|
9
|
+
module Deprecation
|
|
10
|
+
CLOSE_ON = 'close_on: is deprecated and has no effect: a window already closes on the ' \
|
|
11
|
+
'current record when nothing on its requested paths was recorded after it. ' \
|
|
12
|
+
'Remove the option.'
|
|
13
|
+
ALLOW_EXPENSIVE = 'allow_expensive: is deprecated and has no effect: allow_approximate: true ' \
|
|
14
|
+
'alone accepts a path read with PT-AT, in batch methods too. Remove the ' \
|
|
15
|
+
'option.'
|
|
16
|
+
RELOAD_LIVE_ENDPOINTS = 'reload_live_endpoints: is deprecated and will be removed: skipping ' \
|
|
17
|
+
'the reload saves a query for the records and one per association ' \
|
|
18
|
+
'level, and reads values that may be stale. Remove the option.'
|
|
19
|
+
|
|
20
|
+
# Required when first used, as the library loads nothing it does not need.
|
|
21
|
+
#: () -> untyped
|
|
22
|
+
def self.deprecator
|
|
23
|
+
@deprecator ||= begin
|
|
24
|
+
require 'active_support/deprecation'
|
|
25
|
+
active_support = Object.const_get(:ActiveSupport) #: untyped
|
|
26
|
+
active_support.const_get(:Deprecation).new('a future release', 'paper_trail_diff')
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The one value close_on: took. It named what ends a wall-clock window,
|
|
31
|
+
# so it was meaningless without one, and until it is removed it is still
|
|
32
|
+
# checked as it was.
|
|
33
|
+
CLOSE_ON_CURRENT = :current
|
|
34
|
+
|
|
35
|
+
#: (Symbol?, untyped) -> void
|
|
36
|
+
def self.close_on!(close_on, within)
|
|
37
|
+
return if close_on.nil?
|
|
38
|
+
unless close_on == CLOSE_ON_CURRENT
|
|
39
|
+
raise ConfigurationError, "close_on: must be #{CLOSE_ON_CURRENT.inspect} or nil"
|
|
40
|
+
end
|
|
41
|
+
raise ConfigurationError, 'close_on: requires `within`' if within.nil?
|
|
42
|
+
|
|
43
|
+
deprecator.warn(CLOSE_ON)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: (untyped) -> void
|
|
47
|
+
def self.allow_expensive!(allow_expensive)
|
|
48
|
+
deprecator.warn(ALLOW_EXPENSIVE) unless allow_expensive.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# The value to use: the option still works until it is removed, and nil
|
|
52
|
+
# stands for its old default.
|
|
53
|
+
#: (untyped) -> untyped
|
|
54
|
+
def self.reload_live_endpoints!(reload)
|
|
55
|
+
return true if reload.nil?
|
|
56
|
+
|
|
57
|
+
deprecator.warn(RELOAD_LIVE_ENDPOINTS)
|
|
58
|
+
reload
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|