paper_trail_diff 0.1.0 → 0.2.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: e85c7b69b7fe2c0c5b8fac36c28e94f0e8cb3e24bb0e84fa312a8c8c4de9a244
4
- data.tar.gz: fa7118652d053ac5f882a6382c412534ca3c9fdbc3b613192652e7a3080fa205
3
+ metadata.gz: 808e6dba6570e96679cb5b355b774e8afcd3b6bf81c38c1a0b0fc088d6f9f416
4
+ data.tar.gz: 5978755e10f821777aadc86b6f4d79728a827cf48c1ba3e95c89a6f627eaea6e
5
5
  SHA512:
6
- metadata.gz: 04d797a11eadc6702892f0e581da107bc6e1a4f2c41f7bf9759bddc1f01534b29389eeb12830b945be378c5cebe4ae3336235326ce21d7423b1f10e3bc48db28
7
- data.tar.gz: 15cf3d7c0b099e2fb4c4db6db84c64718afd121d42fc91f8f63e34513d68db54f2c1365354eed648faef435e4d6a4e1645115978f80cbf2834e4e382efb3c773
6
+ metadata.gz: '048b927dd9d8274675ae60692ad705ed586c9d31ed6f4c11a581521d86ff8621dedda7daf33a4e4c90223b31a42bc60184c6f376ed4b1363a3cfcb5a2b2ab65a'
7
+ data.tar.gz: 559ea7552ce0133864a3e55df66097fdbbee3315febf9d04cd7485e0caa14e9deb34625b48ecb660f00cd452ec00b4e12d1180689c88a2b2464e59e71182abb5
data/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
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.2.0] - 2026-08-09
7
+
8
+ ### Added
9
+
10
+ - Add deterministic `Diff#each_entry` and `Diff#each_change` traversal with
11
+ immutable `TraversalEntry` values for renderers, counters, exports, and
12
+ notifications.
13
+ - Add immutable version metadata and record references to activity boundaries.
14
+ - Give checkpoint `Step` objects `from_boundary` and `to_boundary` readers and
15
+ give both timeline step types an `empty?` predicate.
16
+ - Add a Rails-focused quickstart with minimal endpoint, timeline, ignore, and
17
+ association examples.
18
+ - Execute selected README and quickstart examples in isolated core and PT-AT
19
+ test sessions so documented behavior cannot silently drift.
20
+
6
21
  ## [0.1.0] - 2026-08-09
7
22
 
8
23
  ### Added
data/QUICKSTART.md ADDED
@@ -0,0 +1,260 @@
1
+ # Quickstart
2
+
3
+ This guide gets `paper_trail_diff` running in a Rails application and shows the
4
+ smallest useful examples. Ruby 3.1 or newer and PaperTrail 16 or 17 are
5
+ supported.
6
+
7
+ ## 1. Install the gem
8
+
9
+ From the Rails application directory:
10
+
11
+ ```console
12
+ bundle add paper_trail_diff
13
+ bin/rails generate paper_trail:install --with-changes
14
+ bin/rails db:migrate
15
+ ```
16
+
17
+ Skip the generator if the application already has a PaperTrail `versions`
18
+ table. The `object_changes` column created by `--with-changes` is recommended
19
+ for efficient activity analysis, although endpoint comparison is based on
20
+ reified state rather than a PaperTrail changeset.
21
+
22
+ ## 2. Version a model
23
+
24
+ ```ruby
25
+ # app/models/article.rb
26
+ class Article < ApplicationRecord
27
+ has_paper_trail
28
+ end
29
+ ```
30
+
31
+ Restart the Rails console after changing the Gemfile or an initializer.
32
+
33
+ ## 3. Create a little history
34
+
35
+ Run this in `bin/rails console`:
36
+
37
+ <!-- executable:quickstart-history -->
38
+ ```ruby
39
+ article = Article.create!(title: "Draft")
40
+
41
+ article.update!(title: "Published")
42
+ draft_version = article.versions.last
43
+
44
+ article.update!(title: "Final")
45
+ published_version = article.versions.last
46
+ ```
47
+
48
+ The variable names are intentional. PaperTrail versions contain the state
49
+ *before* their event:
50
+
51
+ ```ruby
52
+ draft_version.reify.title # => "Draft"
53
+ published_version.reify.title # => "Published"
54
+ article.title # => "Final" (current database state)
55
+ ```
56
+
57
+ ## 4. Compare two states
58
+
59
+ <!-- executable:quickstart-compare -->
60
+ ```ruby
61
+ diff = PaperTrailDiff.compare(draft_version, published_version)
62
+
63
+ diff.empty? # => false
64
+ diff.attributes["title"].from # => "Draft"
65
+ diff.attributes["title"].to # => "Published"
66
+
67
+ diff.to_h
68
+ # => {
69
+ # record_presence_change: nil,
70
+ # attributes: {
71
+ # "title" => { from: "Draft", to: "Published" }
72
+ # },
73
+ # associations: {}
74
+ # }
75
+ ```
76
+
77
+ `compare` reports only the net difference between its endpoints. It does not
78
+ report intermediate changes.
79
+
80
+ ### Compare a version with current state
81
+
82
+ Pass the persisted record explicitly when the second endpoint should be the
83
+ current database state:
84
+
85
+ <!-- executable:quickstart-current -->
86
+ ```ruby
87
+ diff = PaperTrailDiff.compare(published_version, article)
88
+
89
+ diff.attributes["title"].to # => "Final"
90
+ ```
91
+
92
+ The record must be persisted, not destroyed, and have no unsaved changes.
93
+ Current state is never inferred automatically.
94
+
95
+ ## 5. Build a checkpoint timeline
96
+
97
+ Create one more historical endpoint:
98
+
99
+ <!-- executable:quickstart-timeline-state -->
100
+ ```ruby
101
+ article.update!(title: "Archived")
102
+ final_version = article.versions.last # reifies to "Final"
103
+ ```
104
+
105
+ Then compare every adjacent root version:
106
+
107
+ <!-- executable:quickstart-timeline -->
108
+ ```ruby
109
+ steps = PaperTrailDiff.timeline(
110
+ article,
111
+ from: draft_version,
112
+ to: final_version
113
+ )
114
+
115
+ steps.map do |step|
116
+ change = step.diff.attributes["title"]
117
+ [change.from, change.to]
118
+ end
119
+ # => [["Draft", "Published"], ["Published", "Final"]]
120
+
121
+ visible_steps = steps.reject { |step| step.diff.empty? }
122
+ ```
123
+
124
+ Empty steps remain in the timeline. Filter them only when the application's
125
+ display does not need every version boundary.
126
+
127
+ ## 6. Choose the right API
128
+
129
+ | Need | Call |
130
+ | --- | --- |
131
+ | Net difference between two endpoints | `compare` |
132
+ | One step per root-record version | `timeline` |
133
+ | Steps for root and selected child versions | `activity_timeline` |
134
+ | Net diff and timelines from one history pass | `analyze` |
135
+
136
+ `timeline` is a root-checkpoint timeline. A child change becomes visible at the
137
+ next root boundary. `activity_timeline` can make a versioned child change its
138
+ own boundary and can end at an explicitly supplied current record.
139
+
140
+ ## 7. Ignore noise fields
141
+
142
+ `updated_at` is ignored by default. Passing `ignore:` replaces that default:
143
+
144
+ <!-- executable:quickstart-ignore -->
145
+ ```ruby
146
+ PaperTrailDiff.compare(
147
+ draft_version,
148
+ published_version,
149
+ ignore: %i[updated_at lock_version]
150
+ )
151
+
152
+ PaperTrailDiff.compare(
153
+ draft_version,
154
+ published_version,
155
+ ignore: [] # compare every available scalar attribute
156
+ )
157
+ ```
158
+
159
+ See the main README for exact path-specific ignore rules.
160
+
161
+ ## 8. Add association history when needed
162
+
163
+ Associations are optional. Add PT-AT only when the application needs historical
164
+ association reconstruction:
165
+
166
+ ```console
167
+ bundle add paper_trail-association_tracking
168
+ bin/rails generate paper_trail_association_tracking:install
169
+ bin/rails db:migrate
170
+ ```
171
+
172
+ The generator creates `version_associations` and enables
173
+ `PaperTrail.config.track_associations`. Every model whose historical state is
174
+ needed must be versioned:
175
+
176
+ ```ruby
177
+ class Article < ApplicationRecord
178
+ has_many :comments, dependent: :destroy
179
+ has_paper_trail synchronize_version_creation_timestamp: false
180
+ end
181
+
182
+ class Comment < ApplicationRecord
183
+ belongs_to :article
184
+ has_paper_trail
185
+ end
186
+ ```
187
+
188
+ Take an explicit root checkpoint before the example change:
189
+
190
+ <!-- executable:quickstart-association -->
191
+ ```ruby
192
+ Article.transaction do
193
+ Article.find(article.id).paper_trail.save_with_version
194
+ end
195
+ before = article.versions.reload.last
196
+
197
+ article.comments.create!(body: "First comment")
198
+ current = Article.find(article.id)
199
+
200
+ diff = PaperTrailDiff.compare(
201
+ before,
202
+ current,
203
+ associations: [:comments]
204
+ )
205
+
206
+ diff.associations["comments"].added.first.attributes["body"]
207
+ # => "First comment"
208
+ ```
209
+
210
+ Nested paths are explicit and bounded:
211
+
212
+ ```ruby
213
+ diff = PaperTrailDiff.compare(
214
+ before,
215
+ current,
216
+ associations: ["comments.replies.author"]
217
+ )
218
+ ```
219
+
220
+ To see versioned child events without touching the article after each change:
221
+
222
+ <!-- executable:quickstart-activity -->
223
+ ```ruby
224
+ steps = PaperTrailDiff.activity_timeline(
225
+ article,
226
+ from: before,
227
+ to: current,
228
+ associations: ["comments"]
229
+ )
230
+
231
+ steps.reject { |step| step.diff.empty? }.each do |step|
232
+ boundary = step.to_boundary
233
+ puts "#{boundary.item_type} ##{boundary.item_id}"
234
+ end
235
+ ```
236
+
237
+ Use a later transaction-backed root checkpoint instead of `to: current` when
238
+ the result must remain reproducible after the database changes again.
239
+
240
+ ## 9. Common surprises
241
+
242
+ - PaperTrail stores pre-change snapshots. The current record is not represented
243
+ by `versions.last`; pass the record explicitly when current state is wanted.
244
+ - `ignore:` replaces the default list. Include `updated_at` yourself when using
245
+ a custom list and you still want it ignored.
246
+ - Historical associations require PT-AT to be installed, loaded, migrated, and
247
+ enabled. Restart the console after setup.
248
+ - Only requested association paths are traversed. The gem never recursively
249
+ discovers the whole model graph.
250
+ - Historical output cannot contain data that PaperTrail or PT-AT did not record
251
+ or can no longer reconstruct.
252
+
253
+ For performance guidance, HABTM limitations, diagnostics, discovery, path-aware
254
+ ignore rules, and complete result shapes, continue with the
255
+ [README](README.md).
256
+
257
+ Upstream setup references:
258
+ [PaperTrail installation](https://github.com/paper-trail-gem/paper_trail#1b-installation)
259
+ and
260
+ [PT-AT installation](https://github.com/westonganger/paper_trail-association_tracking#install).
data/README.md CHANGED
@@ -12,6 +12,8 @@ available when
12
12
 
13
13
  Ruby 3.1 or newer and PaperTrail 16 or 17 are supported.
14
14
 
15
+ New to the gem? Start with the copyable [Quickstart](QUICKSTART.md).
16
+
15
17
  ## Installation
16
18
 
17
19
  Add the gem to your bundle:
@@ -95,11 +97,22 @@ steps = PaperTrailDiff.timeline(
95
97
 
96
98
  steps.first.from_version # the original PaperTrail version
97
99
  steps.first.to_version # the next PaperTrail version
100
+ steps.first.from_boundary # immutable presentation metadata
101
+ steps.first.to_boundary
98
102
  steps.first.diff # a PaperTrailDiff::Diff
103
+ steps.first.empty? # delegates to the diff
99
104
  steps.first.to_h
100
105
  # => { from_version_id: 2, to_version_id: 3, diff: { ... } }
101
106
  ```
102
107
 
108
+ Both timeline types expose `from_boundary`, `to_boundary`, `diff`, and
109
+ `empty?`. A historical boundary has `event`, `whodunnit`, `record`,
110
+ `recorded_at`, `version?`, and `current?` readers. Checkpoint `Step` objects
111
+ also retain their original `from_version` and `to_version` for callers that
112
+ need custom PaperTrail metadata. Existing `Step#to_h` and
113
+ `ActivityBoundary#to_h` shapes remain unchanged; use the readers for the new
114
+ metadata.
115
+
103
116
  Every version boundary remains in the result, even when its diff is empty after
104
117
  ignored fields are removed. Equal boundaries return a frozen empty array.
105
118
 
@@ -323,6 +336,61 @@ database state. Unknown names and unsupported macros raise explicit
323
336
  Malformed public options raise `PaperTrailDiff::ConfigurationError`, also under
324
337
  that base error.
325
338
 
339
+ ## Traverse result trees
340
+
341
+ The nested tree remains the canonical, lossless result. For renderers,
342
+ counters, exports, and notifications, `Diff#each_change` provides a
343
+ deterministic depth-first stream of semantic changes:
344
+
345
+ <!-- executable:readme-traversal-changes -->
346
+ ```ruby
347
+ diff.each_change do |entry|
348
+ entry.kind # :attribute_changed, :record_added, ...
349
+ entry.association_path # ["comments", "replies"]
350
+ entry.record_path # frozen RecordReference objects
351
+ entry.association_kind # :has_many, :belongs_to, ...
352
+ entry.attribute # "body" for an attribute entry
353
+ entry.value # ValueChange, RecordChange, or RecordSnapshot
354
+ end
355
+
356
+ counts = diff.each_change.map(&:kind).tally
357
+ ```
358
+
359
+ `record_changed` entries are emitted before that record's attribute and nested
360
+ association changes, allowing an application to count both changed records and
361
+ changed fields. Singular relationship operations are classified separately as
362
+ `relationship_added`, `relationship_removed`, or `relationship_replaced`.
363
+ Root create/delete transitions remain `record_presence_changed`.
364
+
365
+ Added, removed, and replaced records carry complete bounded snapshots. Use
366
+ `each_entry` when a renderer also needs that nested state without writing a
367
+ second snapshot walker:
368
+
369
+ <!-- executable:readme-traversal-entries -->
370
+ ```ruby
371
+ diff.each_entry do |entry|
372
+ next unless entry.included_state?
373
+
374
+ entry.kind # :record_included, :attribute_included, :association_included
375
+ entry.state # :before or :after
376
+ entry.context # :included_state
377
+ end
378
+ ```
379
+
380
+ An included record is historical context, not evidence that the nested record
381
+ changed independently. `each_change` therefore omits included-state entries.
382
+ Both methods return an `Enumerator` when no block is supplied and return the
383
+ diff when called with a block. Entries and their paths are immutable and have
384
+ deterministic `to_h` output. The root location uses empty association and record
385
+ paths; descendant `record_path` values contain only the explicitly traversed
386
+ descendant identities.
387
+
388
+ Traversal is path-preserving and does not deduplicate. If the same record is
389
+ reachable through two selected paths, it appears at both locations so the
390
+ consumer can choose whether path identity or record identity controls counting.
391
+ Walking a result never performs database access and never discovers additional
392
+ associations.
393
+
326
394
  ## Discover and diagnose associations
327
395
 
328
396
  Configuration UIs can use bounded public reflection instead of duplicating the
@@ -392,6 +460,7 @@ The public result types are:
392
460
  - `PaperTrailDiff::ActivityBoundary`
393
461
  - `PaperTrailDiff::Analysis`
394
462
  - `PaperTrailDiff::ValueChange`
463
+ - `PaperTrailDiff::TraversalEntry`
395
464
  - `PaperTrailDiff::RecordReference`
396
465
  - `PaperTrailDiff::RecordSnapshot`
397
466
  - `PaperTrailDiff::RecordChange`
@@ -404,11 +473,13 @@ The public result types are:
404
473
  They expose readers, are frozen after construction, and provide deterministic
405
474
  `to_h` output. Structural hash keys are symbols; attribute and association
406
475
  names are strings. Attribute values retain their Ruby types. `RecordChange#record`
407
- is a `RecordReference` with `type` and `id` readers. `Step` itself is frozen but
408
- intentionally retains the original, potentially mutable PaperTrail version
409
- objects for metadata access; `Step#to_h` emits only their IDs. `ActivityStep`
410
- instead retains only immutable boundary metadata and serializes as
411
- `{ from: ..., to: ..., diff: ... }`.
476
+ is a `RecordReference` with `type` and `id` readers. `TraversalEntry#record` and
477
+ `#association` return the final components of their corresponding paths. `Step`
478
+ itself is frozen but intentionally retains the original, potentially mutable
479
+ PaperTrail version objects for metadata access; its new boundary readers provide
480
+ an immutable presentation interface, while `Step#to_h` continues to emit only
481
+ version IDs. `ActivityStep` retains only immutable boundary metadata and
482
+ serializes as `{ from: ..., to: ..., diff: ... }`.
412
483
 
413
484
  ## Historical correctness and limitations
414
485
 
@@ -459,6 +530,9 @@ mise exec -- act push -j quality --matrix ruby:4.0 --matrix paper_trail:17
459
530
 
460
531
  The default Rake task runs core specs without PT-AT loaded, association specs in
461
532
  a separate process, RuboCop, generated-signature verification, and Steep.
533
+ Selected Ruby blocks in this README and the quickstart are executed in those
534
+ same isolated test sessions; the `<!-- executable:... -->` marker opts a block
535
+ into the appropriate stateful session.
462
536
  Inline `#:` annotations in `lib/` generate the committed RBS files under
463
537
  `sig/generated/`.
464
538
 
@@ -9,6 +9,9 @@ module PaperTrailDiff
9
9
  attr_reader :item_type #: String
10
10
  attr_reader :item_id #: untyped
11
11
  attr_reader :recorded_at #: untyped
12
+ attr_reader :event #: String?
13
+ attr_reader :whodunnit #: untyped
14
+ attr_reader :record #: RecordReference
12
15
 
13
16
  class << self
14
17
  #: (untyped) -> ActivityBoundary
@@ -18,7 +21,9 @@ module PaperTrailDiff
18
21
  version_id: version.id,
19
22
  item_type: version.item_type,
20
23
  item_id: version.item_id,
21
- recorded_at: version.created_at
24
+ recorded_at: version.created_at,
25
+ event: version.event,
26
+ whodunnit: version.whodunnit
22
27
  )
23
28
  end
24
29
 
@@ -34,16 +39,37 @@ module PaperTrailDiff
34
39
  end
35
40
  end
36
41
 
37
- #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped) -> void
38
- def initialize(kind:, version_id:, item_type:, item_id:, recorded_at:)
42
+ #: (kind: Symbol, version_id: untyped, item_type: untyped, item_id: untyped, recorded_at: untyped, ?event: untyped, ?whodunnit: untyped) -> void
43
+ def initialize( # rubocop:disable Metrics/ParameterLists
44
+ kind:,
45
+ version_id:,
46
+ item_type:,
47
+ item_id:,
48
+ recorded_at:,
49
+ event: nil,
50
+ whodunnit: nil
51
+ )
39
52
  @kind = kind
40
53
  @version_id = Support.immutable_copy(version_id)
41
54
  @item_type = Support.immutable_copy(item_type.to_s)
42
55
  @item_id = Support.immutable_copy(item_id)
43
56
  @recorded_at = Support.immutable_copy(recorded_at)
57
+ @event = Support.immutable_copy(event&.to_s)
58
+ @whodunnit = Support.immutable_copy(whodunnit)
59
+ @record = RecordReference.new(type: @item_type, id: @item_id)
44
60
  freeze
45
61
  end
46
62
 
63
+ #: () -> bool
64
+ def version?
65
+ kind == :version
66
+ end
67
+
68
+ #: () -> bool
69
+ def current?
70
+ kind == :current
71
+ end
72
+
47
73
  #: () -> Hash[Symbol, untyped]
48
74
  def to_h
49
75
  {
@@ -70,6 +96,11 @@ module PaperTrailDiff
70
96
  freeze
71
97
  end
72
98
 
99
+ #: () -> bool
100
+ def empty?
101
+ diff.empty?
102
+ end
103
+
73
104
  #: () -> Hash[Symbol, untyped]
74
105
  def to_h
75
106
  {
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+ # rbs_inline: enabled
3
+
4
+ module PaperTrailDiff
5
+ # Walks nested singular and collection association differences.
6
+ class AssociationDiffTraversal < TraversalEmitter
7
+ #: (^(TraversalEntry) -> void) -> void
8
+ def initialize(receiver)
9
+ super
10
+ @snapshots = SnapshotTraversal.new(receiver)
11
+ end
12
+
13
+ #: (association_diffs, association_path: traversal_association_path, record_path: traversal_record_path) -> void
14
+ def call(associations, association_path:, record_path:)
15
+ associations.each do |name, association|
16
+ child_path = association_path + [name]
17
+ walk_association(association, child_path, record_path)
18
+ end
19
+ end
20
+
21
+ #: (Hash[String, ValueChange], association_path: traversal_association_path, record_path: traversal_record_path, association_kind: Symbol?) -> void
22
+ def attributes(changes, association_path:, record_path:, association_kind:)
23
+ changes.each do |name, change|
24
+ emit_attribute(name, change, association_path, record_path, association_kind)
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ # @rbs @snapshots: SnapshotTraversal
31
+
32
+ #: (association_diff, traversal_association_path, traversal_record_path) -> void
33
+ def walk_association(association, association_path, record_path)
34
+ if association.is_a?(CollectionAssociationDiff)
35
+ walk_collection(association, association_path, record_path)
36
+ else
37
+ walk_to_one(association, association_path, record_path)
38
+ end
39
+ end
40
+
41
+ #: (String, ValueChange, traversal_association_path, traversal_record_path, Symbol?) -> void
42
+ def emit_attribute(name, change, association_path, record_path, association_kind)
43
+ emit(
44
+ kind: :attribute_changed,
45
+ context: :change,
46
+ association_path: association_path,
47
+ record_path: record_path,
48
+ association_kind: association_kind,
49
+ attribute: name,
50
+ value: change
51
+ )
52
+ end
53
+
54
+ #: (CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
55
+ def walk_collection(association, association_path, record_path)
56
+ walk_memberships(
57
+ association.added, :record_added, :after,
58
+ association, association_path, record_path
59
+ )
60
+ walk_memberships(
61
+ association.removed, :record_removed, :before,
62
+ association, association_path, record_path
63
+ )
64
+ association.changed.each do |change|
65
+ walk_record_change(change, association.kind, association_path, record_path)
66
+ end
67
+ end
68
+
69
+ #: (Array[RecordSnapshot], Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
70
+ def walk_memberships( # rubocop:disable Metrics/ParameterLists
71
+ snapshots, kind, state, association, association_path, record_path
72
+ )
73
+ snapshots.each do |snapshot|
74
+ walk_membership(snapshot, kind, state, association, association_path, record_path)
75
+ end
76
+ end
77
+
78
+ #: (RecordSnapshot, Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
79
+ def walk_membership( # rubocop:disable Metrics/ParameterLists
80
+ snapshot, kind, state, association, association_path, record_path
81
+ )
82
+ child_records = record_path + [snapshot.reference]
83
+ emit_membership(snapshot, kind, state, association, association_path, child_records)
84
+ @snapshots.call(
85
+ snapshot,
86
+ association_path: association_path,
87
+ record_path: child_records,
88
+ state: state,
89
+ association_kind: association.kind,
90
+ include_record: false
91
+ )
92
+ end
93
+
94
+ #: (RecordSnapshot, Symbol, traversal_state, CollectionAssociationDiff, traversal_association_path, traversal_record_path) -> void
95
+ def emit_membership( # rubocop:disable Metrics/ParameterLists
96
+ snapshot, kind, state, association, association_path, record_path
97
+ )
98
+ emit(
99
+ kind: kind,
100
+ context: :change,
101
+ association_path: association_path,
102
+ record_path: record_path,
103
+ association_kind: association.kind,
104
+ state: state,
105
+ value: snapshot
106
+ )
107
+ end
108
+
109
+ #: (ToOneAssociationDiff, traversal_association_path, traversal_record_path) -> void
110
+ def walk_to_one(association, association_path, record_path)
111
+ if association.relationship
112
+ walk_relationship(association.relationship, association.kind, association_path, record_path)
113
+ elsif association.changed
114
+ walk_record_change(association.changed, association.kind, association_path, record_path)
115
+ end
116
+ end
117
+
118
+ #: (ValueChange, Symbol, traversal_association_path, traversal_record_path) -> void
119
+ def walk_relationship(change, association_kind, association_path, record_path)
120
+ emit_relationship(change, association_kind, association_path, record_path)
121
+ walk_related(change.from, :before, association_kind, association_path, record_path)
122
+ walk_related(change.to, :after, association_kind, association_path, record_path)
123
+ end
124
+
125
+ #: (ValueChange, Symbol, traversal_association_path, traversal_record_path) -> void
126
+ def emit_relationship(change, association_kind, association_path, record_path)
127
+ emit(
128
+ kind: relationship_kind(change),
129
+ context: :change,
130
+ association_path: association_path,
131
+ record_path: record_path,
132
+ association_kind: association_kind,
133
+ value: change
134
+ )
135
+ end
136
+
137
+ #: (RecordSnapshot?, traversal_state, Symbol, traversal_association_path, traversal_record_path) -> void
138
+ def walk_related(snapshot, state, association_kind, association_path, record_path)
139
+ return unless snapshot
140
+
141
+ @snapshots.call(
142
+ snapshot,
143
+ association_path: association_path,
144
+ record_path: record_path + [snapshot.reference],
145
+ state: state,
146
+ association_kind: association_kind
147
+ )
148
+ end
149
+
150
+ #: (ValueChange) -> Symbol
151
+ def relationship_kind(change)
152
+ return :relationship_added unless change.from
153
+ return :relationship_removed unless change.to
154
+
155
+ :relationship_replaced
156
+ end
157
+
158
+ #: (RecordChange, Symbol, traversal_association_path, traversal_record_path) -> void
159
+ def walk_record_change(change, association_kind, association_path, record_path)
160
+ child_records = record_path + [change.record]
161
+ emit_record_change(change, association_kind, association_path, child_records)
162
+ attributes(
163
+ change.attributes,
164
+ association_path: association_path,
165
+ record_path: child_records,
166
+ association_kind: association_kind
167
+ )
168
+ call(change.associations, association_path: association_path, record_path: child_records)
169
+ end
170
+
171
+ #: (RecordChange, Symbol, traversal_association_path, traversal_record_path) -> void
172
+ def emit_record_change(change, association_kind, association_path, record_path)
173
+ emit(
174
+ kind: :record_changed,
175
+ context: :change,
176
+ association_path: association_path,
177
+ record_path: record_path,
178
+ association_kind: association_kind,
179
+ value: change
180
+ )
181
+ end
182
+ end
183
+ private_constant :AssociationDiffTraversal
184
+ end