paper_trail_diff 0.13.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 +31 -0
- data/docs/testing.md +31 -8
- data/lib/paper_trail_diff/batched_root_versions.rb +1 -1
- 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 +3 -2
- data/lib/paper_trail_diff/historical_filter.rb +12 -17
- data/lib/paper_trail_diff/root_version_plan.rb +10 -1
- data/lib/paper_trail_diff/snapshot.rb +5 -1
- data/lib/paper_trail_diff/snapshot_normalizer.rb +42 -46
- data/lib/paper_trail_diff/time_range.rb +1 -2
- data/lib/paper_trail_diff/time_version_range.rb +1 -1
- data/lib/paper_trail_diff/timeline_range.rb +5 -4
- data/lib/paper_trail_diff/version.rb +1 -1
- data/lib/paper_trail_diff/version_query.rb +9 -1
- 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/historical_filter.rbs +6 -6
- data/sig/generated/paper_trail_diff/root_version_plan.rbs +7 -0
- data/sig/generated/paper_trail_diff/snapshot_normalizer.rbs +19 -9
- data/sig/generated/paper_trail_diff/timeline_range.rbs +2 -1
- data/sig/generated/paper_trail_diff/version_query.rbs +7 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dfe7bccfe2f013af725d6ac92c772d357127d5a0c14cae72fac0000cb58fad62
|
|
4
|
+
data.tar.gz: 308cc912a32875582ff8e083ec52016d4ca586ee75f3b0e36172360d2600b01d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e948b33a959fc8ffdd00d7468819917f126b961c3daa4548f38260315dabab6fa36cddd9019b171c6aad306eae2b09485568591d5f2f07d42ba9a661e93bfd32
|
|
7
|
+
data.tar.gz: 2ea2cae4b3147f1c4655e79d2ebe2a68202e991f61227718990ec9dcdce38e631f0122ae6fe510aac217ae15f54c1e223095e28148c08944614698a73c3c42dd
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,37 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file. The
|
|
4
4
|
project follows [Semantic Versioning](https://semver.org/).
|
|
5
5
|
|
|
6
|
+
## [0.13.1] - 2026-10-28
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- A collection transition whose before and after records have different
|
|
11
|
+
identities reports the before record as removed and the after record as
|
|
12
|
+
added. It used to report nothing. No current caller builds one, but
|
|
13
|
+
`AssociationSnapshot#transition_to` accepts it.
|
|
14
|
+
- A record with a composite primary key, read from prepared history, gets the
|
|
15
|
+
same id as when it is read live: the key's values. It was `nil`.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `RecordSnapshot.new` requires `type:` to be a String and raises
|
|
20
|
+
`ArgumentError` otherwise. It used to convert any value with `to_s`.
|
|
21
|
+
|
|
22
|
+
### Development
|
|
23
|
+
|
|
24
|
+
- Mutation testing gives each mutation 60 seconds instead of mutant's 5-second
|
|
25
|
+
default. Mutant counts a timeout as a kill, so mutations no spec caught were
|
|
26
|
+
reported as caught. With the longer limit, 123 surfaced in
|
|
27
|
+
`CollectionComparator` and `CollectionIdentityIndex`; each class now has a
|
|
28
|
+
unit spec, and all of them are caught.
|
|
29
|
+
- Mutation testing also covers `SnapshotNormalizer`, `SnapshotPool`,
|
|
30
|
+
`TimelineRange`, `RootVersionPlan`, and `HistoricalFilter`, each with a unit
|
|
31
|
+
spec of its own; all 4,040 mutations across the nine classes are caught.
|
|
32
|
+
Versions are ordered by time and then id through one shared helper.
|
|
33
|
+
- The full mutation run moves to a nightly workflow that runs only when `main`
|
|
34
|
+
has an untested commit, so a later push no longer cancels it. Pull requests
|
|
35
|
+
still mutate the methods they change.
|
|
36
|
+
|
|
6
37
|
## [0.13.0] - 2026-10-28
|
|
7
38
|
|
|
8
39
|
### Breaking changes
|
data/docs/testing.md
CHANGED
|
@@ -39,12 +39,35 @@ variant here when a bug turns out to depend on a schema or configuration.
|
|
|
39
39
|
|
|
40
40
|
## Mutation testing
|
|
41
41
|
|
|
42
|
-
[Mutant](https://github.com/mbj/mutant) changes the
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
[Mutant](https://github.com/mbj/mutant) changes the library one expression at
|
|
43
|
+
a time and expects a spec to fail each time. A change that every spec still
|
|
44
|
+
passes is behavior nothing checks. `mutant.yml` lists the classes it mutates:
|
|
45
|
+
|
|
46
|
+
- what counts as a change: `Engine`, `CollectionComparator`,
|
|
47
|
+
`CollectionIdentityIndex`, and `NestedComparator`;
|
|
48
|
+
- which columns are compared: `SnapshotNormalizer` and `SnapshotPool`;
|
|
49
|
+
- which history a range or filter reads: `TimelineRange`, `RootVersionPlan`,
|
|
50
|
+
and `HistoricalFilter`.
|
|
51
|
+
|
|
52
|
+
The specs for the classes that read the database use tables of their own
|
|
53
|
+
(`spec/support/filter_database.rb`, `spec/support/normalizer_database.rb`), so
|
|
54
|
+
they run in both the core and the association suite.
|
|
55
|
+
|
|
56
|
+
Mutant does not run every spec for every mutation. It runs the specs whose
|
|
57
|
+
description names the mutated class (`RSpec.describe PaperTrailDiff::Engine`),
|
|
58
|
+
and only falls back to wider names such as `PaperTrailDiff` when none does. A
|
|
59
|
+
spec named after a behavior, as most integration specs are, never runs. So
|
|
60
|
+
each mutated class has a unit spec of its own that catches every mutation in
|
|
61
|
+
it, and a class added to `mutant.yml` needs one too.
|
|
62
|
+
|
|
63
|
+
Mutant counts a mutation that runs past its time limit as caught, so
|
|
64
|
+
`mutant.yml` sets the limit well above the time the whole suite takes. With
|
|
65
|
+
mutant's 5-second default, mutations no spec caught were reported as caught.
|
|
66
|
+
|
|
67
|
+
In CI, a pull request mutates only the methods it changes. The full set runs
|
|
68
|
+
nightly in `.github/workflows/mutation.yml`, and only when `main` has a commit
|
|
69
|
+
that has not passed yet. Start it by hand from the Actions tab with "Run
|
|
70
|
+
workflow".
|
|
48
71
|
|
|
49
72
|
Mutant needs Ruby 3.3 or newer, so it has its own bundle:
|
|
50
73
|
|
|
@@ -63,8 +86,8 @@ BUNDLE_GEMFILE=gemfiles/mutant.gemfile mise exec -- bundle exec mutant run --sin
|
|
|
63
86
|
|
|
64
87
|
When a mutation survives, either add the spec that would catch it, or, if the
|
|
65
88
|
mutated code means the same thing, simplify the code to that form. Reserve a
|
|
66
|
-
`mutant:disable` comment for a method whose mutations all mean the
|
|
67
|
-
and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
|
|
89
|
+
`mutant:disable` comment for a method whose surviving mutations all mean the
|
|
90
|
+
same thing as the code, and say why next to it. Mutant forks a worker per CPU; `spec/support/mutant_hooks.rb`
|
|
68
91
|
gives each worker its own copy of the SQLite fixture database.
|
|
69
92
|
|
|
70
93
|
## Test clock
|
|
@@ -28,25 +28,21 @@ module PaperTrailDiff
|
|
|
28
28
|
# @rbs @to: AssociationSnapshot
|
|
29
29
|
# @rbs @record_comparer: untyped
|
|
30
30
|
|
|
31
|
+
# A transition names the one record that changed between adjacent
|
|
32
|
+
# snapshots. With matching identities it is an update; otherwise the
|
|
33
|
+
# before record left and the after record joined.
|
|
31
34
|
#: (CollectionTransition) -> CollectionAssociationDiff
|
|
32
35
|
def transition_difference(transition)
|
|
33
36
|
@from.validate_unique_identities!
|
|
34
37
|
@to.validate_unique_identities!
|
|
35
38
|
before = transition.before
|
|
36
39
|
after = transition.after
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
added:
|
|
40
|
-
|
|
41
|
-
changed: changed ? [changed] : []
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
#: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
|
|
46
|
-
def compare_transition_records(before, after)
|
|
47
|
-
return unless before && after && before.identity == after.identity
|
|
40
|
+
if before && after && before.identity == after.identity
|
|
41
|
+
change = @record_comparer.call(before, after)
|
|
42
|
+
return difference(added: [], removed: [], changed: change ? [change] : [])
|
|
43
|
+
end
|
|
48
44
|
|
|
49
|
-
|
|
45
|
+
difference(added: [after].compact, removed: [before].compact, changed: [])
|
|
50
46
|
end
|
|
51
47
|
|
|
52
48
|
#: () -> Array[RecordChange]?
|
|
@@ -70,17 +66,16 @@ module PaperTrailDiff
|
|
|
70
66
|
|
|
71
67
|
#: (Array[RecordSnapshot], Array[RecordSnapshot]) -> Array[[RecordSnapshot, RecordSnapshot]]?
|
|
72
68
|
def collect_aligned_pairs(from_records, to_records)
|
|
73
|
-
seen =
|
|
69
|
+
seen = Set.new #: Set[identity]
|
|
74
70
|
changed = [] #: Array[[RecordSnapshot, RecordSnapshot]]
|
|
75
71
|
index = 0
|
|
76
72
|
while index < from_records.length
|
|
77
73
|
from_record = from_records.fetch(index)
|
|
78
74
|
to_record = to_records.fetch(index)
|
|
79
75
|
identity = from_record.identity
|
|
80
|
-
raise_duplicate_identity!(identity)
|
|
76
|
+
raise_duplicate_identity!(identity) unless seen.add?(identity)
|
|
81
77
|
return unless identity == to_record.identity
|
|
82
78
|
|
|
83
|
-
seen[identity] = true
|
|
84
79
|
changed << [from_record, to_record] unless from_record.equal?(to_record)
|
|
85
80
|
index += 1
|
|
86
81
|
end
|
|
@@ -148,21 +143,24 @@ module PaperTrailDiff
|
|
|
148
143
|
# result is rendered.
|
|
149
144
|
#: (identity) -> Array[untyped]
|
|
150
145
|
def sortable_identity(identity)
|
|
151
|
-
[identity.fetch(0),
|
|
146
|
+
[identity.fetch(0), sortable_id(identity.fetch(1))]
|
|
152
147
|
end
|
|
153
148
|
|
|
149
|
+
# Only the order of the group numbers matters, so moving the lowest one
|
|
150
|
+
# down or the highest one up changes nothing; the spec checks the order.
|
|
151
|
+
# mutant:disable
|
|
154
152
|
#: (untyped) -> Array[untyped]
|
|
155
153
|
def sortable_id(id)
|
|
156
154
|
case id
|
|
157
|
-
when Numeric then [0, id
|
|
158
|
-
when String, Symbol then [1,
|
|
159
|
-
else [2,
|
|
155
|
+
when Numeric then [0, id]
|
|
156
|
+
when String, Symbol then [1, id.to_s]
|
|
157
|
+
else [2, id.inspect]
|
|
160
158
|
end
|
|
161
159
|
end
|
|
162
160
|
|
|
163
161
|
#: (identity) -> void
|
|
164
162
|
def raise_duplicate_identity!(identity)
|
|
165
|
-
raise ArgumentError, "duplicate record identity: #{identity
|
|
163
|
+
raise ArgumentError, "duplicate record identity: #{identity}"
|
|
166
164
|
end
|
|
167
165
|
end
|
|
168
166
|
end
|
|
@@ -6,19 +6,16 @@ module PaperTrailDiff
|
|
|
6
6
|
class CollectionIdentityIndex
|
|
7
7
|
#: (Array[untyped]) -> void
|
|
8
8
|
def initialize(records)
|
|
9
|
-
@
|
|
9
|
+
@identities = Set.new #: Set[Array[untyped]]
|
|
10
10
|
@serialized_positions = {} #: Hash[Array[String], Integer]
|
|
11
11
|
@id_positions = {} #: Hash[String, Integer?]
|
|
12
12
|
records.each_with_index { |record, index| register(record, index) }
|
|
13
|
-
@positions.freeze
|
|
14
|
-
@serialized_positions.freeze
|
|
15
|
-
@id_positions.freeze
|
|
16
13
|
freeze
|
|
17
14
|
end
|
|
18
15
|
|
|
19
|
-
#: (
|
|
16
|
+
#: (String, untyped) -> Integer?
|
|
20
17
|
def position(type, id)
|
|
21
|
-
@serialized_positions[[type
|
|
18
|
+
@serialized_positions[[type, id.to_s]]
|
|
22
19
|
end
|
|
23
20
|
|
|
24
21
|
#: (untyped) -> Integer?
|
|
@@ -28,26 +25,25 @@ module PaperTrailDiff
|
|
|
28
25
|
|
|
29
26
|
private
|
|
30
27
|
|
|
31
|
-
# @rbs @
|
|
28
|
+
# @rbs @identities: Set[Array[untyped]]
|
|
32
29
|
# @rbs @serialized_positions: Hash[Array[String], Integer]
|
|
33
30
|
# @rbs @id_positions: Hash[String, Integer?]
|
|
34
31
|
|
|
35
32
|
#: (untyped, Integer) -> void
|
|
36
33
|
def register(record, index)
|
|
37
34
|
identity = record.identity
|
|
38
|
-
|
|
39
|
-
raise ArgumentError, "duplicate record identity: #{identity
|
|
35
|
+
unless @identities.add?(identity)
|
|
36
|
+
raise ArgumentError, "duplicate record identity: #{identity}"
|
|
40
37
|
end
|
|
41
38
|
|
|
42
|
-
@
|
|
43
|
-
@serialized_positions[[record.type.to_s, record.id.to_s]] = index
|
|
39
|
+
@serialized_positions[[record.type, record.id.to_s]] = index
|
|
44
40
|
register_id(record.id, index)
|
|
45
41
|
end
|
|
46
42
|
|
|
47
43
|
#: (untyped, Integer) -> void
|
|
48
44
|
def register_id(id, index)
|
|
49
45
|
key = id.to_s
|
|
50
|
-
@id_positions[key] = @id_positions.key?(key)
|
|
46
|
+
@id_positions[key] = (index unless @id_positions.key?(key))
|
|
51
47
|
end
|
|
52
48
|
end
|
|
53
49
|
end
|
|
@@ -64,9 +64,10 @@ module PaperTrailDiff
|
|
|
64
64
|
|
|
65
65
|
#: () -> Array[untyped]
|
|
66
66
|
def destroy_versions
|
|
67
|
-
|
|
67
|
+
destroys = @model.paper_trail.version_class.where(
|
|
68
68
|
item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
|
|
69
|
-
)
|
|
69
|
+
)
|
|
70
|
+
versions = VersionQuery.chronological(destroys)
|
|
70
71
|
versions.group_by { |version| version.item_id.to_s }.values.map do |history|
|
|
71
72
|
Support.chronological_sort(history).last
|
|
72
73
|
end
|
|
@@ -69,7 +69,7 @@ module PaperTrailDiff
|
|
|
69
69
|
|
|
70
70
|
#: (untyped) -> Integer
|
|
71
71
|
def validate_budget(value)
|
|
72
|
-
return value if value.
|
|
72
|
+
return value if value.instance_of?(Integer) && value.positive?
|
|
73
73
|
|
|
74
74
|
raise ConfigurationError, 'max_filter_versions: must be a positive Integer'
|
|
75
75
|
end
|
|
@@ -80,13 +80,13 @@ module PaperTrailDiff
|
|
|
80
80
|
item_id: ids)
|
|
81
81
|
range = @time_range
|
|
82
82
|
relation = range.scope(relation) if range
|
|
83
|
-
|
|
84
|
-
|
|
83
|
+
# One past the remaining budget is enough to know it was exceeded.
|
|
84
|
+
versions = VersionQuery.chronological(relation).limit(@max_versions - @loaded + 1)
|
|
85
85
|
account_versions!(versions.length)
|
|
86
86
|
versions.group_by { |version| version.item_id.to_s }
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
-
#: (Array[untyped], untyped) ->
|
|
89
|
+
#: (Array[untyped], untyped) -> boolish
|
|
90
90
|
def held?(versions, closing)
|
|
91
91
|
return true if versions.any? { |version| matches?(version) }
|
|
92
92
|
return false unless closing
|
|
@@ -97,9 +97,7 @@ module PaperTrailDiff
|
|
|
97
97
|
# What each candidate held after its last change in the window.
|
|
98
98
|
#: (Array[String], Hash[String, Array[untyped]]) -> Hash[String, untyped]
|
|
99
99
|
def closing_states(ids, versions)
|
|
100
|
-
open = ids.reject { |id| versions.fetch(id, []).last&.event
|
|
101
|
-
return {} if open.empty?
|
|
102
|
-
|
|
100
|
+
open = ids.reject { |id| versions.fetch(id, []).last&.event == 'destroy' }
|
|
103
101
|
trailing = trailing_versions(open)
|
|
104
102
|
trailing.merge(live_records(open - trailing.keys))
|
|
105
103
|
end
|
|
@@ -118,15 +116,12 @@ module PaperTrailDiff
|
|
|
118
116
|
|
|
119
117
|
#: (Array[String]) -> Hash[String, untyped]
|
|
120
118
|
def live_records(ids)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
@model.base_class.unscoped.where(@model.primary_key => ids).to_a
|
|
124
|
-
.to_h { |record| [record.id.to_s, record] }
|
|
119
|
+
@model.unscoped.where(@model.primary_key => ids).to_h { |record| [record.id.to_s, record] }
|
|
125
120
|
end
|
|
126
121
|
|
|
127
|
-
#: (untyped) ->
|
|
122
|
+
#: (untyped) -> boolish
|
|
128
123
|
def record_matches?(record)
|
|
129
|
-
return
|
|
124
|
+
return @predicate.call(record) unless @predicate.instance_of?(Hash)
|
|
130
125
|
|
|
131
126
|
@predicate.all? { |name, expected| record.attributes.fetch(name) == expected }
|
|
132
127
|
end
|
|
@@ -141,16 +136,16 @@ module PaperTrailDiff
|
|
|
141
136
|
'narrow the window or explicitly increase the version budget'
|
|
142
137
|
end
|
|
143
138
|
|
|
144
|
-
#: (untyped) ->
|
|
139
|
+
#: (untyped) -> boolish
|
|
145
140
|
def matches?(version)
|
|
146
|
-
if @predicate.
|
|
141
|
+
if @predicate.instance_of?(Hash)
|
|
147
142
|
return false unless version.object
|
|
148
143
|
|
|
149
144
|
return attributes_match?(version)
|
|
150
145
|
end
|
|
151
146
|
|
|
152
147
|
state = version.reify(dup: true)
|
|
153
|
-
|
|
148
|
+
state && @predicate.call(state)
|
|
154
149
|
end
|
|
155
150
|
|
|
156
151
|
#: (untyped) -> bool
|
|
@@ -167,7 +162,7 @@ module PaperTrailDiff
|
|
|
167
162
|
"historical_filter: #{name} was not recorded in version #{version.id}"
|
|
168
163
|
end
|
|
169
164
|
|
|
170
|
-
state.
|
|
165
|
+
state.attributes.fetch(name) == expected
|
|
171
166
|
end
|
|
172
167
|
end
|
|
173
168
|
|
|
@@ -72,7 +72,7 @@ module PaperTrailDiff
|
|
|
72
72
|
@steps = steps.freeze
|
|
73
73
|
@context_version = context_version
|
|
74
74
|
@mutations = (mutations || default_mutations).freeze
|
|
75
|
-
@mutation_keys =
|
|
75
|
+
@mutation_keys = key_set(@mutations)
|
|
76
76
|
freeze
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -114,6 +114,15 @@ module PaperTrailDiff
|
|
|
114
114
|
@versions.reject { |version| key(version) == context }
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
+
# A Set only makes the lookup in `mutation?` fast; an Array would answer the
|
|
118
|
+
# same, which is why mutant leaves this alone.
|
|
119
|
+
# mutant:disable
|
|
120
|
+
#: (Array[untyped]) -> Set[Array[untyped]]
|
|
121
|
+
def key_set(versions)
|
|
122
|
+
Set.new(versions.map { |version| key(version) })
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# By class name, so a version still matches after its class is reloaded.
|
|
117
126
|
#: (untyped) -> Array[untyped]
|
|
118
127
|
def key(version)
|
|
119
128
|
[version.class.name, version.id]
|
|
@@ -171,7 +171,11 @@ module PaperTrailDiff
|
|
|
171
171
|
|
|
172
172
|
#: (type: String, id: untyped, attributes: snapshot_attributes, ?associations: snapshot_associations) -> void
|
|
173
173
|
def initialize(type:, id:, attributes:, associations: {})
|
|
174
|
-
|
|
174
|
+
unless type.is_a?(String)
|
|
175
|
+
raise ArgumentError, "record type must be a String, not #{type.inspect}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@type = Support.immutable_copy(type)
|
|
175
179
|
@id = Support.immutable_copy(id)
|
|
176
180
|
@identity = [@type, @id].freeze
|
|
177
181
|
@attributes = normalize_hash(attributes)
|
|
@@ -21,9 +21,7 @@ module PaperTrailDiff
|
|
|
21
21
|
#: (String, AssociationSnapshot) -> AssociationSnapshot
|
|
22
22
|
def association(path, snapshot)
|
|
23
23
|
previous = @associations[path]
|
|
24
|
-
|
|
25
|
-
return previous
|
|
26
|
-
end
|
|
24
|
+
return previous if previous && same_association?(previous, snapshot)
|
|
27
25
|
|
|
28
26
|
@associations[path] = snapshot
|
|
29
27
|
end
|
|
@@ -38,10 +36,14 @@ module PaperTrailDiff
|
|
|
38
36
|
left.attributes == right.attributes &&
|
|
39
37
|
left.associations.keys == right.associations.keys &&
|
|
40
38
|
left.associations.all? do |name, association|
|
|
41
|
-
|
|
42
|
-
other && association.kind == other.kind && association.records == other.records
|
|
39
|
+
same_association?(association, right.associations.fetch(name))
|
|
43
40
|
end
|
|
44
41
|
end
|
|
42
|
+
|
|
43
|
+
#: (AssociationSnapshot, AssociationSnapshot) -> bool
|
|
44
|
+
def same_association?(left, right)
|
|
45
|
+
left.kind == right.kind && left.records == right.records
|
|
46
|
+
end
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
# Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
|
|
@@ -53,11 +55,13 @@ module PaperTrailDiff
|
|
|
53
55
|
@traversal = traversal
|
|
54
56
|
@pool = pool
|
|
55
57
|
@structural_columns = {} #: Hash[String, Array[String]]
|
|
56
|
-
@excluded_attributes = {} #: Hash[Array[untyped], Array[String]]
|
|
58
|
+
@excluded_attributes = {} #: Hash[Array[untyped], Array[String?]]
|
|
57
59
|
end
|
|
58
60
|
|
|
59
61
|
#: (untyped, reifier: untyped) -> RecordSnapshot?
|
|
60
62
|
def call(record, reifier:)
|
|
63
|
+
return unless record
|
|
64
|
+
|
|
61
65
|
reflections = reflections_for(record, @tree, '')
|
|
62
66
|
normalize_record(record, @tree, '', reifier, reflections)
|
|
63
67
|
end
|
|
@@ -76,7 +80,7 @@ module PaperTrailDiff
|
|
|
76
80
|
# Normalizes scalar attributes without traversing or loading associations.
|
|
77
81
|
#: (untyped, tree: AssociationTree, path: String) -> snapshot_attributes
|
|
78
82
|
def attributes_for(record, tree:, path:)
|
|
79
|
-
if record.
|
|
83
|
+
if record.instance_of?(PreparedRecordState)
|
|
80
84
|
return from_state(record, tree: tree,
|
|
81
85
|
path: path).attributes
|
|
82
86
|
end
|
|
@@ -94,7 +98,7 @@ module PaperTrailDiff
|
|
|
94
98
|
excluded = @excluded_attributes[[model, path]] ||=
|
|
95
99
|
build_excluded_attributes(model, reflections, path)
|
|
96
100
|
snapshot = RecordSnapshot.new(
|
|
97
|
-
type: model.name, id:
|
|
101
|
+
type: model.name, id: state_id(model, state),
|
|
98
102
|
attributes: state.attributes.except(*excluded), associations: associations
|
|
99
103
|
)
|
|
100
104
|
pool_state(path, snapshot)
|
|
@@ -102,6 +106,14 @@ module PaperTrailDiff
|
|
|
102
106
|
|
|
103
107
|
private
|
|
104
108
|
|
|
109
|
+
# The id a live record of the same row reports: one value, or one per
|
|
110
|
+
# column of a composite key.
|
|
111
|
+
#: (untyped, PreparedRecordState) -> untyped
|
|
112
|
+
def state_id(model, state)
|
|
113
|
+
key = model.primary_key
|
|
114
|
+
key.instance_of?(Array) ? state.attributes.fetch_values(*key) : state.attributes.fetch(key)
|
|
115
|
+
end
|
|
116
|
+
|
|
105
117
|
#: (String, RecordSnapshot) -> RecordSnapshot
|
|
106
118
|
def pool_state(path, snapshot)
|
|
107
119
|
path.empty? ? snapshot : @pool.record(path, snapshot)
|
|
@@ -112,12 +124,10 @@ module PaperTrailDiff
|
|
|
112
124
|
# @rbs @traversal: AssociationTraversal
|
|
113
125
|
# @rbs @pool: SnapshotPool
|
|
114
126
|
# @rbs @structural_columns: Hash[String, Array[String]]
|
|
115
|
-
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String]]
|
|
127
|
+
# @rbs @excluded_attributes: Hash[Array[untyped], Array[String?]]
|
|
116
128
|
|
|
117
|
-
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
129
|
+
#: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
118
130
|
def normalize_record(record, tree, path, reifier, reflections)
|
|
119
|
-
return unless record
|
|
120
|
-
|
|
121
131
|
snapshot = RecordSnapshot.new(
|
|
122
132
|
type: record.class.name,
|
|
123
133
|
id: record.id,
|
|
@@ -129,7 +139,7 @@ module PaperTrailDiff
|
|
|
129
139
|
|
|
130
140
|
#: (untyped, Array[untyped], String) -> snapshot_attributes
|
|
131
141
|
def normalized_attributes(record, reflections, path)
|
|
132
|
-
attributes = record.attributes
|
|
142
|
+
attributes = record.attributes
|
|
133
143
|
excluded_attributes(record, reflections, path).each { |name| attributes.delete(name) }
|
|
134
144
|
StoredTimePrecision.apply(attributes, StoredTimePrecision.digits(record.class))
|
|
135
145
|
end
|
|
@@ -137,23 +147,20 @@ module PaperTrailDiff
|
|
|
137
147
|
# Every input is fixed for one model class at one selected path, and the
|
|
138
148
|
# path's structural columns are recorded before its records normalize, so
|
|
139
149
|
# this is resolved once instead of per record per boundary.
|
|
140
|
-
#: (untyped, Array[untyped], String) -> Array[String]
|
|
150
|
+
#: (untyped, Array[untyped], String) -> Array[String?]
|
|
141
151
|
def excluded_attributes(record, reflections, path)
|
|
142
152
|
model_class = record.class
|
|
143
153
|
@excluded_attributes[[model_class, path]] ||=
|
|
144
154
|
build_excluded_attributes(model_class, reflections, path)
|
|
145
155
|
end
|
|
146
156
|
|
|
147
|
-
#: (untyped, Array[untyped], String) -> Array[String]
|
|
157
|
+
#: (untyped, Array[untyped], String) -> Array[String?]
|
|
148
158
|
def build_excluded_attributes(model_class, reflections, path)
|
|
149
|
-
|
|
150
|
-
primary_keys = primary_key.is_a?(Array) ? primary_key : [primary_key]
|
|
151
|
-
# @type var primary_keys: Array[untyped]
|
|
152
|
-
primary_keys = primary_keys.map { |key| key.to_s } # rubocop:disable Style/SymbolProc
|
|
159
|
+
primary_keys = Array(model_class.primary_key) #: Array[String]
|
|
153
160
|
ignored = @ignore_policy.attributes_for(path)
|
|
154
161
|
structural = @structural_columns.fetch(path, [])
|
|
155
|
-
|
|
156
|
-
relationship_columns(reflections) + structural
|
|
162
|
+
primary_keys + ignored + skipped_columns(model_class) +
|
|
163
|
+
relationship_columns(reflections) + structural
|
|
157
164
|
end
|
|
158
165
|
|
|
159
166
|
# `has_paper_trail skip:` columns are never written to a version, so history
|
|
@@ -163,18 +170,17 @@ module PaperTrailDiff
|
|
|
163
170
|
def skipped_columns(model_class)
|
|
164
171
|
return [] unless Support.versioned?(model_class)
|
|
165
172
|
|
|
166
|
-
|
|
167
|
-
entries.
|
|
168
|
-
|
|
169
|
-
end
|
|
173
|
+
# PaperTrail stores these as strings, or hashes keyed by strings.
|
|
174
|
+
entries = model_class.paper_trail_options.fetch(:skip) #: Array[untyped]
|
|
175
|
+
entries.flat_map { |entry| entry.instance_of?(Hash) ? entry.keys : entry }
|
|
170
176
|
end
|
|
171
177
|
|
|
172
|
-
|
|
178
|
+
# A non-polymorphic reflection has no foreign type, so it adds nil, which
|
|
179
|
+
# excludes nothing.
|
|
180
|
+
#: (Array[untyped]) -> Array[String?]
|
|
173
181
|
def relationship_columns(reflections)
|
|
174
182
|
reflections.select { |reflection| reflection.macro == :belongs_to }.flat_map do |reflection|
|
|
175
|
-
|
|
176
|
-
columns << reflection.foreign_type.to_s if reflection.polymorphic?
|
|
177
|
-
columns
|
|
183
|
+
[reflection.foreign_key, reflection.foreign_type]
|
|
178
184
|
end
|
|
179
185
|
end
|
|
180
186
|
|
|
@@ -182,27 +188,20 @@ module PaperTrailDiff
|
|
|
182
188
|
def normalize_associations(record, tree, path, reifier, reflections)
|
|
183
189
|
reflections.to_h do |reflection|
|
|
184
190
|
name = reflection.name.to_s
|
|
185
|
-
|
|
186
|
-
raise ConfigurationError, "missing traversal subtree: #{name}" unless subtree
|
|
187
|
-
|
|
188
|
-
[name, association_snapshot(record, reflection, subtree, path, reifier)]
|
|
191
|
+
[name, association_snapshot(record, reflection, tree.children.fetch(name), path, reifier)]
|
|
189
192
|
end
|
|
190
193
|
end
|
|
191
194
|
|
|
192
195
|
#: (untyped, untyped, AssociationTree, String, untyped) -> AssociationSnapshot
|
|
193
|
-
def association_snapshot(
|
|
196
|
+
def association_snapshot(
|
|
194
197
|
record,
|
|
195
198
|
reflection,
|
|
196
199
|
subtree,
|
|
197
200
|
path,
|
|
198
201
|
reifier
|
|
199
202
|
)
|
|
200
|
-
|
|
201
|
-
records =
|
|
202
|
-
associated.to_a
|
|
203
|
-
else
|
|
204
|
-
Array(associated).compact
|
|
205
|
-
end
|
|
203
|
+
# A reified history can hold a member PaperTrail could not rebuild.
|
|
204
|
+
records = Array(record.public_send(reflection.name)).compact
|
|
206
205
|
child_path = Support.association_path(path, reflection.name.to_s)
|
|
207
206
|
@structural_columns[child_path] ||= @traversal.incoming_relationship_columns(reflection)
|
|
208
207
|
snapshot = AssociationSnapshot.new(
|
|
@@ -214,10 +213,9 @@ module PaperTrailDiff
|
|
|
214
213
|
|
|
215
214
|
#: (Array[untyped], AssociationTree, String, untyped) -> Array[RecordSnapshot]
|
|
216
215
|
def normalize_children(records, tree, path, reifier)
|
|
217
|
-
reflections_by_class = {} #: Hash[
|
|
218
|
-
records.
|
|
219
|
-
|
|
220
|
-
reflections = reflections_by_class[type] ||= reflections_for(child, tree, path)
|
|
216
|
+
reflections_by_class = {} #: Hash[untyped, Array[untyped]]
|
|
217
|
+
records.map do |child|
|
|
218
|
+
reflections = reflections_by_class[child.class] ||= reflections_for(child, tree, path)
|
|
221
219
|
reifier.reify(child, reflections) unless reflections.empty?
|
|
222
220
|
normalize_record(child, tree, path, reifier, reflections)
|
|
223
221
|
end
|
|
@@ -225,8 +223,6 @@ module PaperTrailDiff
|
|
|
225
223
|
|
|
226
224
|
#: (untyped, AssociationTree, String) -> Array[untyped]
|
|
227
225
|
def reflections_for(record, tree, path)
|
|
228
|
-
return [] unless record
|
|
229
|
-
|
|
230
226
|
@traversal.reflections_for(record.class, tree, path: path)
|
|
231
227
|
end
|
|
232
228
|
end
|
|
@@ -53,8 +53,7 @@ module PaperTrailDiff
|
|
|
53
53
|
first_after = VersionQuery.no_earlier_version(table) do |earlier|
|
|
54
54
|
after_end(earlier[:created_at])
|
|
55
55
|
end
|
|
56
|
-
versions = trailing_scope(relation).where(first_after)
|
|
57
|
-
.reorder(created_at: :asc, id: :asc).to_a
|
|
56
|
+
versions = VersionQuery.chronological(trailing_scope(relation).where(first_after)).to_a
|
|
58
57
|
first = {} #: Hash[String, untyped]
|
|
59
58
|
versions.each { |version| first[version.item_id.to_s] ||= version }
|
|
60
59
|
first
|
|
@@ -37,7 +37,7 @@ module PaperTrailDiff
|
|
|
37
37
|
|
|
38
38
|
#: (untyped) -> untyped
|
|
39
39
|
def trailing_version(relation)
|
|
40
|
-
@time_range.trailing_scope(relation)
|
|
40
|
+
VersionQuery.chronological(@time_range.trailing_scope(relation)).first
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
#: (Array[untyped]) -> Array[untyped]
|
|
@@ -30,10 +30,11 @@ module PaperTrailDiff
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# A record with no versions has no first boundary to resolve, which
|
|
33
|
-
# is an empty history rather than a bad request.
|
|
33
|
+
# is an empty history rather than a bad request. Both endpoints are
|
|
34
|
+
# required, so only `:first` can resolve to nothing; a window has none.
|
|
34
35
|
#: () -> bool
|
|
35
36
|
def unresolved?
|
|
36
|
-
|
|
37
|
+
!time? && (@from.nil? || @to.nil?)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
# A batch may have selected these versions already, in which case reselecting
|
|
@@ -97,7 +98,7 @@ module PaperTrailDiff
|
|
|
97
98
|
|
|
98
99
|
#: (untyped) -> bool
|
|
99
100
|
def symbolic?(boundary)
|
|
100
|
-
boundary.
|
|
101
|
+
boundary.instance_of?(Symbol)
|
|
101
102
|
end
|
|
102
103
|
|
|
103
104
|
# Resolving `:first` here keeps callers from depending on the
|
|
@@ -118,7 +119,7 @@ module PaperTrailDiff
|
|
|
118
119
|
|
|
119
120
|
#: () -> untyped
|
|
120
121
|
def ordered_versions
|
|
121
|
-
Endpoint.versions(@record)
|
|
122
|
+
VersionQuery.chronological(Endpoint.versions(@record))
|
|
122
123
|
end
|
|
123
124
|
|
|
124
125
|
#: (untyped) -> TimeRange?
|
|
@@ -2,10 +2,18 @@
|
|
|
2
2
|
# rbs_inline: enabled
|
|
3
3
|
|
|
4
4
|
module PaperTrailDiff
|
|
5
|
-
#
|
|
5
|
+
# Query pieces shared by version lookups.
|
|
6
6
|
module VersionQuery
|
|
7
7
|
module_function
|
|
8
8
|
|
|
9
|
+
# Oldest first. Ids break ties between versions written in the same
|
|
10
|
+
# instant, and the timestamp comes first because ids need not follow time:
|
|
11
|
+
# a UUID key does not.
|
|
12
|
+
#: (untyped) -> untyped
|
|
13
|
+
def chronological(relation)
|
|
14
|
+
relation.reorder(created_at: :asc, id: :asc)
|
|
15
|
+
end
|
|
16
|
+
|
|
9
17
|
# Keeps each item's earliest version among those the block also accepts:
|
|
10
18
|
# no other version of the same item that the block accepts is earlier. A
|
|
11
19
|
# correlated NOT EXISTS stays portable where window functions are not.
|
|
@@ -17,12 +17,12 @@ module PaperTrailDiff
|
|
|
17
17
|
|
|
18
18
|
@to: AssociationSnapshot
|
|
19
19
|
|
|
20
|
+
# A transition names the one record that changed between adjacent
|
|
21
|
+
# snapshots. With matching identities it is an update; otherwise the
|
|
22
|
+
# before record left and the after record joined.
|
|
20
23
|
# : (CollectionTransition) -> CollectionAssociationDiff
|
|
21
24
|
def transition_difference: (CollectionTransition) -> CollectionAssociationDiff
|
|
22
25
|
|
|
23
|
-
# : (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
|
|
24
|
-
def compare_transition_records: (RecordSnapshot?, RecordSnapshot?) -> RecordChange?
|
|
25
|
-
|
|
26
26
|
# : () -> Array[RecordChange]?
|
|
27
27
|
def aligned_changes: () -> Array[RecordChange]?
|
|
28
28
|
|
|
@@ -57,6 +57,9 @@ module PaperTrailDiff
|
|
|
57
57
|
# : (identity) -> Array[untyped]
|
|
58
58
|
def sortable_identity: (identity) -> Array[untyped]
|
|
59
59
|
|
|
60
|
+
# Only the order of the group numbers matters, so moving the lowest one
|
|
61
|
+
# down or the highest one up changes nothing; the spec checks the order.
|
|
62
|
+
# mutant:disable
|
|
60
63
|
# : (untyped) -> Array[untyped]
|
|
61
64
|
def sortable_id: (untyped) -> Array[untyped]
|
|
62
65
|
|
|
@@ -6,8 +6,8 @@ module PaperTrailDiff
|
|
|
6
6
|
# : (Array[untyped]) -> void
|
|
7
7
|
def initialize: (Array[untyped]) -> void
|
|
8
8
|
|
|
9
|
-
# : (
|
|
10
|
-
def position: (
|
|
9
|
+
# : (String, untyped) -> Integer?
|
|
10
|
+
def position: (String, untyped) -> Integer?
|
|
11
11
|
|
|
12
12
|
# : (untyped) -> Integer?
|
|
13
13
|
def position_for_id: (untyped) -> Integer?
|
|
@@ -16,7 +16,7 @@ module PaperTrailDiff
|
|
|
16
16
|
|
|
17
17
|
@id_positions: Hash[String, Integer?]
|
|
18
18
|
|
|
19
|
-
@
|
|
19
|
+
@identities: Set[Array[untyped]]
|
|
20
20
|
|
|
21
21
|
@serialized_positions: Hash[Array[String], Integer]
|
|
22
22
|
|
|
@@ -44,8 +44,8 @@ module PaperTrailDiff
|
|
|
44
44
|
# : (Array[String]) -> Hash[String, Array[untyped]]
|
|
45
45
|
def candidate_versions: (Array[String]) -> Hash[String, Array[untyped]]
|
|
46
46
|
|
|
47
|
-
# : (Array[untyped], untyped) ->
|
|
48
|
-
def held?: (Array[untyped], untyped) ->
|
|
47
|
+
# : (Array[untyped], untyped) -> boolish
|
|
48
|
+
def held?: (Array[untyped], untyped) -> boolish
|
|
49
49
|
|
|
50
50
|
# What each candidate held after its last change in the window.
|
|
51
51
|
# : (Array[String], Hash[String, Array[untyped]]) -> Hash[String, untyped]
|
|
@@ -57,14 +57,14 @@ module PaperTrailDiff
|
|
|
57
57
|
# : (Array[String]) -> Hash[String, untyped]
|
|
58
58
|
def live_records: (Array[String]) -> Hash[String, untyped]
|
|
59
59
|
|
|
60
|
-
# : (untyped) ->
|
|
61
|
-
def record_matches?: (untyped) ->
|
|
60
|
+
# : (untyped) -> boolish
|
|
61
|
+
def record_matches?: (untyped) -> boolish
|
|
62
62
|
|
|
63
63
|
# : (Integer) -> void
|
|
64
64
|
def account_versions!: (Integer) -> void
|
|
65
65
|
|
|
66
|
-
# : (untyped) ->
|
|
67
|
-
def matches?: (untyped) ->
|
|
66
|
+
# : (untyped) -> boolish
|
|
67
|
+
def matches?: (untyped) -> boolish
|
|
68
68
|
|
|
69
69
|
# : (untyped) -> bool
|
|
70
70
|
def attributes_match?: (untyped) -> bool
|
|
@@ -73,6 +73,13 @@ module PaperTrailDiff
|
|
|
73
73
|
# : () -> Array[untyped]
|
|
74
74
|
def default_mutations: () -> Array[untyped]
|
|
75
75
|
|
|
76
|
+
# A Set only makes the lookup in `mutation?` fast; an Array would answer the
|
|
77
|
+
# same, which is why mutant leaves this alone.
|
|
78
|
+
# mutant:disable
|
|
79
|
+
# : (Array[untyped]) -> Set[Array[untyped]]
|
|
80
|
+
def key_set: (Array[untyped]) -> Set[Array[untyped]]
|
|
81
|
+
|
|
82
|
+
# By class name, so a version still matches after its class is reloaded.
|
|
76
83
|
# : (untyped) -> Array[untyped]
|
|
77
84
|
def key: (untyped) -> Array[untyped]
|
|
78
85
|
end
|
|
@@ -19,6 +19,9 @@ module PaperTrailDiff
|
|
|
19
19
|
|
|
20
20
|
# : (RecordSnapshot, RecordSnapshot) -> bool
|
|
21
21
|
def equivalent_record?: (RecordSnapshot, RecordSnapshot) -> bool
|
|
22
|
+
|
|
23
|
+
# : (AssociationSnapshot, AssociationSnapshot) -> bool
|
|
24
|
+
def same_association?: (AssociationSnapshot, AssociationSnapshot) -> bool
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
# Converts an ActiveRecord graph into immutable, persistence-independent snapshots.
|
|
@@ -43,10 +46,15 @@ module PaperTrailDiff
|
|
|
43
46
|
|
|
44
47
|
private
|
|
45
48
|
|
|
49
|
+
# The id a live record of the same row reports: one value, or one per
|
|
50
|
+
# column of a composite key.
|
|
51
|
+
# : (untyped, PreparedRecordState) -> untyped
|
|
52
|
+
def state_id: (untyped, PreparedRecordState) -> untyped
|
|
53
|
+
|
|
46
54
|
# : (String, RecordSnapshot) -> RecordSnapshot
|
|
47
55
|
def pool_state: (String, RecordSnapshot) -> RecordSnapshot
|
|
48
56
|
|
|
49
|
-
@excluded_attributes: Hash[Array[untyped], Array[String]]
|
|
57
|
+
@excluded_attributes: Hash[Array[untyped], Array[String?]]
|
|
50
58
|
|
|
51
59
|
@ignore_policy: IgnorePolicy
|
|
52
60
|
|
|
@@ -58,8 +66,8 @@ module PaperTrailDiff
|
|
|
58
66
|
|
|
59
67
|
@tree: AssociationTree
|
|
60
68
|
|
|
61
|
-
# : (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
62
|
-
def normalize_record: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
69
|
+
# : (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
70
|
+
def normalize_record: (untyped, AssociationTree, String, untyped, Array[untyped]) -> RecordSnapshot
|
|
63
71
|
|
|
64
72
|
# : (untyped, Array[untyped], String) -> snapshot_attributes
|
|
65
73
|
def normalized_attributes: (untyped, Array[untyped], String) -> snapshot_attributes
|
|
@@ -67,11 +75,11 @@ module PaperTrailDiff
|
|
|
67
75
|
# Every input is fixed for one model class at one selected path, and the
|
|
68
76
|
# path's structural columns are recorded before its records normalize, so
|
|
69
77
|
# this is resolved once instead of per record per boundary.
|
|
70
|
-
# : (untyped, Array[untyped], String) -> Array[String]
|
|
71
|
-
def excluded_attributes: (untyped, Array[untyped], String) -> Array[String]
|
|
78
|
+
# : (untyped, Array[untyped], String) -> Array[String?]
|
|
79
|
+
def excluded_attributes: (untyped, Array[untyped], String) -> Array[String?]
|
|
72
80
|
|
|
73
|
-
# : (untyped, Array[untyped], String) -> Array[String]
|
|
74
|
-
def build_excluded_attributes: (untyped, Array[untyped], String) -> Array[String]
|
|
81
|
+
# : (untyped, Array[untyped], String) -> Array[String?]
|
|
82
|
+
def build_excluded_attributes: (untyped, Array[untyped], String) -> Array[String?]
|
|
75
83
|
|
|
76
84
|
# `has_paper_trail skip:` columns are never written to a version, so history
|
|
77
85
|
# holds only their defaults. Compared with a live row they would report a
|
|
@@ -79,8 +87,10 @@ module PaperTrailDiff
|
|
|
79
87
|
# : (untyped) -> Array[String]
|
|
80
88
|
def skipped_columns: (untyped) -> Array[String]
|
|
81
89
|
|
|
82
|
-
#
|
|
83
|
-
|
|
90
|
+
# A non-polymorphic reflection has no foreign type, so it adds nil, which
|
|
91
|
+
# excludes nothing.
|
|
92
|
+
# : (Array[untyped]) -> Array[String?]
|
|
93
|
+
def relationship_columns: (Array[untyped]) -> Array[String?]
|
|
84
94
|
|
|
85
95
|
# : (untyped, AssociationTree, String, untyped, Array[untyped]) -> Hash[String, AssociationSnapshot]
|
|
86
96
|
def normalize_associations: (untyped, AssociationTree, String, untyped, Array[untyped]) -> Hash[String, AssociationSnapshot]
|
|
@@ -19,7 +19,8 @@ module PaperTrailDiff
|
|
|
19
19
|
def initialize: (untyped, from: untyped, to: untyped, within: untyped, ?version_scope: untyped, ?plan: RootVersionPlan?, ?live_endpoint: untyped) -> void
|
|
20
20
|
|
|
21
21
|
# A record with no versions has no first boundary to resolve, which
|
|
22
|
-
# is an empty history rather than a bad request.
|
|
22
|
+
# is an empty history rather than a bad request. Both endpoints are
|
|
23
|
+
# required, so only `:first` can resolve to nothing; a window has none.
|
|
23
24
|
# : () -> bool
|
|
24
25
|
def unresolved?: () -> bool
|
|
25
26
|
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
# Generated from lib/paper_trail_diff/version_query.rb with RBS::Inline
|
|
2
2
|
|
|
3
3
|
module PaperTrailDiff
|
|
4
|
-
#
|
|
4
|
+
# Query pieces shared by version lookups.
|
|
5
5
|
module VersionQuery
|
|
6
|
+
# Oldest first. Ids break ties between versions written in the same
|
|
7
|
+
# instant, and the timestamp comes first because ids need not follow time:
|
|
8
|
+
# a UUID key does not.
|
|
9
|
+
# : (untyped) -> untyped
|
|
10
|
+
def self?.chronological: (untyped) -> untyped
|
|
11
|
+
|
|
6
12
|
# Keeps each item's earliest version among those the block also accepts:
|
|
7
13
|
# no other version of the same item that the block accepts is earlier. A
|
|
8
14
|
# correlated NOT EXISTS stays portable where window functions are not.
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: paper_trail_diff
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex Williams
|
|
@@ -238,11 +238,11 @@ licenses:
|
|
|
238
238
|
metadata:
|
|
239
239
|
allowed_push_host: https://rubygems.org
|
|
240
240
|
bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
|
|
241
|
-
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.13.
|
|
242
|
-
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.13.
|
|
241
|
+
changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.13.1/CHANGELOG.md
|
|
242
|
+
documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.13.1/README.md
|
|
243
243
|
homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
|
|
244
244
|
rubygems_mfa_required: 'true'
|
|
245
|
-
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.13.
|
|
245
|
+
source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.13.1
|
|
246
246
|
rdoc_options: []
|
|
247
247
|
require_paths:
|
|
248
248
|
- lib
|