tree_diff 1.1.0 → 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
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