tree_diff 1.1.0 → 1.1.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: e3699e4d25cd4ef5e40056955c4cb5a72f34d022d91d0cdcf1987cc285f338be
4
- data.tar.gz: 0f9da5732ba16921b1dfbd46e16051618787b6369a287536f8ced41c8a137282
3
+ metadata.gz: 36d4d811a88254040e9ccd3693b1e4c5e667cdf83c5cfa14b3ec1fa94a52f23f
4
+ data.tar.gz: 4e6ab356040d07e5db26f5f828456e4b52196e4202e39c32dfb3c17f8d23ef9d
5
5
  SHA512:
6
- metadata.gz: 8183fd052ac83c3142b200caf071facbeb3be93585e184456f6d78a1967fda9986869997284e605221bc0111272bc018b77fbc5bc1f4d20a5417634f438c4e3c
7
- data.tar.gz: dfb3b3ae7a125ecdba7224f1c5036499e81b2fbbc8bf9ceec9773a09796896c9e5c116816df78c198d5366a337dd86c118208d540645a72f51f55cda3640a2ae
6
+ metadata.gz: 6c067170e623ee334dea672c92f28ec2dc8692facc9d8a139353b541fc7ce67e0faef09dea321ea49465b31ff3b086f7e0f33c1970b43b6d7a8632fab1493ad4
7
+ data.tar.gz: 72c485c7563360466b9f82210c2ba7505a90d663a7569877597e9813c3ffdfeddca95e3cd017520d7b0319533f3895cda76bd02d938338a23fd2942c81fee7e9
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --markup markdown
data/Gemfile CHANGED
@@ -4,5 +4,5 @@ group :test do
4
4
  gem 'minitest'
5
5
  gem 'm'
6
6
  gem 'sqlite3'
7
- gem 'activerecord'
7
+ gem 'activerecord', '~> 5.2'
8
8
  end
data/Gemfile.lock CHANGED
@@ -31,7 +31,7 @@ PLATFORMS
31
31
  ruby
32
32
 
33
33
  DEPENDENCIES
34
- activerecord
34
+ activerecord (~> 5.2)
35
35
  m
36
36
  minitest
37
37
  sqlite3
data/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # Tree Diff
2
+
3
+ Compare large object trees. Like a generic and standalone ActiveModel::Dirty, though completely ORM agnostic.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ gem install tree_diff
9
+ ```
10
+
11
+ ## Getting Started
12
+
13
+ 1. Define a diff class that inherits from `TreeDiff`. Pass an array of hashes and arrays to define your object tree to `observe`.
14
+
15
+ This format is just like how sets of nested attributes are passed to strong params.
16
+
17
+ ```ruby
18
+ class MyDiffClass < TreeDiff
19
+ observe :status, :created_at, :user_id
20
+ items: [:status, :description, :cost],
21
+ starting_location: [:latitude, :longitude, :updated_at,
22
+ address: [:city, :state, :country]
23
+ end
24
+ ```
25
+
26
+ 2. Instantiate your diff class and pass it the object you want to compare just before mutating it. For example, controller usage:
27
+
28
+ ```ruby
29
+ class ThingsController
30
+ def update
31
+ thing = Thing.find(params[:id])
32
+ my_diff = MyDiffClass.new(thing)
33
+
34
+ if thing.update(thing_params)
35
+ handle_stuff if my_diff.saw_any_change?
36
+ redirect_to thing, notice: 'Updated thing.'
37
+ else
38
+ # ...
39
+ end
40
+ end
41
+ end
42
+ ```
43
+
data/Rakefile ADDED
@@ -0,0 +1,7 @@
1
+ require 'rake/testtask'
2
+ Rake::TestTask.new(:test) do |test|
3
+ test.warning = true
4
+ test.pattern = 'test/**/*_test.rb'
5
+ end
6
+
7
+ task :default => :test
data/bin/rake ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ #
5
+ # This file was generated by Bundler.
6
+ #
7
+ # The application 'rake' is installed as part of a gem, and
8
+ # this file is here to facilitate running it.
9
+ #
10
+
11
+ require "pathname"
12
+ ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13
+ Pathname.new(__FILE__).realpath)
14
+
15
+ bundle_binstub = File.expand_path("../bundle", __FILE__)
16
+
17
+ if File.file?(bundle_binstub)
18
+ if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19
+ load(bundle_binstub)
20
+ else
21
+ abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22
+ Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23
+ end
24
+ end
25
+
26
+ require "rubygems"
27
+ require "bundler/setup"
28
+
29
+ load Gem.bin_path("rake", "rake")
data/lib/tree_diff.rb CHANGED
@@ -1,4 +1,5 @@
1
1
  class TreeDiff
2
+ # A container for each copied attribute from the source object by each path.
2
3
  Mold = Class.new
3
4
 
4
5
  class Change
@@ -11,36 +12,65 @@ class TreeDiff
11
12
  end
12
13
  end
13
14
 
14
- def self.original_observations
15
- class_variable_set(:@@original_observations, nil) unless class_variable_defined?(:@@original_observations)
16
- class_variable_get(:@@original_observations)
17
- end
18
-
19
- def self.observations
20
- class_variable_set(:@@observations, nil) unless class_variable_defined?(:@@observations)
21
- class_variable_get(:@@observations)
22
- end
23
-
24
- def self.conditions
25
- class_variable_set(:@@conditions, nil) unless class_variable_defined?(:@@conditions)
26
- class_variable_get(:@@conditions)
27
- end
28
-
15
+ # Defines the full tree of relationships and attributes this TreeDiff class observes. Pass a structure of arrays and
16
+ # hashes, similar to a strong_params `#permit` definition.
17
+ #
18
+ # ```
19
+ # class MyDiffClass < TreeDiff
20
+ # observe :order_number, :available_at, line_items: [:description, :price, tags: [:name]]
21
+ # end
22
+ # ```
23
+ #
24
+ # @param [Array] defs Observation attribute definitions. Anything not in this list will be skipped by the diff.
25
+ # @return [void]
29
26
  def self.observe(*defs)
30
- class_variable_set(:@@original_observations, defs)
31
- class_variable_set(:@@observations, [])
27
+ class_variable_set(:@@observations, defs)
28
+ class_variable_set(:@@attribute_paths, [])
32
29
  observe_chain [], defs
33
30
  end
34
31
 
32
+ # Adds a condition to an attribute that dictates whether an attribute is compared. The given block is invoked
33
+ # just before trying to call the attribute to get a comparable value. Called twice per attribute, once for the
34
+ # old value and once for the new.
35
+ #
36
+ # @yieldparam original_object The original object being compared.
37
+ # @yieldreturn [Boolean] Whether this attribute should be compared.
38
+ #
39
+ # ```
40
+ # class MyDiffClass < TreeDiff
41
+ # observe details: :order_status
42
+ #
43
+ # condition [:details, :order_status] do |order|
44
+ # order.order_status == 'delivered'
45
+ # end
46
+ # end
47
+ #
48
+ # @return [void]
35
49
  def self.condition(path, &condition)
36
50
  class_variable_set(:@@conditions, []) unless conditions
37
51
  conditions << [path, condition]
38
52
  end
39
53
 
40
- def self.condition_for(path)
41
- return unless (condition = conditions.detect { |c| c.first == path })
54
+ # Holds the original object tree definitions passed to `.observe`.
55
+ # @return [Array]
56
+ def self.observations
57
+ class_variable_set(:@@observations, nil) unless class_variable_defined?(:@@observations)
58
+ class_variable_get(:@@observations)
59
+ end
42
60
 
43
- condition.last # A stored block (proc)
61
+ # All paths to be walked expressed as arrays, ending in each observed attribute. These are generated upon
62
+ # calling `.observe`.
63
+ # @return [Array]
64
+ def self.attribute_paths
65
+ class_variable_set(:@@attribute_paths, nil) unless class_variable_defined?(:@@attribute_paths)
66
+ class_variable_get(:@@attribute_paths)
67
+ end
68
+
69
+ # All conditions defined via `.condition`.
70
+ # @return [Array]
71
+ def self.conditions
72
+ class_variable_set(:@@conditions, nil) unless class_variable_defined?(:@@conditions)
73
+ class_variable_get(:@@conditions)
44
74
  end
45
75
 
46
76
  def self.observe_chain(chain, attributes)
@@ -50,7 +80,7 @@ class TreeDiff
50
80
  observe_chain chain + [child_method], keep_as_array(child_attributes)
51
81
  end
52
82
  else
53
- observations << chain + [method_name]
83
+ attribute_paths << chain + [method_name]
54
84
  end
55
85
  end
56
86
  end
@@ -61,32 +91,74 @@ class TreeDiff
61
91
  end
62
92
  private_class_method :keep_as_array
63
93
 
94
+ # Prepares a new comparision. Creates a mold/mock of the object being diffed as the 'old' state according to the
95
+ # relationship tree defined via `.observe`. Each attribute's value is copied via serializing and deserializing
96
+ # via Marshal.
97
+ #
98
+ # @param original_object The object to be compared, before you mutate any of its attributes.
64
99
  def initialize(original_object)
65
100
  check_observations
66
101
 
67
102
  self.class.class_variable_set(:@@conditions, [])
68
- @old_object_as_mold = create_mold Mold.new, original_object, self.class.original_observations
103
+ @old_object_as_mold = create_mold Mold.new, original_object, self.class.observations
69
104
  @current_object = original_object
70
105
  end
71
106
 
107
+ # Check if there is any change at all, otherwise each observed attribute is considered equal before and after
108
+ # the change.
109
+ # @return [Boolean] Whether there was a change
72
110
  def saw_any_change?
73
111
  changes.present?
74
112
  end
75
113
 
114
+ # Walk all observed paths and compare each resulting value. Returns a structure like:
115
+ #
116
+ # ```ruby
117
+ # [{path: [:line_items, :description], old: ["thing", "other thing"], new: ["other thing", "new thing"]},
118
+ # {path: [:line_items, :price_usd_cents], old: [1234, 5678], new: [5678, 1357]},
119
+ # {path: [:line_items, :item_categories, :description], old: ['foo', 'bar'], new: ['foo']}]
120
+ # ```
121
+ #
122
+ # @return [Array] A list of each attribute that has changed. Each element is a hash with keys
123
+ # :path, :old, and :new.
76
124
  def changes
77
125
  iterate_observations(@old_object_as_mold, @current_object)
78
126
  end
79
127
 
128
+ # Compare all observed paths, find the given path, and check if its value has changed.
129
+ #
130
+ # @return [Boolean] Whether the path is changed.
131
+ def changed?(path)
132
+ changes_at(path).present?
133
+ end
134
+
135
+ # Walk all observed paths and compare each resulting value. Returns a structure like:
136
+ #
137
+ # @example Return all "old" values
138
+ # diff = MyDiffClass.new(my_object)
139
+ # # Mutate the object...
140
+ # changes = diff.changes_as_objects
141
+ #
142
+ # changes.map(&:old)
143
+ #
144
+ # @return [Array of TreeDiff::Change] A list of each attribute that has changed. Each element is an ojbect with
145
+ # methods :path, :old, and :new.
80
146
  def changes_as_objects
81
147
  changes.map { |c| Change.new(c.fetch(:path), c.fetch(:old), c.fetch(:new)) }
82
148
  end
83
149
 
150
+ # Get a collection of changed paths only.
151
+ # @return [Array of Arrays]
84
152
  def changed_paths
85
153
  changes_as_objects.map(&:path)
86
154
  end
87
155
 
156
+ # Find a change by its path.
157
+ #
158
+ # @return [TreeDiff::Change] The change at the given path, or `nil` if no change.
88
159
  def changes_at(path)
89
- changes_as_objects.detect { |c| c.path == path }
160
+ arrayed_path = Array(path)
161
+ changes_as_objects.detect { |c| c.path == arrayed_path }
90
162
  end
91
163
 
92
164
  private
@@ -96,7 +168,7 @@ class TreeDiff
96
168
  raise 'TreeDiff is an abstract class - write a child class with observations'
97
169
  end
98
170
 
99
- if !self.class.original_observations || self.class.original_observations.empty?
171
+ if !self.class.observations || self.class.observations.empty?
100
172
  raise 'you need to define some observations first'
101
173
  end
102
174
  end
@@ -137,7 +209,7 @@ class TreeDiff
137
209
  end
138
210
 
139
211
  def iterate_observations(mold, current_object)
140
- self.class.observations.each.with_object([]) do |path, changes|
212
+ self.class.attribute_paths.each.with_object([]) do |path, changes|
141
213
  old_obj_and_method = try_path mold, path
142
214
  new_obj_and_method = try_path current_object, path, mold
143
215
 
@@ -165,7 +237,7 @@ class TreeDiff
165
237
 
166
238
  def method_caller_with_condition(path)
167
239
  ->(object_and_method) do
168
- condition = self.class.condition_for(path)
240
+ condition = condition_for(path)
169
241
 
170
242
  if !condition || condition.call(object_and_method.fetch(:receiver))
171
243
  object_and_method.fetch(:receiver).public_send(object_and_method.fetch(:method_name))
@@ -173,6 +245,13 @@ class TreeDiff
173
245
  end
174
246
  end
175
247
 
248
+ def condition_for(path)
249
+ return unless (condition = self.class.conditions.detect { |c| c.first == path })
250
+
251
+ condition.last # A stored block (proc)
252
+ end
253
+
254
+
176
255
  # By design this tries to be as loosely typed and flexible as possible. Sometimes, calling an attribute
177
256
  # name on a collection of them yields an enumerable object. For example, active record's enum column returns
178
257
  # a hash indicating the enumeration definition. That would lead to incorrectly comparing this value, a hash,
@@ -1,5 +1,5 @@
1
1
  require_relative '../lib/tree_diff'
2
- require 'helper'
2
+ require_relative 'helper'
3
3
  require 'minitest/autorun'
4
4
 
5
5
  class TreeDiffTest < Minitest::Test
@@ -38,6 +38,19 @@ class TreeDiffTest < Minitest::Test
38
38
 
39
39
  assert_equal [[:at]], diff.changed_paths
40
40
  assert_equal [{path: [:at], old: Time.gm(2000, 1, 1, 12, 30), new: Time.gm(2000, 1, 1, 1, 30)}], diff.changes
41
+
42
+ assert_equal true, diff.changed?([:at])
43
+ assert_equal true, diff.changed?(:at)
44
+ assert_equal false, diff.changed?([:quantity])
45
+ assert_equal false, diff.changed?(:quantity)
46
+
47
+ change = diff.changes_at(:at)
48
+ assert_kind_of TreeDiff::Change, change
49
+ assert_equal [:at], change.path
50
+ assert_equal Time.gm(2000, 1, 1, 12, 30), change.old
51
+ assert_equal Time.gm(2000, 1, 1, 1, 30), change.new
52
+
53
+ assert_nil diff.changes_at(:quantity)
41
54
  end
42
55
 
43
56
  def test_big_tree
@@ -68,7 +81,7 @@ class TreeDiffTest < Minitest::Test
68
81
 
69
82
  def test_polymorphic_path
70
83
  truck = Truck.new(number: 123, capacity: 55)
71
- other_truck = Truck.new(number: 456, capacity: 25)
84
+ # other_truck = Truck.new(number: 456, capacity: 25)
72
85
  order = Order.create!(number: 'XYZ123', trucks: [truck])
73
86
 
74
87
  diff = OrderVehicleDiff.new(order)
data/tree_diff.gemspec CHANGED
@@ -1,6 +1,6 @@
1
1
  Gem::Specification.new do |s|
2
2
  s.name = 'tree_diff'
3
- s.version = '1.1.0'
3
+ s.version = '1.1.1'
4
4
  s.date = '2019-07-13'
5
5
  s.summary = "Observe attribute changes in big complex object trees, like a generic & standalone ActiveModel::Dirty"
6
6
  s.description = "Given a tree of relationships in a similar format to strong params, analyzes attribute changes by " \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tree_diff
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Nelson
@@ -75,9 +75,13 @@ extensions: []
75
75
  extra_rdoc_files: []
76
76
  files:
77
77
  - ".ruby-version"
78
+ - ".yardopts"
78
79
  - Gemfile
79
80
  - Gemfile.lock
80
81
  - LICENSE
82
+ - README.md
83
+ - Rakefile
84
+ - bin/rake
81
85
  - lib/tree_diff.rb
82
86
  - test/helper.rb
83
87
  - test/tree_diff_test.rb