paper_trail_diff 0.9.0 → 0.11.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 +94 -0
- data/README.md +181 -0
- data/lib/paper_trail_diff/activity_boundary.rb +20 -4
- data/lib/paper_trail_diff/activity_grouping.rb +26 -0
- data/lib/paper_trail_diff/activity_timeline_builder.rb +13 -6
- data/lib/paper_trail_diff/activity_transaction_grouper.rb +83 -0
- data/lib/paper_trail_diff/diagnostics.rb +4 -2
- data/lib/paper_trail_diff/errors.rb +8 -0
- data/lib/paper_trail_diff/nested_comparator.rb +108 -0
- data/lib/paper_trail_diff/paper_trail_adapter.rb +31 -12
- data/lib/paper_trail_diff/prepared_history_loader.rb +1 -1
- data/lib/paper_trail_diff/scoped_analysis.rb +31 -0
- data/lib/paper_trail_diff/scoped_root_selection.rb +148 -0
- data/lib/paper_trail_diff/support.rb +13 -0
- data/lib/paper_trail_diff/time_activity_timeline_builder.rb +13 -8
- data/lib/paper_trail_diff/traversal_preparer.rb +25 -0
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff.rb +101 -12
- data/sig/generated/paper_trail_diff/activity_boundary.rbs +13 -2
- data/sig/generated/paper_trail_diff/activity_grouping.rbs +19 -0
- data/sig/generated/paper_trail_diff/activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/activity_transaction_grouper.rbs +54 -0
- data/sig/generated/paper_trail_diff/errors.rbs +10 -0
- data/sig/generated/paper_trail_diff/nested_comparator.rbs +68 -0
- data/sig/generated/paper_trail_diff/paper_trail_adapter.rbs +15 -8
- data/sig/generated/paper_trail_diff/scoped_analysis.rbs +25 -0
- data/sig/generated/paper_trail_diff/scoped_root_selection.rbs +93 -0
- data/sig/generated/paper_trail_diff/support.rbs +11 -0
- data/sig/generated/paper_trail_diff/time_activity_timeline_builder.rbs +4 -2
- data/sig/generated/paper_trail_diff/traversal_preparer.rbs +10 -0
- data/sig/generated/paper_trail_diff.rbs +58 -4
- metadata +14 -4
data/lib/paper_trail_diff.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative 'paper_trail_diff/snapshot_traversal'
|
|
|
24
24
|
require_relative 'paper_trail_diff/association_diff_traversal'
|
|
25
25
|
require_relative 'paper_trail_diff/traversal'
|
|
26
26
|
require_relative 'paper_trail_diff/collection_comparator'
|
|
27
|
+
require_relative 'paper_trail_diff/nested_comparator'
|
|
27
28
|
require_relative 'paper_trail_diff/engine'
|
|
28
29
|
require_relative 'paper_trail_diff/historical_association_reifier'
|
|
29
30
|
require_relative 'paper_trail_diff/prepared_record_index'
|
|
@@ -59,10 +60,14 @@ require_relative 'paper_trail_diff/activity_event_snapshot_refresher'
|
|
|
59
60
|
require_relative 'paper_trail_diff/activity_root_snapshot_refresher'
|
|
60
61
|
require_relative 'paper_trail_diff/branch_snapshot_refresher'
|
|
61
62
|
require_relative 'paper_trail_diff/activity_boundary'
|
|
63
|
+
require_relative 'paper_trail_diff/activity_grouping'
|
|
64
|
+
require_relative 'paper_trail_diff/activity_transaction_grouper'
|
|
62
65
|
require_relative 'paper_trail_diff/step'
|
|
63
66
|
require_relative 'paper_trail_diff/analysis'
|
|
64
67
|
require_relative 'paper_trail_diff/activity_root_steps'
|
|
65
68
|
require_relative 'paper_trail_diff/analysis_batch'
|
|
69
|
+
require_relative 'paper_trail_diff/scoped_analysis'
|
|
70
|
+
require_relative 'paper_trail_diff/scoped_root_selection'
|
|
66
71
|
require_relative 'paper_trail_diff/batched_root_analyzer'
|
|
67
72
|
require_relative 'paper_trail_diff/version_range'
|
|
68
73
|
require_relative 'paper_trail_diff/version_sequence_diagnostics'
|
|
@@ -181,7 +186,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
181
186
|
reload_live_endpoints: true,
|
|
182
187
|
version_scope: nil,
|
|
183
188
|
close_on: nil,
|
|
184
|
-
snapshots: false
|
|
189
|
+
snapshots: false,
|
|
190
|
+
group: nil
|
|
185
191
|
)
|
|
186
192
|
PaperTrailAdapter.new(
|
|
187
193
|
associations: associations,
|
|
@@ -194,12 +200,13 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
194
200
|
within: within,
|
|
195
201
|
version_scope: version_scope,
|
|
196
202
|
close_on: close_on,
|
|
203
|
+
group: group,
|
|
197
204
|
snapshots: snapshots
|
|
198
205
|
)
|
|
199
206
|
end
|
|
200
207
|
|
|
201
208
|
# Builds an endpoint diff and root-checkpoint timeline while normalizing each version once.
|
|
202
|
-
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
209
|
+
#: (untyped, ?from: untyped, ?to: untyped, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
203
210
|
def analyze( # rubocop:disable Metrics/ParameterLists
|
|
204
211
|
record,
|
|
205
212
|
from: nil,
|
|
@@ -210,7 +217,8 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
210
217
|
activity: false,
|
|
211
218
|
version_scope: nil,
|
|
212
219
|
close_on: nil,
|
|
213
|
-
snapshots: false
|
|
220
|
+
snapshots: false,
|
|
221
|
+
group: nil
|
|
214
222
|
)
|
|
215
223
|
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze(
|
|
216
224
|
record,
|
|
@@ -220,16 +228,25 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
220
228
|
activity: activity,
|
|
221
229
|
version_scope: version_scope,
|
|
222
230
|
close_on: close_on,
|
|
223
|
-
snapshots: snapshots
|
|
231
|
+
snapshots: snapshots,
|
|
232
|
+
group: group
|
|
224
233
|
)
|
|
225
234
|
end
|
|
226
235
|
|
|
227
236
|
# Analyzes many roots over one shared time window, preparing their selected
|
|
228
237
|
# history once for the batch instead of once per record. Roots with no
|
|
229
238
|
# versions in the window return an empty `Analysis`.
|
|
230
|
-
|
|
239
|
+
#
|
|
240
|
+
# Pass `records` to analyze a list you assembled, which returns a Hash keyed
|
|
241
|
+
# by identity. Pass `scope:` with a `limit:` to have the roots selected for
|
|
242
|
+
# you from a relation, which returns a `ScopedAnalysis` -- the same Hash,
|
|
243
|
+
# plus the roots the relation could not reach. See `analyze_scope` for why
|
|
244
|
+
# that second collection exists.
|
|
245
|
+
#: (?Array[untyped]?, ?scope: untyped, ?limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> (Hash[identity, Analysis] | ScopedAnalysis)
|
|
231
246
|
def analyze_many( # rubocop:disable Metrics/ParameterLists
|
|
232
|
-
records,
|
|
247
|
+
records = nil,
|
|
248
|
+
scope: nil,
|
|
249
|
+
limit: nil,
|
|
233
250
|
within: nil,
|
|
234
251
|
associations: [],
|
|
235
252
|
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
@@ -237,12 +254,57 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
237
254
|
version_scope: nil,
|
|
238
255
|
close_on: nil
|
|
239
256
|
)
|
|
240
|
-
PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
257
|
+
adapter = PaperTrailAdapter.new(associations: associations, ignore: ignore)
|
|
258
|
+
if scope
|
|
259
|
+
raise ConfigurationError, 'pass either records or scope:, not both' unless records.nil?
|
|
260
|
+
|
|
261
|
+
return adapter.analyze_scope(scope, limit: limit, within: within, activity: activity,
|
|
262
|
+
version_scope: version_scope, close_on: close_on)
|
|
263
|
+
end
|
|
264
|
+
raise ConfigurationError, 'pass records or scope:' if records.nil?
|
|
265
|
+
|
|
266
|
+
adapter.analyze_many(records, within: within, activity: activity,
|
|
267
|
+
version_scope: version_scope, close_on: close_on)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Analyzes every root the relation reaches whose history moved inside the
|
|
271
|
+
# window, selecting them in a fixed number of queries rather than making the
|
|
272
|
+
# caller rediscover them.
|
|
273
|
+
#
|
|
274
|
+
# `limit:` is required and exceeding it raises. Selection moves into the gem
|
|
275
|
+
# here, so the bound on how much work a page can ask for has to move with
|
|
276
|
+
# it, and a truncated audit report is worse than a refused one.
|
|
277
|
+
#
|
|
278
|
+
# Returns a `ScopedAnalysis`, which destructures:
|
|
279
|
+
#
|
|
280
|
+
# analyses, unreachable = PaperTrailDiff.analyze_scope(
|
|
281
|
+
# Article.where(status: 'published'), within: july, limit: 500
|
|
282
|
+
# )
|
|
283
|
+
#
|
|
284
|
+
# `unreachable` names roots that changed in the window but have no live row
|
|
285
|
+
# left. A relation's conditions are evaluated against the live table, so a
|
|
286
|
+
# destroyed root cannot be tested against them at all -- its history is
|
|
287
|
+
# intact and the state it held at destruction may well have matched. Those
|
|
288
|
+
# roots are reported rather than dropped so that a page auditing deletions
|
|
289
|
+
# is told where to look instead of quietly coming up short.
|
|
290
|
+
#
|
|
291
|
+
# Note also that a relation selects on current state, not on state during
|
|
292
|
+
# the window: `where(status: 'published')` means published *now*, which is a
|
|
293
|
+
# different set from what was published while the window was open.
|
|
294
|
+
#: (untyped, limit: Integer?, ?within: untyped, ?associations: Array[String | Symbol], ?ignore: ignore_option, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
295
|
+
def analyze_scope( # rubocop:disable Metrics/ParameterLists
|
|
296
|
+
scope,
|
|
297
|
+
limit:,
|
|
298
|
+
within: nil,
|
|
299
|
+
associations: [],
|
|
300
|
+
ignore: DEFAULT_IGNORED_ATTRIBUTES,
|
|
301
|
+
activity: false,
|
|
302
|
+
version_scope: nil,
|
|
303
|
+
close_on: nil
|
|
304
|
+
)
|
|
305
|
+
PaperTrailAdapter.new(associations: associations, ignore: ignore).analyze_scope(
|
|
306
|
+
scope, limit: limit, within: within, activity: activity,
|
|
307
|
+
version_scope: version_scope, close_on: close_on
|
|
246
308
|
)
|
|
247
309
|
end
|
|
248
310
|
|
|
@@ -263,5 +325,32 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
|
|
|
263
325
|
def diagnose(from_version, to_version, associations: [])
|
|
264
326
|
HistoryDiagnostics.new(from_version, to_version, associations: associations).call
|
|
265
327
|
end
|
|
328
|
+
|
|
329
|
+
# Looks inside an attribute the database stores whole, such as a JSON or
|
|
330
|
+
# jsonb column, and reports which keys changed.
|
|
331
|
+
#
|
|
332
|
+
# change = diff.attributes.fetch('config')
|
|
333
|
+
# PaperTrailDiff.nested_changes(change)
|
|
334
|
+
# # => { ['theme'] => <from "dark" to "light">,
|
|
335
|
+
# # ['limits', 'max'] => <from 10 to 20> }
|
|
336
|
+
#
|
|
337
|
+
# Accepts the `ValueChange` an attribute diff already produced, or a bare
|
|
338
|
+
# pair. Returns an empty hash when the pair is not two readable structures,
|
|
339
|
+
# which is the honest answer: a column that held text on one side and JSON
|
|
340
|
+
# on the other changed wholesale, and the caller still has that change.
|
|
341
|
+
#
|
|
342
|
+
# Paths are arrays because a JSON key may contain a dot. Arrays are reported
|
|
343
|
+
# whole rather than by index, since their elements carry no identity and a
|
|
344
|
+
# list that merely shifted would otherwise look changed throughout. A key
|
|
345
|
+
# that was absent reads as `NestedComparator::ABSENT` rather than nil, which
|
|
346
|
+
# JSON uses for a present null.
|
|
347
|
+
#: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
|
|
348
|
+
def nested_changes(change, to_value = nil)
|
|
349
|
+
# Tested by type rather than by responding to `from`: ActiveSupport gives
|
|
350
|
+
# String#from, so duck-typing here quietly reads a plain string as a pair.
|
|
351
|
+
return NestedComparator.call(change.from, change.to) if change.is_a?(ValueChange)
|
|
352
|
+
|
|
353
|
+
NestedComparator.call(change, to_value)
|
|
354
|
+
end
|
|
266
355
|
end
|
|
267
356
|
end
|
|
@@ -19,6 +19,11 @@ module PaperTrailDiff
|
|
|
19
19
|
|
|
20
20
|
attr_reader record: RecordReference
|
|
21
21
|
|
|
22
|
+
# The transaction the version was written in, when PaperTrail recorded one.
|
|
23
|
+
# Several versions saved together share it, which is what lets a timeline
|
|
24
|
+
# report one save as one step rather than as its parts.
|
|
25
|
+
attr_reader transaction_id: untyped
|
|
26
|
+
|
|
22
27
|
# : (untyped) -> ActivityBoundary
|
|
23
28
|
def self.from_version: (untyped) -> ActivityBoundary
|
|
24
29
|
|
|
@@ -31,8 +36,14 @@ module PaperTrailDiff
|
|
|
31
36
|
# : (untyped) -> ActivityBoundary
|
|
32
37
|
def self.destroyed: (untyped) -> ActivityBoundary
|
|
33
38
|
|
|
34
|
-
#
|
|
35
|
-
|
|
39
|
+
# A custom version class need not carry the column, and PaperTrail leaves
|
|
40
|
+
# it nil outside a transaction. Both read as "no transaction here", which
|
|
41
|
+
# groups with nothing.
|
|
42
|
+
# : (untyped) -> untyped
|
|
43
|
+
def self.transaction_id: (untyped) -> untyped
|
|
44
|
+
|
|
45
|
+
# : (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
|
|
46
|
+
def initialize: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped, ?transaction_id: untyped) -> void
|
|
36
47
|
|
|
37
48
|
# : () -> bool
|
|
38
49
|
def version?: () -> bool
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_grouping.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Shared by the two activity builders. They differ in how they choose
|
|
5
|
+
# boundaries -- one from an explicit version range, one from a wall-clock
|
|
6
|
+
# window -- but a transaction has to collapse the same way in both, or the
|
|
7
|
+
# same history would read differently depending on how it was asked for.
|
|
8
|
+
module ActivityGrouping
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
# : () -> bool
|
|
12
|
+
def grouping?: () -> bool
|
|
13
|
+
|
|
14
|
+
# Applied last, to finished steps, so grouping sees the same timeline the
|
|
15
|
+
# caller would otherwise have received.
|
|
16
|
+
# : (Array[ActivityStep]) -> Array[ActivityStep]
|
|
17
|
+
def group_steps: (Array[ActivityStep]) -> Array[ActivityStep]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Compares adjacent root and selected-descendant activity boundaries.
|
|
5
5
|
class ActivityTimelineBuilder
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include ActivityGrouping
|
|
7
|
+
|
|
8
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
8
10
|
|
|
9
11
|
# : () -> Array[ActivityStep]
|
|
10
12
|
def build: () -> Array[ActivityStep]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/activity_transaction_grouper.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Reports one saved transaction as one activity step.
|
|
5
|
+
#
|
|
6
|
+
# A parent and its children saved together produce a version each, and the
|
|
7
|
+
# timeline reports the gap between every pair of them. One deliberate action
|
|
8
|
+
# therefore arrives as several steps, none of which is the thing the person
|
|
9
|
+
# did.
|
|
10
|
+
#
|
|
11
|
+
# A version records the state before its own event. Two things follow.
|
|
12
|
+
#
|
|
13
|
+
# The change a step reports was made by the event that *opens* it, so a step
|
|
14
|
+
# belongs to the transaction of its `from_boundary`, and consecutive steps
|
|
15
|
+
# sharing one are the parts of a single save. Grouping on the closing boundary
|
|
16
|
+
# instead would credit each change to whoever made the next one.
|
|
17
|
+
#
|
|
18
|
+
# And a child's new value is revealed only by something later -- a further
|
|
19
|
+
# version of that child, its destroy version, or the live row. Inside a
|
|
20
|
+
# transaction there is usually none of those yet, so a step can read as empty
|
|
21
|
+
# while a change was in fact made at that boundary, and the change surfaces
|
|
22
|
+
# later, folded in with whatever that step carried. Grouping puts it back
|
|
23
|
+
# together with the save it belongs to.
|
|
24
|
+
#
|
|
25
|
+
# Merging compares the group's outer snapshots rather than combining the
|
|
26
|
+
# diffs between them. A field set and then restored inside one transaction
|
|
27
|
+
# has not changed, and only a comparison of the endpoints can say so.
|
|
28
|
+
#
|
|
29
|
+
# A boundary with no transaction groups with nothing. PaperTrail leaves the
|
|
30
|
+
# column nil outside a transaction, and a custom version class need not carry
|
|
31
|
+
# it at all; treating those as one shared transaction would merge unrelated
|
|
32
|
+
# history into a single step.
|
|
33
|
+
class ActivityTransactionGrouper
|
|
34
|
+
# : (Array[ActivityStep], retain: bool) -> void
|
|
35
|
+
def initialize: (Array[ActivityStep], retain: bool) -> void
|
|
36
|
+
|
|
37
|
+
# : () -> Array[ActivityStep]
|
|
38
|
+
def call: () -> Array[ActivityStep]
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
@retain: bool
|
|
43
|
+
|
|
44
|
+
@steps: Array[ActivityStep]
|
|
45
|
+
|
|
46
|
+
# : (Array[ActivityStep], ActivityStep) -> bool
|
|
47
|
+
def continues?: (Array[ActivityStep], ActivityStep) -> bool
|
|
48
|
+
|
|
49
|
+
# Rebuilt rather than reused even when a group holds one step, so that every
|
|
50
|
+
# returned step retains its snapshots on the same terms.
|
|
51
|
+
# : (Array[ActivityStep]) -> ActivityStep
|
|
52
|
+
def merge: (Array[ActivityStep]) -> ActivityStep
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -41,10 +41,20 @@ module PaperTrailDiff
|
|
|
41
41
|
class AmbiguousVersionOrderError < Error
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
+
# Raised when a relation selects more roots than the batch was told to analyze.
|
|
45
|
+
# Truncating instead would hand back a report that is short without saying so.
|
|
46
|
+
class BatchLimitExceededError < Error
|
|
47
|
+
end
|
|
48
|
+
|
|
44
49
|
# Raised when a requested ActiveRecord association does not exist.
|
|
45
50
|
class UnknownAssociationError < Error
|
|
46
51
|
end
|
|
47
52
|
|
|
53
|
+
# Raised when a selected association's target is not versioned, so its history
|
|
54
|
+
# cannot be reconstructed at all.
|
|
55
|
+
class UnversionedAssociationError < Error
|
|
56
|
+
end
|
|
57
|
+
|
|
48
58
|
# Raised when a requested association macro is not supported.
|
|
49
59
|
class UnsupportedAssociationError < Error
|
|
50
60
|
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/nested_comparator.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Compares the inside of a value a database column holds whole.
|
|
5
|
+
#
|
|
6
|
+
# A JSON or jsonb column reifies to one Hash, so an ordinary attribute diff
|
|
7
|
+
# can only say that the blob changed. This says which keys changed, leaving
|
|
8
|
+
# the surrounding diff untouched.
|
|
9
|
+
#
|
|
10
|
+
# Three decisions worth knowing about.
|
|
11
|
+
#
|
|
12
|
+
# Paths are arrays, not dotted strings. A JSON key may contain a dot -- host
|
|
13
|
+
# names and locales routinely do -- and joining would make `a.b` ambiguous
|
|
14
|
+
# between one key and two.
|
|
15
|
+
#
|
|
16
|
+
# Arrays are leaves. Their elements carry no identity, so an insertion at the
|
|
17
|
+
# front makes every later index look changed; reporting "element 2 changed"
|
|
18
|
+
# would be confidently wrong about a list that merely shifted. The whole array
|
|
19
|
+
# is reported as one change, which is the same rule the collection comparator
|
|
20
|
+
# follows for records it cannot identify.
|
|
21
|
+
#
|
|
22
|
+
# An absent key is not a null one. `{"a": null}` and `{}` mean different
|
|
23
|
+
# things in JSON and an audit trail that conflated them would be lying about
|
|
24
|
+
# one of them, so absence is its own value rather than nil.
|
|
25
|
+
class NestedComparator
|
|
26
|
+
# Stands in for a key that was not there at all.
|
|
27
|
+
ABSENT: untyped
|
|
28
|
+
|
|
29
|
+
def inspect: () -> untyped
|
|
30
|
+
|
|
31
|
+
def to_s: () -> untyped
|
|
32
|
+
|
|
33
|
+
# : (untyped, untyped) -> Hash[Array[String], ValueChange]
|
|
34
|
+
def self.call: (untyped, untyped) -> Hash[Array[String], ValueChange]
|
|
35
|
+
|
|
36
|
+
# : (untyped, untyped) -> void
|
|
37
|
+
def initialize: (untyped, untyped) -> void
|
|
38
|
+
|
|
39
|
+
# Returns the changed paths, or an empty hash when the pair is not two
|
|
40
|
+
# structures this can look inside. An empty result therefore means "nothing
|
|
41
|
+
# to report at this depth", and the caller still has the whole-value change.
|
|
42
|
+
# : () -> Hash[Array[String], ValueChange]
|
|
43
|
+
def call: () -> Hash[Array[String], ValueChange]
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
@from_value: untyped
|
|
48
|
+
|
|
49
|
+
@to_value: untyped
|
|
50
|
+
|
|
51
|
+
# Both sides have to be readable as a Hash for a nested answer to mean
|
|
52
|
+
# anything. A column that held text on one side and JSON on the other
|
|
53
|
+
# changed wholesale, and saying so is the accurate report.
|
|
54
|
+
# : () -> [Hash[untyped, untyped]?, Hash[untyped, untyped]?]
|
|
55
|
+
def structures: () -> [ Hash[untyped, untyped]?, Hash[untyped, untyped]? ]
|
|
56
|
+
|
|
57
|
+
# : (untyped) -> Hash[untyped, untyped]?
|
|
58
|
+
def structure: (untyped) -> Hash[untyped, untyped]?
|
|
59
|
+
|
|
60
|
+
# : (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
|
|
61
|
+
def walk: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
|
|
62
|
+
|
|
63
|
+
# Sorted so a report reads the same twice, and stringified because a hash
|
|
64
|
+
# loaded from JSON and one built in Ruby can key the same field differently.
|
|
65
|
+
# : (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
66
|
+
def keys: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -22,16 +22,23 @@ module PaperTrailDiff
|
|
|
22
22
|
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
23
23
|
def timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?) -> Array[Step]
|
|
24
24
|
|
|
25
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
26
|
-
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Array[ActivityStep]
|
|
25
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
26
|
+
def activity_timeline: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Array[ActivityStep]
|
|
27
27
|
|
|
28
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
29
|
-
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool) -> Analysis
|
|
28
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
29
|
+
def analyze: (untyped, from: untyped, to: untyped, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
30
30
|
|
|
31
31
|
# Analyzes many roots over one shared range, preparing their history once.
|
|
32
32
|
# : (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
33
33
|
def analyze_many: (Array[untyped], within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> Hash[identity, Analysis]
|
|
34
34
|
|
|
35
|
+
# Selects the roots from a relation before running the ordinary batch, so a
|
|
36
|
+
# caller reporting on a population does not have to rediscover which of its
|
|
37
|
+
# members changed. The roots the relation could not reach come back named
|
|
38
|
+
# rather than dropped -- see `PaperTrailDiff.analyze_scope`.
|
|
39
|
+
# : (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
40
|
+
def analyze_scope: (untyped, limit: Integer?, within: untyped, ?activity: bool, ?version_scope: untyped, ?close_on: Symbol?) -> ScopedAnalysis
|
|
41
|
+
|
|
35
42
|
private
|
|
36
43
|
|
|
37
44
|
@activity_snapshotter: ActivitySnapshotProvider
|
|
@@ -56,8 +63,8 @@ module PaperTrailDiff
|
|
|
56
63
|
|
|
57
64
|
@traversal_preparer: TraversalPreparer
|
|
58
65
|
|
|
59
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
60
|
-
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool) -> Analysis
|
|
66
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
67
|
+
def analyze_activity: (untyped, from: untyped, to: untyped, within: untyped, version_scope: untyped, live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> Analysis
|
|
61
68
|
|
|
62
69
|
# Association membership is resolved by timestamp, so endpoints sharing one
|
|
63
70
|
# cannot be told apart and any association change between them is invisible.
|
|
@@ -89,8 +96,8 @@ module PaperTrailDiff
|
|
|
89
96
|
# : () -> ActivitySnapshotProvider
|
|
90
97
|
def build_activity_snapshotter: () -> ActivitySnapshotProvider
|
|
91
98
|
|
|
92
|
-
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
93
|
-
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool) -> ActivityTimelineBuilder
|
|
99
|
+
# : (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
|
|
100
|
+
def activity_builder: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?live_endpoint: untyped, ?snapshots: bool, ?group: Symbol?) -> ActivityTimelineBuilder
|
|
94
101
|
|
|
95
102
|
# : (untyped) -> void
|
|
96
103
|
def reject_live_habtm_activity!: (untyped) -> void
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/scoped_analysis.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# What the relation form of `analyze_many` returns: the analyses, plus the
|
|
5
|
+
# roots the relation could not reach.
|
|
6
|
+
#
|
|
7
|
+
# `to_ary` is defined so the pair destructures, which keeps the common case
|
|
8
|
+
# reading like the plain Hash the record form returns:
|
|
9
|
+
#
|
|
10
|
+
# analyses, unreachable = PaperTrailDiff.analyze_many(scope: ..., limit: 500)
|
|
11
|
+
#
|
|
12
|
+
# It deliberately does not pretend to be a Hash beyond that. Somebody
|
|
13
|
+
# iterating this object should have to decide which half they meant.
|
|
14
|
+
class ScopedAnalysis
|
|
15
|
+
attr_reader analyses: Hash[Array[String], Analysis]
|
|
16
|
+
|
|
17
|
+
attr_reader unreachable: Array[Array[String]]
|
|
18
|
+
|
|
19
|
+
# : (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
20
|
+
def initialize: (analyses: Hash[Array[String], Analysis], unreachable: Array[Array[String]]) -> void
|
|
21
|
+
|
|
22
|
+
# : () -> [Hash[Array[String], Analysis], Array[Array[String]]]
|
|
23
|
+
def to_ary: () -> [ Hash[Array[String], Analysis], Array[Array[String]] ]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Generated from lib/paper_trail_diff/scoped_root_selection.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module PaperTrailDiff
|
|
4
|
+
# Chooses the roots a batch will analyze from a relation instead of from an
|
|
5
|
+
# array the caller assembled, so a reporting page does not reimplement the
|
|
6
|
+
# root selection this gem already performs.
|
|
7
|
+
#
|
|
8
|
+
# Two populations are deliberately kept apart, because conflating them would
|
|
9
|
+
# make the report wrong in a way nothing announces:
|
|
10
|
+
#
|
|
11
|
+
# - A root whose live row does not satisfy the relation is filtered out. That
|
|
12
|
+
# is what the caller asked for, and it needs no reporting.
|
|
13
|
+
# - A root whose live row is gone cannot be filtered at all. The relation's
|
|
14
|
+
# conditions read the live table, and a destroyed root has nothing there to
|
|
15
|
+
# read -- even though its history is intact, and the state it held when it
|
|
16
|
+
# was destroyed may well have satisfied those conditions. Reifying every
|
|
17
|
+
# candidate to find out would cost the batched query plan this class exists
|
|
18
|
+
# to provide.
|
|
19
|
+
#
|
|
20
|
+
# So the second population is returned by name rather than dropped. A caller
|
|
21
|
+
# that does not care can ignore it; one auditing deletions is told where to
|
|
22
|
+
# look instead of silently coming up short.
|
|
23
|
+
#
|
|
24
|
+
# Note that a relation filters on current state, not on state during the
|
|
25
|
+
# window. `where(status: 'published')` selects what is published now, which is
|
|
26
|
+
# not the same set as what was published while the window was open.
|
|
27
|
+
class ScopedRootSelection
|
|
28
|
+
# A plain class rather than Data.define, which arrived in Ruby 3.2 while this
|
|
29
|
+
# gem supports 3.1.
|
|
30
|
+
class Result
|
|
31
|
+
attr_reader records: Array[untyped]
|
|
32
|
+
|
|
33
|
+
attr_reader unreachable: Array[Array[String]]
|
|
34
|
+
|
|
35
|
+
# : (records: Array[untyped], unreachable: Array[Array[String]]) -> void
|
|
36
|
+
def initialize: (records: Array[untyped], unreachable: Array[Array[String]]) -> void
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# : (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
40
|
+
def initialize: (untyped, time_range: TimeRange?, limit: Integer) -> void
|
|
41
|
+
|
|
42
|
+
# : () -> Result
|
|
43
|
+
def call: () -> Result
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
@limit: Integer
|
|
48
|
+
|
|
49
|
+
@scope: untyped
|
|
50
|
+
|
|
51
|
+
@time_range: TimeRange?
|
|
52
|
+
|
|
53
|
+
# Accepts a model class as readily as a relation: `Article` and
|
|
54
|
+
# `Article.where(...)` both name a population, and `all` is what makes them
|
|
55
|
+
# the same kind of thing.
|
|
56
|
+
# : (untyped) -> untyped
|
|
57
|
+
def normalize_scope: (untyped) -> untyped
|
|
58
|
+
|
|
59
|
+
# : (Integer) -> Integer
|
|
60
|
+
def validate_limit: (Integer) -> Integer
|
|
61
|
+
|
|
62
|
+
# Every root whose history moved inside the window, destroyed ones included.
|
|
63
|
+
# This is the gem's own notion of "which roots changed", answered from the
|
|
64
|
+
# version table alone so that it does not depend on rows still existing.
|
|
65
|
+
# : () -> Array[String]
|
|
66
|
+
def versioned_ids: () -> Array[String]
|
|
67
|
+
|
|
68
|
+
# Which of those candidates still have a row, asked without the relation's
|
|
69
|
+
# conditions so that "filtered out" and "no longer exists" stay separable.
|
|
70
|
+
# : (Array[String]) -> Array[String]
|
|
71
|
+
def live_ids: (Array[String]) -> Array[String]
|
|
72
|
+
|
|
73
|
+
# Loading one past the limit is what turns an oversized page into an error
|
|
74
|
+
# rather than a silently truncated report.
|
|
75
|
+
# : (Array[String]) -> Array[untyped]
|
|
76
|
+
def selected_records: (Array[String]) -> Array[untyped]
|
|
77
|
+
|
|
78
|
+
# : (String) -> Array[String]
|
|
79
|
+
def identity: (String) -> Array[String]
|
|
80
|
+
|
|
81
|
+
# : () -> String
|
|
82
|
+
def item_type: () -> String
|
|
83
|
+
|
|
84
|
+
# : () -> untyped
|
|
85
|
+
def base_class: () -> untyped
|
|
86
|
+
|
|
87
|
+
# : () -> untyped
|
|
88
|
+
def primary_key: () -> untyped
|
|
89
|
+
|
|
90
|
+
# : () -> untyped
|
|
91
|
+
def version_class: () -> untyped
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -32,6 +32,17 @@ module PaperTrailDiff
|
|
|
32
32
|
# : (Array[untyped]) -> Array[untyped]?
|
|
33
33
|
def self?.tied_timestamp_pair: (Array[untyped]) -> Array[untyped]?
|
|
34
34
|
|
|
35
|
+
# Whether a model records history at all.
|
|
36
|
+
#
|
|
37
|
+
# PaperTrail defines `paper_trail` on every ActiveRecord model, so asking
|
|
38
|
+
# whether a class responds to it says nothing -- it is true for models that
|
|
39
|
+
# never called `has_paper_trail`, and reading history from one of those fails
|
|
40
|
+
# at the version class rather than at the question. Only configured options
|
|
41
|
+
# distinguish the two, which is why this lives in one place: the predicate is
|
|
42
|
+
# easy to write in a form that looks right and always answers true.
|
|
43
|
+
# : (untyped) -> bool
|
|
44
|
+
def self?.versioned?: (untyped) -> bool
|
|
45
|
+
|
|
35
46
|
# : (untyped) -> bool
|
|
36
47
|
def self?.sequential_id?: (untyped) -> bool
|
|
37
48
|
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
4
|
# Builds activity views for mutations selected by a wall-clock range.
|
|
5
5
|
class TimeActivityTimelineBuilder
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
include ActivityGrouping
|
|
7
|
+
|
|
8
|
+
# : (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
9
|
+
def initialize: (untyped, range: TimelineRange, tree: AssociationTree, snapshotter: untyped, ?snapshots: bool, ?group: Symbol?) -> void
|
|
8
10
|
|
|
9
11
|
# : () -> Array[ActivityStep]
|
|
10
12
|
def build: () -> Array[ActivityStep]
|
|
@@ -16,6 +16,16 @@ module PaperTrailDiff
|
|
|
16
16
|
|
|
17
17
|
@tree: AssociationTree
|
|
18
18
|
|
|
19
|
+
# A model PaperTrail never versioned has no history to reconstruct, so a
|
|
20
|
+
# comparison over it can only ever answer "nothing changed" -- which is a
|
|
21
|
+
# wrong answer rather than an empty one. Live-to-live comparison reads
|
|
22
|
+
# current state and is unaffected, so this applies to historical work only.
|
|
23
|
+
# : (untyped) -> void
|
|
24
|
+
def ensure_versioned_targets!: (untyped) -> void
|
|
25
|
+
|
|
26
|
+
# : (untyped) -> bool
|
|
27
|
+
def versioned?: (untyped) -> bool
|
|
28
|
+
|
|
19
29
|
# : () -> void
|
|
20
30
|
def ensure_association_tracking!: () -> void
|
|
21
31
|
end
|