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
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Shares association event discovery across every root in a prepared graph.
|
|
6
|
+
class PreparedActivityIndex
|
|
7
|
+
#: (PreparedRecordIndex, Array[untyped], start_at: untyped, end_at: untyped) -> void
|
|
8
|
+
def initialize(records, root_versions, start_at:, end_at:)
|
|
9
|
+
@records = records
|
|
10
|
+
@range = ActivityRange.new(start_at, end_at)
|
|
11
|
+
@roots = root_versions.group_by { |version| [version.item_type, version.item_id.to_s] }
|
|
12
|
+
@nodes = {} #: Hash[String, Array[untyped]]
|
|
13
|
+
@children = {} #: Hash[Array[String], untyped]
|
|
14
|
+
@rows = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
#: (untyped, Array[untyped]) -> void
|
|
18
|
+
def register(model, ids)
|
|
19
|
+
name = model.base_class.name.to_s
|
|
20
|
+
@nodes[name] = (@nodes.fetch(name, []) | ids)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
24
|
+
def versions(model, ids)
|
|
25
|
+
ids.flat_map do |id|
|
|
26
|
+
key = [model.base_class.name.to_s, id.to_s]
|
|
27
|
+
@roots[key] || @records.versions_for_id(model, id)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
32
|
+
def child_ids(model, ids, reflection, range)
|
|
33
|
+
key = [model.base_class.name.to_s, reflection.name.to_s]
|
|
34
|
+
historical, live = @children[key] ||= child_rows(model, reflection)
|
|
35
|
+
empty = [] #: Array[untyped]
|
|
36
|
+
selected = historical_ids(reflection.klass, ids, historical, range)
|
|
37
|
+
selected |= ids.flat_map { |id| live.fetch(id.to_s, empty) } if range.current_end?
|
|
38
|
+
selected.uniq
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: (untyped, Array[untyped], untyped, ?transactions: bool) -> Array[untyped]
|
|
42
|
+
def association_rows(model, selected_versions, reflection, transactions: false)
|
|
43
|
+
key = [model.base_class.name.to_s, reflection.name.to_s]
|
|
44
|
+
rows = @rows[key] ||= load_association_rows(model, reflection, transactions)
|
|
45
|
+
version_ids(selected_versions, transactions).flat_map { |id| rows.fetch(id.to_s, []) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @rbs @records: PreparedRecordIndex
|
|
51
|
+
# @rbs @range: ActivityRange
|
|
52
|
+
# @rbs @roots: Hash[Array[String], Array[untyped]]
|
|
53
|
+
# @rbs @nodes: Hash[String, Array[untyped]]
|
|
54
|
+
# @rbs @children: Hash[Array[String], untyped]
|
|
55
|
+
# @rbs @rows: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
56
|
+
|
|
57
|
+
#: (untyped, Array[untyped], untyped, ActivityRange) -> Array[untyped]
|
|
58
|
+
def historical_ids(model, ids, historical, range)
|
|
59
|
+
empty = [] #: Array[untyped]
|
|
60
|
+
ids.flat_map do |id|
|
|
61
|
+
historical.fetch(id.to_s, empty).filter_map do |row|
|
|
62
|
+
row[1] if eligible_child?(model, row, range)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
#: (untyped) -> Array[untyped]
|
|
68
|
+
def node_ids(model)
|
|
69
|
+
name = model.base_class.name.to_s
|
|
70
|
+
@nodes.fetch(name) do
|
|
71
|
+
raise ConfigurationError,
|
|
72
|
+
"prepared activity graph is missing #{name}; " \
|
|
73
|
+
'register the model before collecting activity'
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
#: (untyped, untyped) -> untyped
|
|
78
|
+
def child_rows(model, reflection)
|
|
79
|
+
loader = ActivityChildCandidateLoader.new(
|
|
80
|
+
model, node_ids(model), reflection, @range
|
|
81
|
+
)
|
|
82
|
+
[loader.historical_rows.group_by { |row| row.first.to_s },
|
|
83
|
+
loader.current_pairs.group_by { |row| row.first.to_s }
|
|
84
|
+
.transform_values { |rows| rows.map(&:last) }]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Mirrors VersionAssociationCandidateScope's before-window rule. The loaded
|
|
88
|
+
# series includes changes to other owners too, so an old membership cannot
|
|
89
|
+
# survive a later pre-window reassignment just because that owner is absent.
|
|
90
|
+
#: (untyped, Array[untyped], ActivityRange) -> bool
|
|
91
|
+
def eligible_child?(model, row, range)
|
|
92
|
+
_owner, id, timestamp, event = row
|
|
93
|
+
return timestamp <= range.end_time if timestamp >= range.start_time
|
|
94
|
+
return false if event == 'destroy'
|
|
95
|
+
|
|
96
|
+
later = @records.versions_for_id(model, id)
|
|
97
|
+
.bsearch { |version| version.created_at > timestamp }
|
|
98
|
+
!later || later.created_at >= range.start_time
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
#: (untyped, untyped, bool) -> Hash[String, Array[untyped]]
|
|
102
|
+
def load_association_rows(model, reflection, transactions)
|
|
103
|
+
all_versions = versions(model, node_ids(model))
|
|
104
|
+
foreign_key = transactions ? reflection.name.to_s : reflection.foreign_key.to_s
|
|
105
|
+
model.paper_trail.version_association_class.where(
|
|
106
|
+
version_id: version_ids(all_versions, transactions), foreign_key_name: foreign_key
|
|
107
|
+
).to_a.group_by { |row| row.version_id.to_s }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
#: (Array[untyped], bool) -> Array[untyped]
|
|
111
|
+
def version_ids(versions, transactions)
|
|
112
|
+
if transactions
|
|
113
|
+
versions.filter_map do |version|
|
|
114
|
+
version.transaction_id if version.respond_to?(:transaction_id)
|
|
115
|
+
end.uniq
|
|
116
|
+
else
|
|
117
|
+
versions.map(&:id)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -50,11 +50,15 @@ module PaperTrailDiff
|
|
|
50
50
|
def belongs_to_groups(owner_class, owner_ids, reflection)
|
|
51
51
|
key = scalar_foreign_key(reflection)
|
|
52
52
|
return {} unless key
|
|
53
|
+
unless reflection.polymorphic?
|
|
54
|
+
return group_for(reflection.klass,
|
|
55
|
+
@records.attribute_values_for(owner_class, owner_ids, key))
|
|
56
|
+
end
|
|
53
57
|
|
|
54
58
|
grouped = {} #: Hash[String, Hash[Symbol, untyped]]
|
|
55
59
|
@records.records_for(owner_class, owner_ids).each do |owner|
|
|
56
60
|
id = owner.public_send(key)
|
|
57
|
-
model_class =
|
|
61
|
+
model_class = AssociationTraversal.belongs_to_target(reflection, owner)
|
|
58
62
|
add_identity(grouped, model_class, id) if model_class && id
|
|
59
63
|
end
|
|
60
64
|
grouped
|
|
@@ -109,7 +113,12 @@ module PaperTrailDiff
|
|
|
109
113
|
association_class.arel_table[:foreign_key_id],
|
|
110
114
|
version_class.arel_table[:item_id]
|
|
111
115
|
)
|
|
112
|
-
group_ids_by_owner(pairs)
|
|
116
|
+
group_ids_by_owner(typed_child_ids(reflection.klass, pairs))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
#: (untyped, Array[Array[untyped]]) -> Array[Array[untyped]]
|
|
120
|
+
def typed_child_ids(model_class, pairs)
|
|
121
|
+
pairs.map { |owner_id, child_id| [owner_id, Support.record_id(model_class, child_id)] }
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
#: (untyped, Array[untyped], untyped, String) -> untyped
|
|
@@ -139,15 +148,6 @@ module PaperTrailDiff
|
|
|
139
148
|
group_ids_by_owner(relation.pluck(foreign_key, reflection.klass.primary_key))
|
|
140
149
|
end
|
|
141
150
|
|
|
142
|
-
#: (untyped, untyped) -> untyped
|
|
143
|
-
def target_class(reflection, owner)
|
|
144
|
-
return reflection.klass unless reflection.polymorphic?
|
|
145
|
-
|
|
146
|
-
Object.const_get(owner.public_send(reflection.foreign_type).to_s)
|
|
147
|
-
rescue NameError
|
|
148
|
-
nil
|
|
149
|
-
end
|
|
150
|
-
|
|
151
151
|
#: (Hash[String, Hash[Symbol, untyped]], untyped, untyped) -> void
|
|
152
152
|
def add_identity(groups, model_class, id)
|
|
153
153
|
group = groups[model_class.name.to_s] ||= { model: model_class, ids: [] }
|
|
@@ -4,17 +4,21 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Batched record states and relationship candidates for one activity range.
|
|
6
6
|
class PreparedHistory
|
|
7
|
+
attr_reader :activity_index #: PreparedActivityIndex?
|
|
8
|
+
|
|
7
9
|
DIRECT_RESOLVERS = {
|
|
8
10
|
belongs_to: :resolve_belongs_to,
|
|
9
11
|
has_one: :resolve_children,
|
|
10
12
|
has_many: :resolve_children,
|
|
11
|
-
has_and_belongs_to_many: :
|
|
13
|
+
has_and_belongs_to_many: :habtm_records
|
|
12
14
|
}.freeze
|
|
13
15
|
private_constant :DIRECT_RESOLVERS
|
|
14
16
|
|
|
15
|
-
#: (PreparedRecordIndex) -> void
|
|
16
|
-
def initialize(records)
|
|
17
|
+
#: (PreparedRecordIndex, ?activity_index: PreparedActivityIndex?) -> void
|
|
18
|
+
def initialize(records, activity_index: nil)
|
|
17
19
|
@records = records
|
|
20
|
+
@state_reader = PreparedStateAssociationReader.new(records, method(:edge))
|
|
21
|
+
@activity_index = activity_index
|
|
18
22
|
@edges = {} #: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
|
|
19
23
|
@unsupported = {} #: Hash[Array[String], bool]
|
|
20
24
|
@habtm = {} #: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
@@ -24,7 +28,7 @@ module PaperTrailDiff
|
|
|
24
28
|
def merge_edge(owner_class, reflection, groups)
|
|
25
29
|
key = edge_key(owner_class, reflection)
|
|
26
30
|
empty = {} #: Hash[String, Hash[Symbol, untyped]]
|
|
27
|
-
@edges[key] =
|
|
31
|
+
@edges[key] = Support.merge_record_groups(@edges.fetch(key, empty), groups)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
#: (untyped, untyped) -> Hash[String, Hash[Symbol, untyped]]
|
|
@@ -48,6 +52,16 @@ module PaperTrailDiff
|
|
|
48
52
|
@records.transition(model_class, id, version)
|
|
49
53
|
end
|
|
50
54
|
|
|
55
|
+
#: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
|
|
56
|
+
def record_after(model_class, id, version, direct: false)
|
|
57
|
+
@records.record_after(model_class, id, version, direct: direct)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
#: (PreparedRecordState, untyped, untyped) -> [bool, Array[PreparedRecordState]]
|
|
61
|
+
def snapshot_states(owner, reflection, boundary)
|
|
62
|
+
@state_reader.call(owner, reflection, boundary)
|
|
63
|
+
end
|
|
64
|
+
|
|
51
65
|
#: (untyped, untyped, untyped, habtm_boundary: untyped) -> [bool, Array[untyped]]
|
|
52
66
|
def resolve(record, reflection, boundary, habtm_boundary:)
|
|
53
67
|
return [false, []] unless supported?(record.class, reflection)
|
|
@@ -63,6 +77,7 @@ module PaperTrailDiff
|
|
|
63
77
|
private
|
|
64
78
|
|
|
65
79
|
# @rbs @records: PreparedRecordIndex
|
|
80
|
+
# @rbs @state_reader: PreparedStateAssociationReader
|
|
66
81
|
# @rbs @edges: Hash[Array[String], Hash[String, Hash[Symbol, untyped]]]
|
|
67
82
|
# @rbs @unsupported: Hash[Array[String], bool]
|
|
68
83
|
# @rbs @habtm: Hash[Array[String], Hash[String, Array[untyped]]]
|
|
@@ -70,12 +85,7 @@ module PaperTrailDiff
|
|
|
70
85
|
#: (untyped, untyped) -> bool
|
|
71
86
|
def supported?(owner_class, reflection)
|
|
72
87
|
!@unsupported[edge_key(owner_class, reflection)] &&
|
|
73
|
-
reflection.scope.nil? && !
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
#: (untyped) -> bool
|
|
77
|
-
def default_scoped?(model_class)
|
|
78
|
-
model_class.default_scopes?
|
|
88
|
+
reflection.scope.nil? && !reflection.klass.default_scopes?
|
|
79
89
|
end
|
|
80
90
|
|
|
81
91
|
#: (untyped, untyped, untyped, untyped) -> Array[untyped]?
|
|
@@ -94,11 +104,6 @@ module PaperTrailDiff
|
|
|
94
104
|
child_records(record, reflection, boundary)
|
|
95
105
|
end
|
|
96
106
|
|
|
97
|
-
#: (untyped, untyped, untyped, untyped) -> Array[untyped]?
|
|
98
|
-
def resolve_habtm(record, reflection, boundary, habtm_boundary)
|
|
99
|
-
habtm_records(record, reflection, boundary, habtm_boundary)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
107
|
#: (untyped, untyped, untyped) -> Array[untyped]?
|
|
103
108
|
def belongs_to_records(record, reflection, boundary)
|
|
104
109
|
foreign_key = scalar_foreign_key(reflection)
|
|
@@ -107,7 +112,7 @@ module PaperTrailDiff
|
|
|
107
112
|
id = record.public_send(foreign_key)
|
|
108
113
|
return [] if id.nil?
|
|
109
114
|
|
|
110
|
-
model_class =
|
|
115
|
+
model_class = AssociationTraversal.belongs_to_target(reflection, record)
|
|
111
116
|
return unless model_class && @records.loaded?(model_class, id)
|
|
112
117
|
|
|
113
118
|
Array(@records.record_before(model_class, id, boundary)).compact
|
|
@@ -189,15 +194,6 @@ module PaperTrailDiff
|
|
|
189
194
|
key.to_s unless key.is_a?(Array)
|
|
190
195
|
end
|
|
191
196
|
|
|
192
|
-
#: (untyped, untyped) -> untyped
|
|
193
|
-
def belongs_to_class(record, reflection)
|
|
194
|
-
return reflection.klass unless reflection.polymorphic?
|
|
195
|
-
|
|
196
|
-
Object.const_get(record.public_send(reflection.foreign_type).to_s)
|
|
197
|
-
rescue NameError
|
|
198
|
-
nil
|
|
199
|
-
end
|
|
200
|
-
|
|
201
197
|
#: (untyped, untyped) -> Array[String]
|
|
202
198
|
def owner_types(reflection, owner)
|
|
203
199
|
types = [owner.class.name, owner.class.base_class.name, reflection.active_record.name]
|
|
@@ -218,10 +214,5 @@ module PaperTrailDiff
|
|
|
218
214
|
def edge_key(owner_class, reflection)
|
|
219
215
|
[owner_class.base_class.name.to_s, reflection.name.to_s]
|
|
220
216
|
end
|
|
221
|
-
|
|
222
|
-
#: (Hash[String, Hash[Symbol, untyped]], Hash[String, Hash[Symbol, untyped]]) -> Hash[String, Hash[Symbol, untyped]]
|
|
223
|
-
def merge_groups(existing, incoming)
|
|
224
|
-
Support.merge_record_groups(existing, incoming)
|
|
225
|
-
end
|
|
226
217
|
end
|
|
227
218
|
end
|
|
@@ -17,16 +17,21 @@ module PaperTrailDiff
|
|
|
17
17
|
)
|
|
18
18
|
@record = record
|
|
19
19
|
@root_ids = root_ids
|
|
20
|
+
@root_versions = root_versions
|
|
20
21
|
@tree = tree
|
|
21
22
|
@traversal = traversal
|
|
22
23
|
@records = PreparedRecordIndex.new(start_at, end_at: end_at, live_records: live_records)
|
|
23
|
-
|
|
24
|
+
activity = PreparedActivityIndex.new(
|
|
25
|
+
@records, root_versions, start_at: start_at, end_at: end_at || root_versions.last.created_at
|
|
26
|
+
)
|
|
27
|
+
@history = PreparedHistory.new(@records, activity_index: activity)
|
|
24
28
|
@edges = PreparedEdgeLoader.new(@records, root_versions, start_at: start_at)
|
|
25
29
|
@prepared = {} #: Hash[Array[String], Array[String]]
|
|
26
30
|
end
|
|
27
31
|
|
|
28
32
|
#: () -> PreparedHistory
|
|
29
33
|
def call
|
|
34
|
+
@records.seed(@record.class, @root_ids, @root_versions)
|
|
30
35
|
load_node(@record.class, @root_ids, @tree, path: '')
|
|
31
36
|
@history
|
|
32
37
|
end
|
|
@@ -35,6 +40,7 @@ module PaperTrailDiff
|
|
|
35
40
|
|
|
36
41
|
# @rbs @record: untyped
|
|
37
42
|
# @rbs @root_ids: Array[untyped]
|
|
43
|
+
# @rbs @root_versions: Array[untyped]
|
|
38
44
|
# @rbs @tree: AssociationTree
|
|
39
45
|
# @rbs @traversal: AssociationTraversal
|
|
40
46
|
# @rbs @records: PreparedRecordIndex
|
|
@@ -44,6 +50,7 @@ module PaperTrailDiff
|
|
|
44
50
|
|
|
45
51
|
#: (untyped, Array[untyped], AssociationTree, path: String) -> void
|
|
46
52
|
def load_node(model_class, ids, tree, path:)
|
|
53
|
+
@history.activity_index&.register(model_class, ids)
|
|
47
54
|
@traversal.reflections_for(model_class, tree, path: path).each do |reflection|
|
|
48
55
|
subtree = tree.child(reflection.name)
|
|
49
56
|
next unless subtree
|
|
@@ -119,7 +126,12 @@ module PaperTrailDiff
|
|
|
119
126
|
|
|
120
127
|
#: (Hash[String, Hash[Symbol, untyped]]) -> void
|
|
121
128
|
def load_groups(groups)
|
|
122
|
-
groups.each_value
|
|
129
|
+
groups.each_value do |group|
|
|
130
|
+
model = group.fetch(:model)
|
|
131
|
+
ids = group.fetch(:ids)
|
|
132
|
+
@history.activity_index&.register(model, ids)
|
|
133
|
+
load_records(model, ids)
|
|
134
|
+
end
|
|
123
135
|
end
|
|
124
136
|
|
|
125
137
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -10,7 +10,7 @@ module PaperTrailDiff
|
|
|
10
10
|
#: (untyped) -> void
|
|
11
11
|
def initialize(record)
|
|
12
12
|
@model_class = record.class
|
|
13
|
-
@attributes =
|
|
13
|
+
@attributes = stored_attributes(record.class, record.attributes)
|
|
14
14
|
freeze
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -36,9 +36,18 @@ module PaperTrailDiff
|
|
|
36
36
|
#: (untyped, Hash[untyped, untyped]) -> void
|
|
37
37
|
def initialize_attributes(model_class, attributes)
|
|
38
38
|
@model_class = model_class
|
|
39
|
-
@attributes =
|
|
39
|
+
@attributes = stored_attributes(model_class, attributes)
|
|
40
40
|
freeze
|
|
41
41
|
end
|
|
42
|
+
|
|
43
|
+
# A live fallback and a version decoded from history must agree on what
|
|
44
|
+
# the stored history could represent, or the gap between them reads as a
|
|
45
|
+
# change.
|
|
46
|
+
#: (untyped, Hash[untyped, untyped]) -> Hash[untyped, untyped]
|
|
47
|
+
def stored_attributes(model_class, attributes)
|
|
48
|
+
digits = StoredTimePrecision.digits(model_class)
|
|
49
|
+
Support.immutable_copy(StoredTimePrecision.apply(attributes, digits))
|
|
50
|
+
end
|
|
42
51
|
end
|
|
43
52
|
|
|
44
53
|
# Extracts a version's scalar state without constructing a disposable AR object.
|
|
@@ -121,17 +130,37 @@ module PaperTrailDiff
|
|
|
121
130
|
|
|
122
131
|
#: (untyped) -> untyped
|
|
123
132
|
def record_before(boundary)
|
|
133
|
+
state_before(boundary)&.instantiate
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
#: (untyped) -> PreparedRecordState?
|
|
137
|
+
def state_before(boundary)
|
|
124
138
|
index = boundary_index(boundary)
|
|
125
|
-
return state_for(versions.fetch(index))
|
|
139
|
+
return state_for(versions.fetch(index)) if index
|
|
140
|
+
|
|
141
|
+
@live
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# A handled nil is known absence, not permission to query the current row.
|
|
145
|
+
#: (untyped, ?direct: bool) -> [bool, untyped]
|
|
146
|
+
def record_after(version, direct: false)
|
|
147
|
+
index = @version_positions[version.id.to_s]
|
|
148
|
+
return [false, nil] unless index
|
|
126
149
|
|
|
127
|
-
|
|
150
|
+
state = state_after(index)
|
|
151
|
+
[true, direct ? state : state&.instantiate]
|
|
128
152
|
end
|
|
129
153
|
|
|
130
154
|
#: () -> Array[untyped]
|
|
131
155
|
def records
|
|
132
|
-
|
|
156
|
+
states.map(&:instantiate)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
#: () -> Array[PreparedRecordState]
|
|
160
|
+
def states
|
|
161
|
+
recorded = versions.filter_map { |version| state_for(version) }
|
|
133
162
|
live = @live
|
|
134
|
-
live ? [*
|
|
163
|
+
live ? [*recorded, live] : recorded
|
|
135
164
|
end
|
|
136
165
|
|
|
137
166
|
#: (untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
@@ -164,11 +193,15 @@ module PaperTrailDiff
|
|
|
164
193
|
# boundary's transaction. The first is monotonic over the chronological
|
|
165
194
|
# list and the second is indexed, so neither rescans a long history once
|
|
166
195
|
# per boundary.
|
|
196
|
+
#
|
|
197
|
+
# "At or after" is decided by the version order rather than the timestamp
|
|
198
|
+
# alone. A version written in the same instant as the boundary but before
|
|
199
|
+
# it already took effect there; reading its pre-change state would put the
|
|
200
|
+
# boundary on the wrong side of a change it follows.
|
|
167
201
|
#: (untyped) -> Integer?
|
|
168
202
|
def boundary_index(boundary)
|
|
169
|
-
created_at = boundary.created_at
|
|
170
203
|
chronological = versions.bsearch_index do |candidate|
|
|
171
|
-
candidate
|
|
204
|
+
VersionBoundary.at_or_after?(candidate, boundary)
|
|
172
205
|
end
|
|
173
206
|
transactional = transaction_index(boundary)
|
|
174
207
|
return chronological unless transactional
|
|
@@ -200,6 +233,7 @@ module PaperTrailDiff
|
|
|
200
233
|
#: (untyped) -> PreparedRecordState?
|
|
201
234
|
def state_for(version)
|
|
202
235
|
return @states[version.id] if @states.key?(version.id)
|
|
236
|
+
return @states[version.id] = nil unless version.object
|
|
203
237
|
|
|
204
238
|
prepared = @state_loader.call(version)
|
|
205
239
|
return @states[version.id] = prepared if prepared
|
|
@@ -226,6 +260,8 @@ module PaperTrailDiff
|
|
|
226
260
|
end
|
|
227
261
|
@state_loader = PreparedVersionStateLoader.new
|
|
228
262
|
@series = {} #: Hash[Array[String], PreparedRecordSeries]
|
|
263
|
+
@direct_loader = DirectRecordStateLoader.new
|
|
264
|
+
@direct_states = {} #: Hash[PreparedRecordState, PreparedRecordState?]
|
|
229
265
|
end
|
|
230
266
|
|
|
231
267
|
#: (untyped, Array[untyped]) -> void
|
|
@@ -240,21 +276,67 @@ module PaperTrailDiff
|
|
|
240
276
|
missing.each { |id| add_series(model_class, id, versions, live) }
|
|
241
277
|
end
|
|
242
278
|
|
|
279
|
+
#: (untyped, Array[untyped], Array[untyped]) -> void
|
|
280
|
+
def seed(model_class, ids, versions)
|
|
281
|
+
grouped = versions.group_by { |version| version.item_id.to_s }
|
|
282
|
+
live = available_live_records(model_class, ids).to_h { |record| [record.id.to_s, record] }
|
|
283
|
+
ids.each { |id| add_series(model_class, id, grouped, live) }
|
|
284
|
+
end
|
|
285
|
+
|
|
243
286
|
#: (untyped, untyped, untyped) -> untyped
|
|
244
287
|
def record_before(model_class, id, boundary)
|
|
245
288
|
@series.fetch(identity(model_class, id)).record_before(boundary)
|
|
246
289
|
end
|
|
247
290
|
|
|
291
|
+
#: (untyped, untyped, untyped) -> [bool, PreparedRecordState?]
|
|
292
|
+
def snapshot_state_before(model_class, id, boundary)
|
|
293
|
+
series = @series[identity(model_class, id)]
|
|
294
|
+
return [false, nil] unless series
|
|
295
|
+
|
|
296
|
+
state = series.state_before(boundary)
|
|
297
|
+
return [true, nil] unless state
|
|
298
|
+
|
|
299
|
+
@direct_states[state] = @direct_loader.complete(state) unless @direct_states.key?(state)
|
|
300
|
+
prepared = @direct_states[state]
|
|
301
|
+
[!prepared.nil?, prepared]
|
|
302
|
+
end
|
|
303
|
+
|
|
248
304
|
#: (untyped, untyped, untyped) -> [Hash[untyped, untyped], Hash[untyped, untyped]]?
|
|
249
305
|
def transition(model_class, id, version)
|
|
250
306
|
@series[identity(model_class, id)]&.transition(version)
|
|
251
307
|
end
|
|
252
308
|
|
|
309
|
+
#: (untyped, untyped, untyped, ?direct: bool) -> [bool, untyped]
|
|
310
|
+
def record_after(model_class, id, version, direct: false)
|
|
311
|
+
handled, state = @series[identity(model_class, id)]&.record_after(version, direct: direct)
|
|
312
|
+
return [false, nil] unless handled
|
|
313
|
+
return [true, state] unless direct && state
|
|
314
|
+
|
|
315
|
+
prepared = @direct_loader.complete(state)
|
|
316
|
+
[!prepared.nil?, prepared]
|
|
317
|
+
end
|
|
318
|
+
|
|
253
319
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
254
320
|
def records_for(model_class, ids)
|
|
255
321
|
ids.flat_map { |id| @series.fetch(identity(model_class, id)).records }
|
|
256
322
|
end
|
|
257
323
|
|
|
324
|
+
#: (untyped, Array[untyped], String) -> Array[untyped]
|
|
325
|
+
def attribute_values_for(model_class, ids, name)
|
|
326
|
+
plain_reader = generated_reader?(model_class, name)
|
|
327
|
+
ids.flat_map do |id|
|
|
328
|
+
@series.fetch(identity(model_class, id)).states.map do |state|
|
|
329
|
+
direct = @direct_loader.complete(state) if plain_reader
|
|
330
|
+
direct ? direct.attributes[name] : state.instantiate.public_send(name)
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
#: (untyped, untyped) -> Array[untyped]
|
|
336
|
+
def versions_for_id(model_class, id)
|
|
337
|
+
@series.fetch(identity(model_class, id)).versions
|
|
338
|
+
end
|
|
339
|
+
|
|
258
340
|
#: (untyped, untyped) -> bool
|
|
259
341
|
def loaded?(model_class, id)
|
|
260
342
|
@series.key?(identity(model_class, id))
|
|
@@ -262,11 +344,19 @@ module PaperTrailDiff
|
|
|
262
344
|
|
|
263
345
|
private
|
|
264
346
|
|
|
347
|
+
#: (untyped, String) -> bool
|
|
348
|
+
def generated_reader?(model, name)
|
|
349
|
+
model.define_attribute_methods
|
|
350
|
+
model.instance_method(name).owner.name.to_s.end_with?('GeneratedAttributeMethods')
|
|
351
|
+
end
|
|
352
|
+
|
|
265
353
|
# @rbs @start_time: untyped
|
|
266
354
|
# @rbs @end_time: untyped
|
|
267
355
|
# @rbs @seeded_live_records: Hash[Array[String], untyped]
|
|
268
356
|
# @rbs @state_loader: PreparedVersionStateLoader
|
|
269
357
|
# @rbs @series: Hash[Array[String], PreparedRecordSeries]
|
|
358
|
+
# @rbs @direct_loader: DirectRecordStateLoader
|
|
359
|
+
# @rbs @direct_states: Hash[PreparedRecordState, PreparedRecordState?]
|
|
270
360
|
|
|
271
361
|
# Callers bound the range with either a version or a bare timestamp.
|
|
272
362
|
#: (untyped) -> untyped
|
|
@@ -314,27 +404,10 @@ module PaperTrailDiff
|
|
|
314
404
|
#: (untyped) -> untyped
|
|
315
405
|
def window_condition(version_class)
|
|
316
406
|
table = version_class.arel_table
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
def first_after_range(table)
|
|
322
|
-
arel = Object.const_get(:Arel) #: untyped
|
|
323
|
-
later = table.alias('paper_trail_diff_later_versions')
|
|
324
|
-
arel.const_get(:SelectManager).new
|
|
325
|
-
.from(later)
|
|
326
|
-
.project(arel.sql('1'))
|
|
327
|
-
.where(preceding_trailing_version(table, later))
|
|
328
|
-
.exists
|
|
329
|
-
.not
|
|
330
|
-
end
|
|
331
|
-
|
|
332
|
-
#: (untyped, untyped) -> untyped
|
|
333
|
-
def preceding_trailing_version(table, later)
|
|
334
|
-
later[:item_type].eq(table[:item_type])
|
|
335
|
-
.and(later[:item_id].eq(table[:item_id]))
|
|
336
|
-
.and(later[:created_at].gt(@end_time))
|
|
337
|
-
.and(later[:created_at].lt(table[:created_at]))
|
|
407
|
+
first_after = VersionQuery.no_earlier_version(table) do |earlier|
|
|
408
|
+
earlier[:created_at].gt(@end_time)
|
|
409
|
+
end
|
|
410
|
+
table[:created_at].lteq(@end_time).or(first_after)
|
|
338
411
|
end
|
|
339
412
|
|
|
340
413
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Normalizes supported recorded graphs without historical ActiveRecord instances.
|
|
6
|
+
class PreparedSnapshotBuilder
|
|
7
|
+
#: (AssociationTraversal) -> void
|
|
8
|
+
def initialize(traversal)
|
|
9
|
+
@traversal = traversal
|
|
10
|
+
@loader = DirectRecordStateLoader.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
#: (untyped, untyped, tree: AssociationTree, normalizer: SnapshotNormalizer, history: PreparedHistory?) -> [bool, RecordSnapshot?]
|
|
14
|
+
def call(version, boundary, tree:, normalizer:, history:)
|
|
15
|
+
return [true, nil] unless version.object
|
|
16
|
+
|
|
17
|
+
state = @loader.call(version)
|
|
18
|
+
return [false, nil] unless state
|
|
19
|
+
|
|
20
|
+
build(state, boundary, tree, normalizer, history, path: '')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
# @rbs @traversal: AssociationTraversal
|
|
26
|
+
# @rbs @loader: DirectRecordStateLoader
|
|
27
|
+
|
|
28
|
+
#: (PreparedRecordState, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory?, path: String, ?incoming: untyped) -> [bool, RecordSnapshot?]
|
|
29
|
+
def build(state, boundary, tree, normalizer, history, path:, incoming: nil) # rubocop:disable Metrics/ParameterLists
|
|
30
|
+
associations = {} #: snapshot_associations
|
|
31
|
+
@traversal.reflections_for(state.model_class, tree, path: path).each do |reflection|
|
|
32
|
+
return [false, nil] unless history
|
|
33
|
+
|
|
34
|
+
handled, snapshot = association(state, reflection, boundary, tree, normalizer, history,
|
|
35
|
+
path)
|
|
36
|
+
return [false, nil] unless handled && snapshot
|
|
37
|
+
|
|
38
|
+
associations[reflection.name.to_s] = snapshot
|
|
39
|
+
end
|
|
40
|
+
[true, normalizer.from_state(state, tree: tree, path: path,
|
|
41
|
+
incoming: incoming, associations: associations)]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
#: (PreparedRecordState, untyped, untyped, AssociationTree, SnapshotNormalizer, PreparedHistory, String) -> [bool, AssociationSnapshot?]
|
|
45
|
+
def association(owner, reflection, boundary, tree, normalizer, history, path) # rubocop:disable Metrics/ParameterLists
|
|
46
|
+
handled, states = history.snapshot_states(owner, reflection, boundary)
|
|
47
|
+
return [false, nil] unless handled
|
|
48
|
+
|
|
49
|
+
subtree = tree.child(reflection.name)
|
|
50
|
+
return [false, nil] unless subtree
|
|
51
|
+
|
|
52
|
+
child_path = Support.association_path(path, reflection.name.to_s)
|
|
53
|
+
records = [] #: Array[RecordSnapshot]
|
|
54
|
+
states.each do |state|
|
|
55
|
+
supported, snapshot = build(state, boundary, subtree, normalizer, history,
|
|
56
|
+
path: child_path, incoming: reflection)
|
|
57
|
+
return [false, nil] unless supported && snapshot
|
|
58
|
+
|
|
59
|
+
records << snapshot
|
|
60
|
+
end
|
|
61
|
+
[true, AssociationSnapshot.new(kind: reflection.macro, records: records)]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|