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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a4e484cc975317987834dd585f1bc5432985575c66b473b1db5c31b3feb4995d
4
- data.tar.gz: 127f6d1e5ae63a839be293ea22beb6493108b90f3cbc71d34bdf1814613a54ab
3
+ metadata.gz: dfe7bccfe2f013af725d6ac92c772d357127d5a0c14cae72fac0000cb58fad62
4
+ data.tar.gz: 308cc912a32875582ff8e083ec52016d4ca586ee75f3b0e36172360d2600b01d
5
5
  SHA512:
6
- metadata.gz: 9d65a4239a738367dd8b0536670b2f00cecfae7283db328c87308323fd19d8080fa13eed2a8910d7a0e67a2380f286c65d1b4c943a72bd1903f3d1695b2dfc18
7
- data.tar.gz: f316b5892247e31b58a2531dd86073c812327e27bd36f5d4f020efd47d543e280b94ab513467d58a6445173106b89c1378716df471077ee7f1e35fe4bd0999d9
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 comparison code one
43
- expression at a time and expects a spec to fail each time. A change that every
44
- spec still passes is behavior nothing checks. `mutant.yml` lists the classes it
45
- mutates: `Engine`, `CollectionComparator`, `CollectionIdentityIndex`, and
46
- `NestedComparator`, which decide what counts as a change. It runs the
47
- association suite, which reaches all of their paths.
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 same thing,
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
@@ -125,7 +125,7 @@ module PaperTrailDiff
125
125
 
126
126
  #: (untyped) -> Array[untyped]
127
127
  def ordered(relation)
128
- relation.reorder(created_at: :asc, id: :asc).to_a
128
+ VersionQuery.chronological(relation).to_a
129
129
  end
130
130
 
131
131
  #: (untyped, untyped) -> Array[String]
@@ -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
- changed = compare_transition_records(before, after)
38
- difference(
39
- added: before.nil? && after ? [after] : [],
40
- removed: after.nil? && before ? [before] : [],
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
- @record_comparer.call(before, after)
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 = {} #: Hash[identity, bool]
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) if seen.key?(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), *sortable_id(identity.fetch(1))]
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, 0, id.to_s]
159
- else [2, 0, id.inspect]
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.inspect}"
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
- @positions = {} #: Hash[Array[untyped], Integer]
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
- #: (untyped, untyped) -> Integer?
16
+ #: (String, untyped) -> Integer?
20
17
  def position(type, id)
21
- @serialized_positions[[type.to_s, id.to_s]]
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 @positions: Hash[Array[untyped], Integer]
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
- if @positions.key?(identity)
39
- raise ArgumentError, "duplicate record identity: #{identity.inspect}"
35
+ unless @identities.add?(identity)
36
+ raise ArgumentError, "duplicate record identity: #{identity}"
40
37
  end
41
38
 
42
- @positions[identity] = index
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) ? nil : index
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
- versions = @model.paper_trail.version_class.where(
67
+ destroys = @model.paper_trail.version_class.where(
68
68
  item_type: @model.base_class.name, item_id: @identities.map(&:last), event: 'destroy'
69
- ).reorder(created_at: :asc, id: :asc)
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.is_a?(Integer) && value.positive?
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
- versions = relation.reorder(created_at: :asc, id: :asc)
84
- .limit(@max_versions - @loaded + 1).to_a
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) -> bool
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.to_s == 'destroy' }
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
- return {} if ids.empty?
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) -> bool
122
+ #: (untyped) -> boolish
128
123
  def record_matches?(record)
129
- return !!@predicate.call(record) unless @predicate.is_a?(Hash)
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) -> bool
139
+ #: (untyped) -> boolish
145
140
  def matches?(version)
146
- if @predicate.is_a?(Hash)
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
- !!(state && @predicate.call(state))
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.model_class.type_for_attribute(name).cast(state.attributes.fetch(name)) == expected
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 = Set.new(@mutations.map { |version| key(version) }).freeze
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
- @type = Support.immutable_copy(type.to_s)
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
- if previous && previous.kind == snapshot.kind && previous.records == snapshot.records
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
- other = right.associations[name]
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.is_a?(PreparedRecordState)
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: state.attributes[model.primary_key],
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.dup
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
- primary_key = model_class.primary_key #: untyped
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
- (primary_keys + ignored + skipped_columns(model_class) +
156
- relationship_columns(reflections) + structural).uniq.freeze
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
- entries = Array(model_class.paper_trail_options[:skip]) #: Array[untyped]
167
- entries.flat_map do |entry|
168
- entry.is_a?(Hash) ? entry.keys.map(&:to_s) : [entry.to_s]
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
- #: (Array[untyped]) -> Array[String]
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
- columns = [reflection.foreign_key.to_s]
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
- subtree = tree.child(name)
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( # rubocop:disable Metrics/AbcSize
196
+ def association_snapshot(
194
197
  record,
195
198
  reflection,
196
199
  subtree,
197
200
  path,
198
201
  reifier
199
202
  )
200
- associated = record.public_send(reflection.name)
201
- records = if AssociationSnapshot.collection_kind?(reflection.macro)
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[String, Array[untyped]]
218
- records.filter_map do |child|
219
- type = child.class.name
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).reorder(created_at: :asc, id: :asc).first
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
- (symbolic?(@requested_from) && @from.nil?) || (symbolic?(@requested_to) && @to.nil?)
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.is_a?(Symbol)
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).reorder(created_at: :asc, id: :asc)
122
+ VersionQuery.chronological(Endpoint.versions(@record))
122
123
  end
123
124
 
124
125
  #: (untyped) -> TimeRange?
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.13.0'
5
+ VERSION = '0.13.1'
6
6
  end
@@ -2,10 +2,18 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- # Arel conditions shared by the version queries that need one row per item.
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
- # : (untyped, untyped) -> Integer?
10
- def position: (untyped, untyped) -> Integer?
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
- @positions: Hash[Array[untyped], Integer]
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) -> bool
48
- def held?: (Array[untyped], untyped) -> bool
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) -> bool
61
- def record_matches?: (untyped) -> bool
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) -> bool
67
- def matches?: (untyped) -> bool
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
- # : (Array[untyped]) -> Array[String]
83
- def relationship_columns: (Array[untyped]) -> Array[String]
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
- # Arel conditions shared by the version queries that need one row per item.
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.0
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.0/CHANGELOG.md
242
- documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.13.0/README.md
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.0
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