paper_trail_diff 0.11.0 → 0.12.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 205c52c1f80517b072fa68452447963c3d7245ba0707b049d5ebd5d2528727b3
4
- data.tar.gz: 65cc6e544662366fc185719a3660b29c3a9796f7c003fe7eeff4c551c9114c11
3
+ metadata.gz: e747358edb04ac2ea49e43b11eb8b5b8e811c426b50b017ed6d1c72162bb4632
4
+ data.tar.gz: b330e3bb80e43e478a0e88ff7afeb2b6576d3550af0a54075948fb0e60dc045d
5
5
  SHA512:
6
- metadata.gz: 78c62fcbc641a5f2dd029e69bf656453612a6f2768b1e3268048a13ea9fb4d17ff934048e36cb0798ae3d37aa3102f3c39ef289effdd2f96087796636e1ac921
7
- data.tar.gz: 3ba0a0592b1dd997c6177f2ad654bc28a93b00aebd5f83720732b7edaf08200c43aae19ac8e6eae2bbe351147343a2a8cf38b6352ca9427a9f2ad35f1be7c36d
6
+ metadata.gz: 8bcf171950fd74de94c8118dabf3f911e8db3923ce4ed48463c505b3b5748bafbb9c39819b7af49841bf3ea3f6635e797c9536f5d845a42f0a9df9eb1018d1de
7
+ data.tar.gz: 2eb4d0d286b9daf4ed91e1d0d674c6835569ef3912a2712034fad27d31648c5eeed1e410f34fbfbec3d59808b5d82f178df84b896a233eaec570c3b24b6b52ab
data/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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.12.0] - 2026-08-19
7
+
8
+ ### Changed
9
+
10
+ - Report an array inside a JSON column by membership rather than as one opaque
11
+ value. `nested_changes` now returns a `PaperTrailDiff::ArrayChange` for a pair
12
+ of arrays, carrying `added`, `removed` and `reordered?` alongside the whole
13
+ `from` and `to` it already had. Position is deliberately not reported:
14
+ elements carry no identity, so an insertion at the front makes every later
15
+ index look changed and one insertion reads as several edits. Membership is
16
+ answerable without claiming any pairing. Matching is by value and respects
17
+ duplicates, so `["a", "a"]` to `["a"]` reports one removal. The one thing
18
+ membership cannot see -- the same elements in a new order -- is named rather
19
+ than passed over. An element that is itself an object is reported whole,
20
+ since saying which of its fields changed would require pairing before- and
21
+ after-elements that nothing in the value licenses.
22
+
6
23
  ## [0.11.0] - 2026-08-18
7
24
 
8
25
  ### Added
data/README.md CHANGED
@@ -386,9 +386,30 @@ PaperTrailDiff.nested_changes({ "a.b" => 1, "a" => { "b" => 1 } },
386
386
  # => { ["a.b"] => <from 1 to 2>, ["a", "b"] => <from 1 to 3> }
387
387
  ```
388
388
 
389
- **Arrays are reported whole, not by index.** Their elements carry no identity, so
390
- an insertion at the front would make every later index look changed. This is the
391
- same rule the collection comparator follows for records it cannot identify.
389
+ **Arrays are reported by membership, not by position.** Elements carry no
390
+ identity, so an insertion at the front would make every later index look
391
+ changed one insertion described as several edits. An `ArrayChange` says what
392
+ was added and removed, matching by value and respecting duplicates:
393
+
394
+ ```ruby
395
+ change = PaperTrailDiff.nested_changes(
396
+ { "keywords" => %w[apollo nasa] },
397
+ { "keywords" => %w[saturn apollo nasa] }
398
+ ).fetch(["keywords"])
399
+
400
+ change.added # => ["saturn"]
401
+ change.removed # => []
402
+ change.reordered? # => false
403
+ change.from # => ["apollo", "nasa"] (the whole values remain)
404
+ ```
405
+
406
+ Membership cannot see one thing, so it is named rather than passed over: the
407
+ same elements in a new order report `reordered?` as true with nothing added or
408
+ removed.
409
+
410
+ An element that is itself an object is reported whole. Saying which field of
411
+ which object changed would need a pairing of before-elements with
412
+ after-elements, and nothing in the value licenses that pairing.
392
413
 
393
414
  **An absent key is not a null one.** `{"a": null}` and `{}` mean different things
394
415
  in JSON, and an audit trail that showed them alike would be lying about one of
@@ -16,11 +16,11 @@ module PaperTrailDiff
16
16
  # names and locales routinely do -- and joining would make `a.b` ambiguous
17
17
  # between one key and two.
18
18
  #
19
- # Arrays are leaves. Their elements carry no identity, so an insertion at the
20
- # front makes every later index look changed; reporting "element 2 changed"
21
- # would be confidently wrong about a list that merely shifted. The whole array
22
- # is reported as one change, which is the same rule the collection comparator
23
- # follows for records it cannot identify.
19
+ # Arrays are reported by membership, not by position -- see ArrayChange. Their
20
+ # elements carry no identity, so an insertion at the front makes every later
21
+ # index look changed, and one insertion reads as several edits. What is added
22
+ # and removed can be answered without claiming any pairing; what cannot is the
23
+ # same elements in a new order, so that is named rather than passed over.
24
24
  #
25
25
  # An absent key is not a null one. `{"a": null}` and `{}` mean different
26
26
  # things in JSON and an audit trail that conflated them would be lying about
@@ -32,7 +32,7 @@ module PaperTrailDiff
32
32
  def ABSENT.to_s = 'absent'
33
33
  ABSENT.freeze
34
34
 
35
- #: (untyped, untyped) -> Hash[Array[String], ValueChange]
35
+ #: (untyped, untyped) -> nested_changes
36
36
  def self.call(from_value, to_value)
37
37
  new(from_value, to_value).call
38
38
  end
@@ -46,12 +46,12 @@ module PaperTrailDiff
46
46
  # Returns the changed paths, or an empty hash when the pair is not two
47
47
  # structures this can look inside. An empty result therefore means "nothing
48
48
  # to report at this depth", and the caller still has the whole-value change.
49
- #: () -> Hash[Array[String], ValueChange]
49
+ #: () -> nested_changes
50
50
  def call
51
51
  from_structure, to_structure = structures
52
52
  return {} unless from_structure && to_structure
53
53
 
54
- changes = {} #: Hash[Array[String], ValueChange]
54
+ changes = {} #: nested_changes
55
55
  walk(from_structure, to_structure, [], changes)
56
56
  changes.freeze
57
57
  end
@@ -82,22 +82,33 @@ module PaperTrailDiff
82
82
  parsed if parsed.is_a?(Hash)
83
83
  end
84
84
 
85
- #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], Hash[Array[String], ValueChange]) -> void
85
+ #: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
86
86
  def walk(from_hash, to_hash, path, changes)
87
87
  keys(from_hash, to_hash).each do |key|
88
88
  from_item = from_hash.key?(key) ? from_hash[key] : ABSENT
89
89
  to_item = to_hash.key?(key) ? to_hash[key] : ABSENT
90
90
  next if from_item == to_item
91
91
 
92
- here = [*path, key.to_s]
92
+ here = [*path, key.to_s].freeze
93
93
  if from_item.is_a?(Hash) && to_item.is_a?(Hash)
94
94
  walk(from_item, to_item, here, changes)
95
95
  else
96
- changes[here.freeze] = ValueChange.new(from: from_item, to: to_item)
96
+ changes[here] = change_for(from_item, to_item)
97
97
  end
98
98
  end
99
99
  end
100
100
 
101
+ # Two arrays are a membership change; anything else is a change to the
102
+ # value as a whole, including an array facing something that is not one.
103
+ #: (untyped, untyped) -> nested_change
104
+ def change_for(from_item, to_item)
105
+ if from_item.is_a?(Array) && to_item.is_a?(Array)
106
+ ArrayChange.new(from: from_item, to: to_item)
107
+ else
108
+ ValueChange.new(from: from_item, to: to_item)
109
+ end
110
+ end
111
+
101
112
  # Sorted so a report reads the same twice, and stringified because a hash
102
113
  # loaded from JSON and one built in Ruby can key the same field differently.
103
114
  #: (Hash[untyped, untyped], Hash[untyped, untyped]) -> Array[untyped]
@@ -20,6 +20,72 @@ module PaperTrailDiff
20
20
  end
21
21
  end
22
22
 
23
+ # A change to an array inside a value the database stores whole, reported by
24
+ # membership rather than by position.
25
+ #
26
+ # Position would be the obvious thing to report and the wrong one: elements
27
+ # here carry no identity, so inserting at the front makes every later index
28
+ # look changed, and one insertion is described as several edits. Membership is
29
+ # answerable without claiming any pairing -- "gained \"saturn v\"" is true
30
+ # whether the array is a set, a queue, or a ranked list.
31
+ #
32
+ # That leaves one case membership cannot see, so it is named rather than
33
+ # hidden: the same elements in a different order. `reordered?` says so.
34
+ #
35
+ # An element that is itself a Hash or Array is reported whole. Saying which
36
+ # field of which object changed would require pairing before-elements with
37
+ # after-elements, and nothing in the value licenses that pairing.
38
+ class ArrayChange
39
+ attr_reader :from #: Array[untyped]
40
+ attr_reader :to #: Array[untyped]
41
+ attr_reader :added #: Array[untyped]
42
+ attr_reader :removed #: Array[untyped]
43
+
44
+ #: (from: Array[untyped], to: Array[untyped]) -> void
45
+ def initialize(from:, to:)
46
+ @from = Support.immutable_copy(from)
47
+ @to = Support.immutable_copy(to)
48
+ @added = Support.immutable_copy(ArrayChange.surplus(to, from))
49
+ @removed = Support.immutable_copy(ArrayChange.surplus(from, to))
50
+ freeze
51
+ end
52
+
53
+ # Elements of `left` with no counterpart left in `right`, matching by value
54
+ # and respecting duplicates, so ["a", "a"] -> ["a"] reports one removal
55
+ # rather than none.
56
+ #: (Array[untyped], Array[untyped]) -> Array[untyped]
57
+ def self.surplus(left, right)
58
+ pool = right.dup
59
+ left.reject do |item|
60
+ index = pool.index(item)
61
+ index ? pool.delete_at(index) || true : false
62
+ end
63
+ end
64
+
65
+ # Same elements, different sequence. Only meaningful for a change that was
66
+ # recorded at all, which this only is when the two sides differ.
67
+ #: () -> bool
68
+ def reordered?
69
+ added.empty? && removed.empty?
70
+ end
71
+
72
+ #: () -> bool
73
+ def empty?
74
+ from == to
75
+ end
76
+
77
+ #: () -> Hash[Symbol, untyped]
78
+ def to_h
79
+ {
80
+ from: Support.serialize(from),
81
+ to: Support.serialize(to),
82
+ added: Support.serialize(added),
83
+ removed: Support.serialize(removed),
84
+ reordered: reordered?
85
+ }
86
+ end
87
+ end
88
+
23
89
  # Attribute and nested-association changes for a record whose identity did not change.
24
90
  class RecordChange
25
91
  attr_reader :record #: RecordReference
@@ -2,5 +2,5 @@
2
2
  # rbs_inline: enabled
3
3
 
4
4
  module PaperTrailDiff
5
- VERSION = '0.11.0'
5
+ VERSION = '0.12.0'
6
6
  end
@@ -92,6 +92,8 @@ require_relative 'paper_trail_diff/paper_trail_adapter'
92
92
  # type attribute_changes = Hash[String, ValueChange]
93
93
  # type association_diff = ToOneAssociationDiff | CollectionAssociationDiff
94
94
  # type association_diffs = Hash[String, association_diff]
95
+ # type nested_change = ValueChange | ArrayChange
96
+ # type nested_changes = Hash[Array[String], nested_change]
95
97
  # type association_snapshots = Hash[String, AssociationSnapshot]
96
98
  # type identity = Array[untyped]
97
99
  # type comparison_input = Hash[String | Symbol, untyped]
@@ -344,7 +346,7 @@ module PaperTrailDiff # rubocop:disable Metrics/ModuleLength
344
346
  # list that merely shifted would otherwise look changed throughout. A key
345
347
  # that was absent reads as `NestedComparator::ABSENT` rather than nil, which
346
348
  # JSON uses for a present null.
347
- #: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
349
+ #: (untyped, ?untyped) -> nested_changes
348
350
  def nested_changes(change, to_value = nil)
349
351
  # Tested by type rather than by responding to `from`: ActiveSupport gives
350
352
  # String#from, so duck-typing here quietly reads a plain string as a pair.
@@ -13,11 +13,11 @@ module PaperTrailDiff
13
13
  # names and locales routinely do -- and joining would make `a.b` ambiguous
14
14
  # between one key and two.
15
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.
16
+ # Arrays are reported by membership, not by position -- see ArrayChange. Their
17
+ # elements carry no identity, so an insertion at the front makes every later
18
+ # index look changed, and one insertion reads as several edits. What is added
19
+ # and removed can be answered without claiming any pairing; what cannot is the
20
+ # same elements in a new order, so that is named rather than passed over.
21
21
  #
22
22
  # An absent key is not a null one. `{"a": null}` and `{}` mean different
23
23
  # things in JSON and an audit trail that conflated them would be lying about
@@ -30,8 +30,8 @@ module PaperTrailDiff
30
30
 
31
31
  def to_s: () -> untyped
32
32
 
33
- # : (untyped, untyped) -> Hash[Array[String], ValueChange]
34
- def self.call: (untyped, untyped) -> Hash[Array[String], ValueChange]
33
+ # : (untyped, untyped) -> nested_changes
34
+ def self.call: (untyped, untyped) -> nested_changes
35
35
 
36
36
  # : (untyped, untyped) -> void
37
37
  def initialize: (untyped, untyped) -> void
@@ -39,8 +39,8 @@ module PaperTrailDiff
39
39
  # Returns the changed paths, or an empty hash when the pair is not two
40
40
  # structures this can look inside. An empty result therefore means "nothing
41
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]
42
+ # : () -> nested_changes
43
+ def call: () -> nested_changes
44
44
 
45
45
  private
46
46
 
@@ -57,8 +57,13 @@ module PaperTrailDiff
57
57
  # : (untyped) -> Hash[untyped, untyped]?
58
58
  def structure: (untyped) -> Hash[untyped, untyped]?
59
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
60
+ # : (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
61
+ def walk: (Hash[untyped, untyped], Hash[untyped, untyped], Array[String], nested_changes) -> void
62
+
63
+ # Two arrays are a membership change; anything else is a change to the
64
+ # value as a whole, including an array facing something that is not one.
65
+ # : (untyped, untyped) -> nested_change
66
+ def change_for: (untyped, untyped) -> nested_change
62
67
 
63
68
  # Sorted so a report reads the same twice, and stringified because a hash
64
69
  # loaded from JSON and one built in Ruby can key the same field differently.
@@ -14,6 +14,51 @@ module PaperTrailDiff
14
14
  def to_h: () -> Hash[Symbol, untyped]
15
15
  end
16
16
 
17
+ # A change to an array inside a value the database stores whole, reported by
18
+ # membership rather than by position.
19
+ #
20
+ # Position would be the obvious thing to report and the wrong one: elements
21
+ # here carry no identity, so inserting at the front makes every later index
22
+ # look changed, and one insertion is described as several edits. Membership is
23
+ # answerable without claiming any pairing -- "gained \"saturn v\"" is true
24
+ # whether the array is a set, a queue, or a ranked list.
25
+ #
26
+ # That leaves one case membership cannot see, so it is named rather than
27
+ # hidden: the same elements in a different order. `reordered?` says so.
28
+ #
29
+ # An element that is itself a Hash or Array is reported whole. Saying which
30
+ # field of which object changed would require pairing before-elements with
31
+ # after-elements, and nothing in the value licenses that pairing.
32
+ class ArrayChange
33
+ attr_reader from: Array[untyped]
34
+
35
+ attr_reader to: Array[untyped]
36
+
37
+ attr_reader added: Array[untyped]
38
+
39
+ attr_reader removed: Array[untyped]
40
+
41
+ # : (from: Array[untyped], to: Array[untyped]) -> void
42
+ def initialize: (from: Array[untyped], to: Array[untyped]) -> void
43
+
44
+ # Elements of `left` with no counterpart left in `right`, matching by value
45
+ # and respecting duplicates, so ["a", "a"] -> ["a"] reports one removal
46
+ # rather than none.
47
+ # : (Array[untyped], Array[untyped]) -> Array[untyped]
48
+ def self.surplus: (Array[untyped], Array[untyped]) -> Array[untyped]
49
+
50
+ # Same elements, different sequence. Only meaningful for a change that was
51
+ # recorded at all, which this only is when the two sides differ.
52
+ # : () -> bool
53
+ def reordered?: () -> bool
54
+
55
+ # : () -> bool
56
+ def empty?: () -> bool
57
+
58
+ # : () -> Hash[Symbol, untyped]
59
+ def to_h: () -> Hash[Symbol, untyped]
60
+ end
61
+
17
62
  # Attribute and nested-association changes for a record whose identity did not change.
18
63
  class RecordChange
19
64
  attr_reader record: RecordReference
@@ -99,8 +99,8 @@ module PaperTrailDiff
99
99
  # list that merely shifted would otherwise look changed throughout. A key
100
100
  # that was absent reads as `NestedComparator::ABSENT` rather than nil, which
101
101
  # JSON uses for a present null.
102
- # : (untyped, ?untyped) -> Hash[Array[String], ValueChange]
103
- def self.nested_changes: (untyped, ?untyped) -> Hash[Array[String], ValueChange]
102
+ # : (untyped, ?untyped) -> nested_changes
103
+ def self.nested_changes: (untyped, ?untyped) -> nested_changes
104
104
  end
105
105
 
106
106
  module PaperTrailDiff
@@ -114,6 +114,10 @@ module PaperTrailDiff
114
114
 
115
115
  type association_diffs = Hash[String, association_diff]
116
116
 
117
+ type nested_change = ValueChange | ArrayChange
118
+
119
+ type nested_changes = Hash[Array[String], nested_change]
120
+
117
121
  type association_snapshots = Hash[String, AssociationSnapshot]
118
122
 
119
123
  type identity = Array[untyped]
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.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex Williams
@@ -212,11 +212,11 @@ licenses:
212
212
  metadata:
213
213
  allowed_push_host: https://rubygems.org
214
214
  bug_tracker_uri: https://github.com/aheathwilliams/paper_trail_diff/issues
215
- changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.11.0/CHANGELOG.md
216
- documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.11.0/README.md
215
+ changelog_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.12.0/CHANGELOG.md
216
+ documentation_uri: https://github.com/aheathwilliams/paper_trail_diff/blob/v0.12.0/README.md
217
217
  homepage_uri: https://github.com/aheathwilliams/paper_trail_diff
218
218
  rubygems_mfa_required: 'true'
219
- source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.11.0
219
+ source_code_uri: https://github.com/aheathwilliams/paper_trail_diff/tree/v0.12.0
220
220
  rdoc_options: []
221
221
  require_paths:
222
222
  - lib