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
|
@@ -2,22 +2,35 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
#
|
|
5
|
+
# Prepares the snapshot store for TimelineBuilder, one root at a time.
|
|
6
6
|
class TimelineSnapshotProvider
|
|
7
7
|
#: (HistoricalSnapshotStore, live_snapshotter: untyped) -> void
|
|
8
8
|
def initialize(store, live_snapshotter:)
|
|
9
9
|
@store = store
|
|
10
10
|
@live_snapshotter = live_snapshotter
|
|
11
|
-
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
12
11
|
end
|
|
13
12
|
|
|
14
|
-
#: (untyped, Array[untyped]) ->
|
|
13
|
+
#: (untyped, Array[untyped]) -> PreparedTimelineSnapshots
|
|
15
14
|
def prepare(record, versions)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
history = @store.prepare(record, versions)
|
|
16
|
+
PreparedTimelineSnapshots.new(history, store: @store, live_snapshotter: @live_snapshotter)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @rbs @store: HistoricalSnapshotStore
|
|
20
|
+
# @rbs @live_snapshotter: untyped
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One root's timeline snapshots, read with the history prepared for it.
|
|
24
|
+
# Adjacent steps share endpoints, so each immutable snapshot, including a
|
|
25
|
+
# nil create state, is kept for this root only and released with it, and
|
|
26
|
+
# batch memory does not retain every reconstructed graph.
|
|
27
|
+
class PreparedTimelineSnapshots
|
|
28
|
+
#: (PreparedHistory?, store: HistoricalSnapshotStore, live_snapshotter: untyped) -> void
|
|
29
|
+
def initialize(history, store:, live_snapshotter:)
|
|
30
|
+
@history = history
|
|
31
|
+
@store = store
|
|
32
|
+
@live_snapshotter = live_snapshotter
|
|
33
|
+
@snapshots = {} #: Hash[Array[untyped], RecordSnapshot?]
|
|
21
34
|
end
|
|
22
35
|
|
|
23
36
|
# A window closing on current state ends at the live record rather than at a
|
|
@@ -28,19 +41,13 @@ module PaperTrailDiff
|
|
|
28
41
|
return @snapshots[key] if @snapshots.key?(key)
|
|
29
42
|
|
|
30
43
|
@snapshots[key] = if Endpoint.record?(endpoint)
|
|
31
|
-
|
|
44
|
+
@live_snapshotter.call(endpoint)
|
|
32
45
|
else
|
|
33
|
-
@store.uncached(endpoint, endpoint)
|
|
46
|
+
@store.uncached(endpoint, endpoint, history: @history)
|
|
34
47
|
end
|
|
35
48
|
end
|
|
36
49
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
#: (untyped) -> RecordSnapshot?
|
|
40
|
-
def live_snapshot(record)
|
|
41
|
-
@live_snapshotter.call(record)
|
|
42
|
-
end
|
|
43
|
-
|
|
50
|
+
# @rbs @history: PreparedHistory?
|
|
44
51
|
# @rbs @store: HistoricalSnapshotStore
|
|
45
52
|
# @rbs @live_snapshotter: untyped
|
|
46
53
|
# @rbs @snapshots: Hash[Array[untyped], RecordSnapshot?]
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module PaperTrailDiff
|
|
5
|
+
# Moves each edge of a wall-clock window that falls inside one of a root's
|
|
6
|
+
# transactions back to where that transaction began, so the transaction is
|
|
7
|
+
# wholly in the later window.
|
|
8
|
+
#
|
|
9
|
+
# PT-AT reads a root's associations at the start of the transaction of the
|
|
10
|
+
# root version it reifies. A window starting just after a child saved its
|
|
11
|
+
# part of such a transaction therefore starts from the state before that
|
|
12
|
+
# child's change, and one ending there ends at it too, so the change belonged
|
|
13
|
+
# to no window and surfaced at the next transaction, under its author.
|
|
14
|
+
#
|
|
15
|
+
# Only the root's own versions on either side of an edge are consulted:
|
|
16
|
+
# rows locked by an update keep two transactions of one root from
|
|
17
|
+
# interleaving, so a transaction holding a root version and crossing the edge
|
|
18
|
+
# holds the root's nearest version on one side of it. Each edge is moved from
|
|
19
|
+
# where it was asked for, never from where another edge moved, so adjacent
|
|
20
|
+
# windows move their shared edge alike.
|
|
21
|
+
module TransactionCut
|
|
22
|
+
# One edge of a window. A strict edge keeps versions recorded at its
|
|
23
|
+
# instant on the left, as an inclusive end does. A plain class rather than
|
|
24
|
+
# Data.define, which arrived in Ruby 3.2 while this gem supports 3.1.
|
|
25
|
+
class Edge
|
|
26
|
+
#: (Time, bool) -> void
|
|
27
|
+
def initialize(time, strict)
|
|
28
|
+
@time = time
|
|
29
|
+
@strict = strict
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
#: (untyped) -> untyped
|
|
34
|
+
def left(column)
|
|
35
|
+
@strict ? column.lteq(@time) : column.lt(@time)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
#: (untyped) -> untyped
|
|
39
|
+
def right(column)
|
|
40
|
+
@strict ? column.gt(@time) : column.gteq(@time)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: (untyped, later: bool) -> untyped
|
|
44
|
+
def side(column, later:)
|
|
45
|
+
later ? right(column) : left(column)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @rbs @time: Time
|
|
49
|
+
# @rbs @strict: bool
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Roots per statement: two lookups each stays under SQLite's limit of 500
|
|
53
|
+
# terms in a compound SELECT.
|
|
54
|
+
SLICE = 200
|
|
55
|
+
private_constant :SLICE
|
|
56
|
+
|
|
57
|
+
# The same as `windows` for a batch of roots, keyed by identity.
|
|
58
|
+
#: (Array[untyped], TimeRange?) -> Hash[Array[String], TimeRange]
|
|
59
|
+
def self.batch_windows(records, range)
|
|
60
|
+
return {} unless range
|
|
61
|
+
|
|
62
|
+
moved = {} #: Hash[Array[String], TimeRange]
|
|
63
|
+
records.group_by(&:class).each_with_object(moved) do |(model_class, group), by_identity|
|
|
64
|
+
identities = group.to_h { |record| [record.id.to_s, Endpoint.identity(record)] }
|
|
65
|
+
windows(model_class, group, range).each do |id, window|
|
|
66
|
+
by_identity[identities.fetch(id)] = window
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Where history for a batch must be read from: the earliest moved start.
|
|
72
|
+
#: (TimeRange?, Hash[Array[String], TimeRange]) -> Time?
|
|
73
|
+
def self.earliest_start(range, windows)
|
|
74
|
+
return unless range
|
|
75
|
+
|
|
76
|
+
[range.begin_time, *windows.each_value.map(&:begin_time)].min
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The windows that move, keyed by root id. A root missing from the result
|
|
80
|
+
# keeps the window as asked.
|
|
81
|
+
#: (untyped, Array[untyped], TimeRange) -> Hash[String, TimeRange]
|
|
82
|
+
def self.windows(model_class, records, range)
|
|
83
|
+
versions = transaction_versions(model_class)
|
|
84
|
+
return {} unless versions
|
|
85
|
+
|
|
86
|
+
starts = moved_edges(versions, records, Edge.new(range.begin_time, false))
|
|
87
|
+
ends = moved_edges(versions, records, Edge.new(range.end_time, !range.exclude_end?))
|
|
88
|
+
(starts.keys | ends.keys).to_h { |id| [id, moved(range, starts[id], ends[id])] }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
#: (untyped) -> untyped
|
|
92
|
+
def self.transaction_versions(model_class)
|
|
93
|
+
return unless model_class.paper_trail.version_class.column_names.include?('transaction_id')
|
|
94
|
+
|
|
95
|
+
VersionQuery.for_model(model_class)
|
|
96
|
+
end
|
|
97
|
+
private_class_method :transaction_versions
|
|
98
|
+
|
|
99
|
+
# Each root's earliest start among its transactions that cross the edge.
|
|
100
|
+
#: (untyped, Array[untyped], Edge) -> Hash[String, Time]
|
|
101
|
+
def self.moved_edges(versions, records, edge)
|
|
102
|
+
roots = nearest_roots(versions, records, edge)
|
|
103
|
+
moved = {} #: Hash[String, Time]
|
|
104
|
+
starts = crossing_starts(versions.klass, roots.keys, edge)
|
|
105
|
+
starts.each_with_object(moved) do |(transaction, start), edges|
|
|
106
|
+
roots.fetch(transaction).each { |id| edges[id] = [edges[id], start].compact.min }
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
private_class_method :moved_edges
|
|
110
|
+
|
|
111
|
+
# The roots whose versions next to the edge each transaction holds.
|
|
112
|
+
#: (untyped, Array[untyped], Edge) -> Hash[untyped, Array[String]]
|
|
113
|
+
def self.nearest_roots(versions, records, edge)
|
|
114
|
+
records.each_slice(SLICE).flat_map { |slice| nearest(versions, slice, edge) }
|
|
115
|
+
.group_by(&:last).transform_values { |pairs| pairs.map(&:first) }
|
|
116
|
+
end
|
|
117
|
+
private_class_method :nearest_roots
|
|
118
|
+
|
|
119
|
+
# Each root's nearest versions on either side of an edge, as [id,
|
|
120
|
+
# transaction] pairs; versions tied at the nearest instant are all kept.
|
|
121
|
+
# One indexed lookup per root and side, where a single query over the
|
|
122
|
+
# roots would read every version before the edge. The lookups are written
|
|
123
|
+
# once with the root's id left open, as building a relation for each root
|
|
124
|
+
# costs more than the query. Rows read as SQL text come back as the adapter
|
|
125
|
+
# returns them, so the transaction is cast as the grouped query's keys are.
|
|
126
|
+
#: (untyped, Array[untyped], Edge) -> Array[[String, untyped]]
|
|
127
|
+
def self.nearest(versions, records, edge)
|
|
128
|
+
lookup = nearest_lookups(versions, edge)
|
|
129
|
+
item_id = versions.type_for_attribute('item_id')
|
|
130
|
+
lookups = records.map do |record|
|
|
131
|
+
versions.sanitize_sql_array([lookup, *Array.new(4, item_id.serialize(record.id))])
|
|
132
|
+
end
|
|
133
|
+
transaction = versions.type_for_attribute('transaction_id')
|
|
134
|
+
versions.connection.select_rows(lookups.join(' UNION ALL ')).map do |id, value|
|
|
135
|
+
[id.to_s, transaction.cast(value)]
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
private_class_method :nearest
|
|
139
|
+
|
|
140
|
+
# Both sides' lookups for a root whose id is each `?`.
|
|
141
|
+
#: (untyped, Edge) -> String
|
|
142
|
+
def self.nearest_lookups(versions, edge)
|
|
143
|
+
own = versions.where("#{versions.quoted_table_name}.item_id = ?")
|
|
144
|
+
"#{nearest_lookup(own, edge, false)} UNION ALL #{nearest_lookup(own, edge, true)}"
|
|
145
|
+
end
|
|
146
|
+
private_class_method :nearest_lookups
|
|
147
|
+
|
|
148
|
+
#: (untyped, Edge, bool) -> String
|
|
149
|
+
def self.nearest_lookup(own, edge, later)
|
|
150
|
+
column = own.arel_table[:created_at]
|
|
151
|
+
instant = own.where(edge.side(column, later: later))
|
|
152
|
+
.reorder(created_at: later ? :asc : :desc).limit(1).select(:created_at)
|
|
153
|
+
own.where(column.eq(instant.arel)).where.not(transaction_id: nil)
|
|
154
|
+
.select(:item_id, :transaction_id).to_sql
|
|
155
|
+
end
|
|
156
|
+
private_class_method :nearest_lookup
|
|
157
|
+
|
|
158
|
+
# Where each transaction that has versions on both sides of the edge began.
|
|
159
|
+
#: (untyped, Array[untyped], Edge) -> Hash[untyped, Time]
|
|
160
|
+
def self.crossing_starts(version_class, transactions, edge)
|
|
161
|
+
return {} if transactions.empty?
|
|
162
|
+
|
|
163
|
+
column = version_class.arel_table[:created_at]
|
|
164
|
+
scope = version_class.where(transaction_id: transactions)
|
|
165
|
+
scope.where(edge.left(column))
|
|
166
|
+
.where(transaction_id: scope.where(edge.right(column)).select(:transaction_id))
|
|
167
|
+
.group(:transaction_id).minimum(:created_at)
|
|
168
|
+
end
|
|
169
|
+
private_class_method :crossing_starts
|
|
170
|
+
|
|
171
|
+
#: (TimeRange, Time?, Time?) -> TimeRange
|
|
172
|
+
def self.moved(range, start, finish)
|
|
173
|
+
begin_time = start || range.begin_time
|
|
174
|
+
return TimeRange.new(begin_time...[finish, begin_time].max) if finish
|
|
175
|
+
return TimeRange.new(begin_time...range.end_time) if range.exclude_end?
|
|
176
|
+
|
|
177
|
+
TimeRange.new(begin_time..range.end_time)
|
|
178
|
+
end
|
|
179
|
+
private_class_method :moved
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -4,62 +4,167 @@
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
5
|
# Checks that a model can be traversed before any reconstruction starts, and
|
|
6
6
|
# that association tracking is actually available when history is involved.
|
|
7
|
+
# `diagnose` reports the same refusals instead of raising the first.
|
|
7
8
|
class TraversalPreparer
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
# One reason to refuse a request: the error `call` raises, and the code
|
|
10
|
+
# and path `diagnose` reports.
|
|
11
|
+
class Refusal
|
|
12
|
+
attr_reader :error #: untyped
|
|
13
|
+
attr_reader :code #: Symbol
|
|
14
|
+
attr_reader :message #: String
|
|
15
|
+
attr_reader :path #: String?
|
|
16
|
+
|
|
17
|
+
#: (untyped, Symbol, String, ?String?) -> void
|
|
18
|
+
def initialize(error, code, message, path = nil)
|
|
19
|
+
@error = error
|
|
20
|
+
@code = code
|
|
21
|
+
@message = message
|
|
22
|
+
@path = path
|
|
23
|
+
freeze
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
PROBLEMS = {
|
|
28
|
+
unversioned_association_target: '%<model>s is not versioned',
|
|
29
|
+
unversioned_through_model: 'it goes through %<model>s, which is not versioned'
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
# Each model a path's history needs versions of and has none, with the
|
|
33
|
+
# refusal's code: the target, then each model a has_many :through goes
|
|
34
|
+
# through. A polymorphic target is not known until a row names it;
|
|
35
|
+
# `diagnose` reports that separately rather than guessing here.
|
|
36
|
+
#: (untyped) -> Array[[Symbol, untyped]]
|
|
37
|
+
def self.unversioned_models(reflection)
|
|
38
|
+
return [] if reflection.polymorphic?
|
|
39
|
+
|
|
40
|
+
target = [:unversioned_association_target, reflection.klass] #: [Symbol, untyped]
|
|
41
|
+
through = through_models(reflection).map do |model|
|
|
42
|
+
[:unversioned_through_model, model] #: [Symbol, untyped]
|
|
43
|
+
end
|
|
44
|
+
[target, *through].reject { |_code, model| Support.versioned?(model) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# A has_many :through reads past membership from the versions of the
|
|
48
|
+
# models it goes through, so each of them needs history too.
|
|
49
|
+
#: (untyped) -> Array[untyped]
|
|
50
|
+
def self.through_models(reflection)
|
|
51
|
+
return [] unless reflection.through_reflection?
|
|
52
|
+
|
|
53
|
+
[reflection.through_reflection, reflection.source_reflection].flat_map do |part|
|
|
54
|
+
part.polymorphic? ? through_models(part) : [part.klass, *through_models(part)]
|
|
55
|
+
end.uniq - [reflection.klass]
|
|
56
|
+
end
|
|
57
|
+
private_class_method :through_models
|
|
58
|
+
|
|
59
|
+
#: (tree: AssociationTree, traversal: AssociationTraversal, ?allow_approximate: bool) -> void
|
|
60
|
+
def initialize(tree:, traversal:, allow_approximate: false)
|
|
61
|
+
unless [true, false].include?(allow_approximate)
|
|
62
|
+
raise ConfigurationError, 'allow_approximate: must be true or false'
|
|
63
|
+
end
|
|
64
|
+
|
|
10
65
|
@tree = tree
|
|
11
66
|
@traversal = traversal
|
|
67
|
+
@allow_approximate = allow_approximate
|
|
68
|
+
@refusals = {} #: Hash[Array[untyped], Array[Refusal]]
|
|
12
69
|
end
|
|
13
70
|
|
|
14
71
|
#: (untyped, historical: bool) -> void
|
|
15
72
|
def call(model_class, historical:)
|
|
16
|
-
|
|
73
|
+
refusal = refusals(model_class, historical: historical).first
|
|
74
|
+
raise refusal.error, refusal.message if refusal
|
|
75
|
+
end
|
|
17
76
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
77
|
+
# Every reason to refuse a request over the model, in the order `call`
|
|
78
|
+
# raises them. Live-to-live comparison reads current state, so only the
|
|
79
|
+
# traversal itself is checked for it.
|
|
80
|
+
#: (untyped, historical: bool) -> Array[Refusal]
|
|
81
|
+
def refusals(model_class, historical:)
|
|
82
|
+
return [] if @tree.empty?
|
|
83
|
+
|
|
84
|
+
@refusals[[model_class, historical]] ||= collect(model_class, historical).freeze
|
|
21
85
|
end
|
|
22
86
|
|
|
23
87
|
private
|
|
24
88
|
|
|
25
89
|
# @rbs @tree: AssociationTree
|
|
26
90
|
# @rbs @traversal: AssociationTraversal
|
|
91
|
+
# @rbs @allow_approximate: bool
|
|
92
|
+
# @rbs @refusals: Hash[Array[untyped], Array[Refusal]]
|
|
93
|
+
|
|
94
|
+
#: (untyped, bool) -> Array[Refusal]
|
|
95
|
+
def collect(model_class, historical)
|
|
96
|
+
found = [] #: Array[Refusal]
|
|
97
|
+
found << tracking_refusal if historical && !association_tracking?
|
|
98
|
+
unresolved = traversal_refusal(model_class)
|
|
99
|
+
return found << unresolved if unresolved
|
|
100
|
+
return found unless historical
|
|
101
|
+
|
|
102
|
+
found.concat(unversioned_refusals(model_class), approximate_refusals(model_class))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
#: (untyped) -> Refusal?
|
|
106
|
+
def traversal_refusal(model_class)
|
|
107
|
+
@traversal.validate!(model_class)
|
|
108
|
+
nil
|
|
109
|
+
rescue UnknownAssociationError, UnsupportedAssociationError => e
|
|
110
|
+
code = e.is_a?(UnknownAssociationError) ? :unknown_association : :unsupported_association
|
|
111
|
+
Refusal.new(e.class, code, e.message)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# A path prepared history cannot read is read with PT-AT, which rebuilds
|
|
115
|
+
# an association from its current members, swapping in older versions of
|
|
116
|
+
# them. A record that entered the association since, such as one moved
|
|
117
|
+
# into a scope or restored from a soft delete, then reads as a member it
|
|
118
|
+
# was not, and one that left reads as missing. Only a caller who accepts
|
|
119
|
+
# that may read such a path.
|
|
120
|
+
#: (untyped) -> Array[Refusal]
|
|
121
|
+
def approximate_refusals(model_class)
|
|
122
|
+
return [] if @allow_approximate
|
|
123
|
+
|
|
124
|
+
ApproximatePaths.call(@traversal, model_class).map do |path, reason|
|
|
125
|
+
message = "association #{path} cannot be read exactly from history (#{reason}): " \
|
|
126
|
+
'PT-AT rebuilds it from its current members, so a record that entered or ' \
|
|
127
|
+
'left it since is reported wrongly; remove this path, make its scope plain ' \
|
|
128
|
+
'conditions on the target\'s own columns, or pass allow_approximate: true'
|
|
129
|
+
Refusal.new(UnsupportedAssociationError, :approximate_association, message, path)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
27
132
|
|
|
28
133
|
# A model PaperTrail never versioned has no history to reconstruct, so a
|
|
29
134
|
# comparison over it can only ever answer "nothing changed" -- which is a
|
|
30
135
|
# wrong answer rather than an empty one. Live-to-live comparison reads
|
|
31
136
|
# current state and is unaffected, so this applies to historical work only.
|
|
32
|
-
#: (untyped) ->
|
|
33
|
-
def
|
|
34
|
-
@traversal.selected_reflections(model_class).
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
next if versioned?(reflection.klass)
|
|
39
|
-
|
|
40
|
-
raise UnversionedAssociationError,
|
|
41
|
-
"association #{path} cannot be compared historically: " \
|
|
42
|
-
"#{reflection.klass.name} is not versioned. Add `has_paper_trail` to it, " \
|
|
43
|
-
'or mirror the fields you need onto a model that has it.'
|
|
137
|
+
#: (untyped) -> Array[Refusal]
|
|
138
|
+
def unversioned_refusals(model_class)
|
|
139
|
+
@traversal.selected_reflections(model_class).flat_map do |path, reflection|
|
|
140
|
+
self.class.unversioned_models(reflection).map do |code, model|
|
|
141
|
+
unversioned(path, model, code)
|
|
142
|
+
end
|
|
44
143
|
end
|
|
45
144
|
end
|
|
46
145
|
|
|
47
|
-
#: (untyped) ->
|
|
48
|
-
def
|
|
49
|
-
|
|
146
|
+
#: (String, untyped, Symbol) -> Refusal
|
|
147
|
+
def unversioned(path, model_class, code)
|
|
148
|
+
problem = format(PROBLEMS.fetch(code), model: model_class.name)
|
|
149
|
+
message = "association #{path} cannot be compared historically: #{problem}. " \
|
|
150
|
+
"Add `has_paper_trail` to #{model_class.name}, or mirror the fields you need " \
|
|
151
|
+
'onto a model that has it.'
|
|
152
|
+
Refusal.new(UnversionedAssociationError, code, message, path)
|
|
50
153
|
end
|
|
51
154
|
|
|
52
|
-
#: () ->
|
|
53
|
-
def
|
|
155
|
+
#: () -> bool
|
|
156
|
+
def association_tracking?
|
|
54
157
|
paper_trail = Object.const_get(:PaperTrail) #: untyped
|
|
55
158
|
config = paper_trail.config #: untyped
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
return if available
|
|
159
|
+
!!(defined?(::PaperTrailAssociationTracking) &&
|
|
160
|
+
config.respond_to?(:track_associations?) && config.track_associations?)
|
|
161
|
+
end
|
|
60
162
|
|
|
61
|
-
|
|
62
|
-
|
|
163
|
+
#: () -> Refusal
|
|
164
|
+
def tracking_refusal
|
|
165
|
+
Refusal.new(AssociationTrackingUnavailableError, :association_tracking_unavailable,
|
|
166
|
+
'association tracking must be loaded and enabled to compare historical ' \
|
|
167
|
+
'associations')
|
|
63
168
|
end
|
|
64
169
|
end
|
|
65
170
|
end
|
|
@@ -18,13 +18,49 @@ module PaperTrailDiff
|
|
|
18
18
|
return true unless version_record?(boundary)
|
|
19
19
|
return true if same_version?(candidate, boundary)
|
|
20
20
|
|
|
21
|
-
unless orderable_tie?(candidate, boundary)
|
|
21
|
+
unless Support.orderable_tie?(candidate, boundary)
|
|
22
22
|
raise AmbiguousVersionOrderError, Support.ambiguous_message([boundary, candidate])
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
Support.compare_versions(candidate, boundary) >= 0
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# A range starting at a version starts before that version's transaction,
|
|
29
|
+
# as PT-AT reads associations there. A version saved earlier in the same
|
|
30
|
+
# transaction, such as the child update behind a `touch: true` parent
|
|
31
|
+
# version, can be recorded before the start, so the start moves back to
|
|
32
|
+
# the earliest version of each root's first transaction in the range.
|
|
33
|
+
# History loaded from the start alone would read that child after its
|
|
34
|
+
# change.
|
|
35
|
+
#: (Array[untyped], untyped) -> untyped
|
|
36
|
+
def transaction_start(root_versions, start_at)
|
|
37
|
+
opening = opening_transactions(root_versions, start_at)
|
|
38
|
+
return start_at if opening.empty?
|
|
39
|
+
|
|
40
|
+
earliest = root_versions.first.class.where(transaction_id: opening).minimum(:created_at)
|
|
41
|
+
earliest && earliest < start_at ? earliest : start_at
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The transaction of each root's first version at or after the start.
|
|
45
|
+
#: (Array[untyped], untyped) -> Array[untyped]
|
|
46
|
+
def opening_transactions(root_versions, start_at)
|
|
47
|
+
return [] unless start_at
|
|
48
|
+
|
|
49
|
+
root_versions.reject { |version| version.created_at < start_at }
|
|
50
|
+
.sort_by { |version| Support.chronological_version_key(version) }
|
|
51
|
+
.uniq { |version| [version.item_type.to_s, version.item_id.to_s] }
|
|
52
|
+
.filter_map { |version| transaction_id(version) }.uniq
|
|
53
|
+
end
|
|
54
|
+
private_class_method :opening_transactions
|
|
55
|
+
|
|
56
|
+
# A custom version class need not carry the column, and PaperTrail leaves
|
|
57
|
+
# it nil outside a transaction. Both read as "no transaction here", which
|
|
58
|
+
# groups with nothing.
|
|
59
|
+
#: (untyped) -> untyped
|
|
60
|
+
def transaction_id(version)
|
|
61
|
+
version.transaction_id if version.respond_to?(:transaction_id)
|
|
62
|
+
end
|
|
63
|
+
|
|
28
64
|
#: (untyped) -> bool
|
|
29
65
|
def version_record?(value)
|
|
30
66
|
value.respond_to?(:id) && value.respond_to?(:item_type) &&
|
|
@@ -37,12 +73,5 @@ module PaperTrailDiff
|
|
|
37
73
|
left.class.table_name == right.class.table_name && left.id.to_s == right.id.to_s
|
|
38
74
|
end
|
|
39
75
|
private_class_method :same_version?
|
|
40
|
-
|
|
41
|
-
#: (untyped, untyped) -> bool
|
|
42
|
-
def orderable_tie?(left, right)
|
|
43
|
-
left.class.table_name == right.class.table_name &&
|
|
44
|
-
Support.sequential_id?(left.id) && Support.sequential_id?(right.id)
|
|
45
|
-
end
|
|
46
|
-
private_class_method :orderable_tie?
|
|
47
76
|
end
|
|
48
77
|
end
|
|
@@ -14,28 +14,52 @@ module PaperTrailDiff
|
|
|
14
14
|
relation.reorder(created_at: :asc, id: :asc)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
# Every version of a model's items. They are read from the version class
|
|
18
|
+
# rather than through the model's versions association, whose scope may
|
|
19
|
+
# leave some out: a history replayed without them credits their changes to
|
|
20
|
+
# the next version kept.
|
|
21
|
+
#: (untyped) -> untyped
|
|
22
|
+
def for_model(model_class)
|
|
23
|
+
model_class.paper_trail.version_class.where(item_type: model_class.base_class.name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The versions of the given items, read the same way.
|
|
27
|
+
#: (untyped, untyped) -> untyped
|
|
28
|
+
def for_items(model_class, ids)
|
|
29
|
+
for_model(model_class).where(item_id: ids)
|
|
30
|
+
end
|
|
31
|
+
|
|
17
32
|
# Keeps each item's earliest version among those the block also accepts:
|
|
18
33
|
# no other version of the same item that the block accepts is earlier. A
|
|
19
34
|
# correlated NOT EXISTS stays portable where window functions are not.
|
|
20
35
|
#: (untyped) ?{ (untyped) -> untyped } -> untyped
|
|
21
36
|
def no_earlier_version(table, &block)
|
|
37
|
+
no_nearer_version(table, later: true, &block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The same for either side of an instant: each item's earliest version
|
|
41
|
+
# when the versions lie after it, and its latest when they lie before.
|
|
42
|
+
#: (untyped, later: bool) ?{ (untyped) -> untyped } -> untyped
|
|
43
|
+
def no_nearer_version(table, later:, &block)
|
|
22
44
|
arel = Object.const_get(:Arel) #: untyped
|
|
23
|
-
|
|
45
|
+
other = table.alias('paper_trail_diff_earlier_versions')
|
|
24
46
|
arel.const_get(:SelectManager).new
|
|
25
|
-
.from(
|
|
47
|
+
.from(other)
|
|
26
48
|
.project(arel.sql('1'))
|
|
27
|
-
.where(
|
|
49
|
+
.where(nearer_version(table, other, later: later, &block))
|
|
28
50
|
.exists
|
|
29
51
|
.not
|
|
30
52
|
end
|
|
31
53
|
|
|
32
|
-
#: (untyped, untyped) ?{ (untyped) -> untyped } -> untyped
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
#: (untyped, untyped, later: bool) ?{ (untyped) -> untyped } -> untyped
|
|
55
|
+
def nearer_version(table, other, later:)
|
|
56
|
+
created_at = other[:created_at]
|
|
57
|
+
nearer = later ? created_at.lt(table[:created_at]) : created_at.gt(table[:created_at])
|
|
58
|
+
same_item = other[:item_type].eq(table[:item_type])
|
|
59
|
+
.and(other[:item_id].eq(table[:item_id]))
|
|
60
|
+
.and(nearer)
|
|
61
|
+
block_given? ? same_item.and(yield(other)) : same_item
|
|
38
62
|
end
|
|
39
|
-
private_class_method :
|
|
63
|
+
private_class_method :nearer_version
|
|
40
64
|
end
|
|
41
65
|
end
|
|
@@ -7,9 +7,17 @@ module PaperTrailDiff
|
|
|
7
7
|
# caller, because the one that reveals the last selected mutation is drawn
|
|
8
8
|
# from them.
|
|
9
9
|
class VersionScopeFilter
|
|
10
|
+
# A filter is a callable that narrows the version relation.
|
|
11
|
+
#: (untyped) -> untyped
|
|
12
|
+
def self.validated(scope)
|
|
13
|
+
return scope if scope.nil? || scope.respond_to?(:call)
|
|
14
|
+
|
|
15
|
+
raise ConfigurationError, 'version_scope: must respond to call'
|
|
16
|
+
end
|
|
17
|
+
|
|
10
18
|
#: (untyped) -> void
|
|
11
19
|
def initialize(scope)
|
|
12
|
-
@scope = validated(scope)
|
|
20
|
+
@scope = self.class.validated(scope)
|
|
13
21
|
end
|
|
14
22
|
|
|
15
23
|
#: (untyped, Array[untyped]) -> Array[untyped]
|
|
@@ -31,15 +39,6 @@ module PaperTrailDiff
|
|
|
31
39
|
Set.new(scope.call(relation).pluck(:id))
|
|
32
40
|
end
|
|
33
41
|
|
|
34
|
-
private
|
|
35
|
-
|
|
36
42
|
# @rbs @scope: untyped
|
|
37
|
-
|
|
38
|
-
#: (untyped) -> untyped
|
|
39
|
-
def validated(scope)
|
|
40
|
-
return scope if scope.nil? || scope.respond_to?(:call)
|
|
41
|
-
|
|
42
|
-
raise ConfigurationError, 'version_scope: must respond to call'
|
|
43
|
-
end
|
|
44
43
|
end
|
|
45
44
|
end
|