tree_diff 1.0.0 → 1.1.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: 0e1a59222b215b096b927b8278b6be165ff26d96f6e7c9a6714996b459b8f5b4
4
- data.tar.gz: cb2363e020e496f6f8a8380fed116f0dbeb544140145e8584f3840bdd17109c8
3
+ metadata.gz: e3699e4d25cd4ef5e40056955c4cb5a72f34d022d91d0cdcf1987cc285f338be
4
+ data.tar.gz: 0f9da5732ba16921b1dfbd46e16051618787b6369a287536f8ced41c8a137282
5
5
  SHA512:
6
- metadata.gz: '08d71f0379f871769f2a039d5f1856252f48bbbab5574fa2b4ad7d7939a919e56a608ee8527a3ff4496a24615f517620b4b0a6077ec9ab88ef1e370328d0ca3e'
7
- data.tar.gz: c30c915a69acb00c0399909ae57143d732de9d82535373ff6b88270aea8f7ca539c77cfb0d784be2ec09803d725897466aae4e24b11cc44fd5807b176e636378
6
+ metadata.gz: 8183fd052ac83c3142b200caf071facbeb3be93585e184456f6d78a1967fda9986869997284e605221bc0111272bc018b77fbc5bc1f4d20a5417634f438c4e3c
7
+ data.tar.gz: dfb3b3ae7a125ecdba7224f1c5036499e81b2fbbc8bf9ceec9773a09796896c9e5c116816df78c198d5366a337dd86c118208d540645a72f51f55cda3640a2ae
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019 Michael Nelson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/lib/tree_diff.rb CHANGED
@@ -32,11 +32,22 @@ class TreeDiff
32
32
  observe_chain [], defs
33
33
  end
34
34
 
35
+ def self.condition(path, &condition)
36
+ class_variable_set(:@@conditions, []) unless conditions
37
+ conditions << [path, condition]
38
+ end
39
+
40
+ def self.condition_for(path)
41
+ return unless (condition = conditions.detect { |c| c.first == path })
42
+
43
+ condition.last # A stored block (proc)
44
+ end
45
+
35
46
  def self.observe_chain(chain, attributes)
36
47
  attributes.each do |method_name|
37
48
  if method_name.respond_to?(:keys)
38
49
  method_name.each do |child_method, child_attributes|
39
- observe_chain chain + [child_method], child_attributes
50
+ observe_chain chain + [child_method], keep_as_array(child_attributes)
40
51
  end
41
52
  else
42
53
  observations << chain + [method_name]
@@ -45,16 +56,10 @@ class TreeDiff
45
56
  end
46
57
  private_class_method :observe_chain
47
58
 
48
- def self.condition(path, &condition)
49
- class_variable_set(:@@conditions, []) unless conditions
50
- conditions << [path, condition]
51
- end
52
-
53
- def self.condition_for(path)
54
- return unless (condition = conditions.detect { |c| c.first == path })
55
-
56
- condition.last # A stored block (proc)
59
+ def self.keep_as_array(input)
60
+ input.is_a?(Array) ? input : [input]
57
61
  end
62
+ private_class_method :keep_as_array
58
63
 
59
64
  def initialize(original_object)
60
65
  check_observations
@@ -91,40 +96,42 @@ class TreeDiff
91
96
  raise 'TreeDiff is an abstract class - write a child class with observations'
92
97
  end
93
98
 
94
- if self.class.original_observations.empty?
99
+ if !self.class.original_observations || self.class.original_observations.empty?
95
100
  raise 'you need to define some observations first'
96
101
  end
97
102
  end
98
103
 
99
104
  def create_mold(mold, source_object, attributes)
100
- attributes.each do |a|
101
- if a.respond_to?(:keys)
102
- a.each do |key, child_attributes|
103
- source_value = source_object.public_send(key)
104
-
105
- mold_or_molds = if source_value.respond_to?(:each) # 1:many
106
- source_value.map do |val|
107
- create_mold(Mold.new, val, child_attributes)
108
- end
109
- else # 1:1
110
- create_mold(Mold.new, source_value, child_attributes)
111
- end
112
-
113
- mold.define_singleton_method(key) { mold_or_molds }
105
+ attributes.each do |attribute|
106
+ if attribute.respond_to?(:keys)
107
+ attribute.each do |attribute_name, child_attributes|
108
+ mold_or_molds = mold_relationship(source_object, attribute_name, child_attributes)
109
+ mold.define_singleton_method(attribute_name) { mold_or_molds }
114
110
  end
115
111
  else
116
- mold.define_singleton_method(a, &copied_value(source_object, a))
112
+ mold.define_singleton_method(attribute, &call_and_copy_value(source_object, attribute))
117
113
  end
118
114
  end
119
115
 
120
116
  mold
121
117
  end
122
118
 
123
- def copied_value(object, attribute_name)
124
- source_value = object.public_send(attribute_name)
119
+ def mold_relationship(source_object, relationship_name, relationship_attributes)
120
+ source_value = source_object.public_send(relationship_name)
121
+ relationship_attributes = [relationship_attributes] unless relationship_attributes.is_a?(Array)
122
+
123
+ if source_value.respond_to?(:each) # 1:many
124
+ source_value.map do |v|
125
+ create_mold(Mold.new, v, relationship_attributes)
126
+ end
127
+ else # 1:1
128
+ create_mold(Mold.new, source_value, relationship_attributes)
129
+ end
130
+ end
125
131
 
126
- # TODO This properly clones hashes but is a dependency on Rails. Figure out a better way.
127
- source_value = source_value.deep_dup
132
+ def call_and_copy_value(receiver, method_name)
133
+ source_value = receiver.public_send(method_name)
134
+ source_value = Marshal.load(Marshal.dump(source_value)) # Properly clones hashes
128
135
 
129
136
  -> { source_value }
130
137
  end
@@ -145,7 +152,8 @@ class TreeDiff
145
152
  callable = method_caller_with_condition(path)
146
153
 
147
154
  if object_and_method.is_a?(Array)
148
- object_and_method.each.with_object([]) do |r, c|
155
+ # TODO: Flatten is kind of a hack? Revisit this..
156
+ object_and_method.flatten.each.with_object([]) do |r, c|
149
157
  result = callable.(r)
150
158
  c << result if result
151
159
  end
@@ -159,8 +167,8 @@ class TreeDiff
159
167
  ->(object_and_method) do
160
168
  condition = self.class.condition_for(path)
161
169
 
162
- if !condition || condition.call(object_and_method.fetch(:object))
163
- object_and_method.fetch(:object).public_send(object_and_method.fetch(:method_name))
170
+ if !condition || condition.call(object_and_method.fetch(:receiver))
171
+ object_and_method.fetch(:receiver).public_send(object_and_method.fetch(:method_name))
164
172
  end
165
173
  end
166
174
  end
@@ -174,8 +182,8 @@ class TreeDiff
174
182
  # the call chain on not only the changed/current object, but that original object too. The mold is created
175
183
  # with correct 1:many relationships and does not have the aforementioned problem, so by verifying the path
176
184
  # against the mold first, we avoid that issue.
177
- def try_path(object, path, mold_as_reference = nil)
178
- result, reference_result = follow_call_chain(object, path, mold_as_reference)
185
+ def try_path(receiver, path, mold_as_reference = nil)
186
+ result, reference_result = follow_call_chain(receiver, path, mold_as_reference)
179
187
 
180
188
  if result.respond_to?(:each)
181
189
  result.map.with_index do |o, idx|
@@ -183,7 +191,7 @@ class TreeDiff
183
191
  try_path(o, path[1..-1], ref)
184
192
  end
185
193
  else
186
- {object: result, method_name: path.last}
194
+ {receiver: result, method_name: path.last}
187
195
  end
188
196
  end
189
197
 
data/test/helper.rb CHANGED
@@ -15,14 +15,90 @@ ActiveRecord::Schema.define do
15
15
  t.integer :price_usd_cents
16
16
  t.belongs_to :order, index: true
17
17
  end
18
+
19
+ create_table :item_categories, force: true do |t|
20
+ t.string :short_name
21
+ t.string :description
22
+ t.integer :ordering
23
+ t.belongs_to :line_item, index: true
24
+ end
25
+
26
+ create_table :deliveries, force: true do |t|
27
+ t.time :at
28
+ t.integer :quantity
29
+ end
30
+
31
+ create_table :orders_vehicles, force: true do |t|
32
+ t.datetime :created_at
33
+ t.belongs_to :order, index: true
34
+ t.belongs_to :vehicle, polymorphic: true
35
+ end
36
+
37
+ create_table :trucks, force: true do |t|
38
+ t.integer :number
39
+ t.integer :capacity
40
+ end
41
+
42
+ create_table :delivery_bikes, force: true do |t|
43
+ t.integer :max_speed
44
+ t.string :maintenance_status
45
+ end
18
46
  end
19
47
 
20
48
  # Define the models
21
49
  class Order < ActiveRecord::Base
22
50
  has_many :line_items, inverse_of: :order
51
+ has_many :orders_vehicles
52
+
53
+ has_many :trucks, through: :orders_vehicles, source: :vehicle, source_type: "Vehicle", class_name: "Truck"
54
+
23
55
  accepts_nested_attributes_for :line_items, allow_destroy: true
24
56
  end
25
57
 
26
58
  class LineItem < ActiveRecord::Base
27
59
  belongs_to :order, inverse_of: :line_items
60
+ has_many :item_categories
61
+
62
+ accepts_nested_attributes_for :item_categories, allow_destroy: true
63
+ end
64
+
65
+ class ItemCategory < ActiveRecord::Base
66
+ belongs_to :line_item, inverse_of: :item_categories
67
+ end
68
+
69
+ class Delivery < ActiveRecord::Base
70
+ end
71
+
72
+ class OrdersVehicle < ActiveRecord::Base
73
+ belongs_to :order, inverse_of: :orders_vehicles
74
+ belongs_to :vehicle, polymorphic: true
75
+ end
76
+
77
+ class Truck < ActiveRecord::Base
78
+ end
79
+
80
+ class DeliveryBike < ActiveRecord::Base
81
+ end
82
+
83
+ module HashInitialization
84
+ def initialize(hash)
85
+ hash.each do |k, v|
86
+ public_send("#{k}=", v)
87
+ end
88
+ end
89
+ end
90
+
91
+ class Table
92
+ include HashInitialization
93
+ attr_accessor :wood_finish
94
+ end
95
+
96
+ class WoodFinish
97
+ include HashInitialization
98
+ attr_accessor :color, :cost, :applied_at, :blemishes, :cost_mapping
99
+ end
100
+
101
+ class Blemish
102
+ include HashInitialization
103
+ attr_accessor :severity
28
104
  end
@@ -3,12 +3,48 @@ require 'helper'
3
3
  require 'minitest/autorun'
4
4
 
5
5
  class TreeDiffTest < Minitest::Test
6
+ class DeliveryDiff < TreeDiff
7
+ observe :at, :quantity
8
+ end
9
+
6
10
  class OrderDiff < TreeDiff
7
- observe :number, line_items: [:description, :price_usd_cents]
11
+ observe :number, line_items: [:description, :price_usd_cents, item_categories: [:description]]
12
+ end
13
+
14
+ class OrderVehicleDiff < TreeDiff
15
+ observe trucks: [:number], orders_vehicles: [{vehicle: [:id]}]
16
+ end
17
+
18
+ class OmittingArraysDiff < TreeDiff
19
+ observe :number, orders_vehicles: {vehicle: :id}
8
20
  end
9
21
 
10
- def test_todo
11
- thing = LineItem.new(description: 'thing', price_usd_cents: 1234)
22
+ class TableDiff < TreeDiff
23
+ observe wood_finish: [:color, :cost, :applied_at, :cost_mapping,
24
+ blemishes: [:severity]]
25
+ end
26
+
27
+ class EmptyDiff < TreeDiff
28
+ end
29
+
30
+ def test_simple_one_table
31
+ delivery = Delivery.create!(at: '12:30', quantity: 50)
32
+
33
+ diff = DeliveryDiff.new(delivery)
34
+
35
+ assert_empty diff.changes
36
+
37
+ delivery.update!(at: '1:30')
38
+
39
+ assert_equal [[:at]], diff.changed_paths
40
+ assert_equal [{path: [:at], old: Time.gm(2000, 1, 1, 12, 30), new: Time.gm(2000, 1, 1, 1, 30)}], diff.changes
41
+ end
42
+
43
+ def test_big_tree
44
+ foo_category = ItemCategory.new(description: 'foo')
45
+ bar_category = ItemCategory.new(description: 'bar')
46
+
47
+ thing = LineItem.new(description: 'thing', price_usd_cents: 1234, item_categories: [foo_category, bar_category])
12
48
  other_thing = LineItem.new(description: 'other thing', price_usd_cents: 5678)
13
49
  order = Order.create!(number: 'XYZ123', line_items: [thing, other_thing])
14
50
 
@@ -17,13 +53,93 @@ class TreeDiffTest < Minitest::Test
17
53
  assert_empty diff.changes
18
54
 
19
55
  order.update!(line_items_attributes: [{id: thing.id, description: 'thing', price_usd_cents: 1234, _destroy: true},
20
- {id: other_thing.id, description: 'other thing', price_usd_cents: 5678},
56
+ {id: other_thing.id, description: 'other thing', price_usd_cents: 5678,
57
+ item_categories_attributes: [{description: 'foo'}]},
21
58
  {description: 'new thing', price_usd_cents: 1357}])
22
59
 
23
60
  assert_equal [[:line_items, :description],
24
- [:line_items, :price_usd_cents]], diff.changed_paths
61
+ [:line_items, :price_usd_cents],
62
+ [:line_items, :item_categories, :description]], diff.changed_paths
25
63
 
26
64
  assert_equal [{path: [:line_items, :description], old: ["thing", "other thing"], new: ["other thing", "new thing"]},
27
- {path: [:line_items, :price_usd_cents], old: [1234, 5678], new: [5678, 1357]}], diff.changes
65
+ {path: [:line_items, :price_usd_cents], old: [1234, 5678], new: [5678, 1357]},
66
+ {path: [:line_items, :item_categories, :description], old: ['foo', 'bar'], new: ['foo']}], diff.changes
67
+ end
68
+
69
+ def test_polymorphic_path
70
+ truck = Truck.new(number: 123, capacity: 55)
71
+ other_truck = Truck.new(number: 456, capacity: 25)
72
+ order = Order.create!(number: 'XYZ123', trucks: [truck])
73
+
74
+ diff = OrderVehicleDiff.new(order)
75
+
76
+ assert_empty diff.changes
77
+
78
+ order.update!(trucks: [])
79
+
80
+ assert_equal [[:trucks, :number],
81
+ [:orders_vehicles, :vehicle, :id]], diff.changed_paths
82
+
83
+ changes = diff.changes_as_objects
84
+ assert_equal [123], changes.first.old
85
+ assert_equal [], changes.first.new
86
+
87
+ assert_kind_of Integer, changes.last.old.first
88
+ assert_equal [], changes.last.new
89
+ end
90
+
91
+ def test_omitting_arrays_of_child_attributes
92
+ truck = Truck.new(number: 123, capacity: 55)
93
+ order = Order.create!(number: 'XYZ123', trucks: [truck])
94
+
95
+ diff = OmittingArraysDiff.new(order)
96
+
97
+ assert_empty diff.changes
98
+
99
+ order.update!(number: 'ZYX987')
100
+
101
+ assert_equal [{:path => [:number], :old => "XYZ123", :new => "ZYX987"}], diff.changes
102
+ end
103
+
104
+ def test_poro_tree
105
+ blemish = Blemish.new(severity: 'super_bad')
106
+ wood_finish = WoodFinish.new(color: 'mahogany',
107
+ cost: 500.0,
108
+ applied_at: Time.now.utc,
109
+ blemishes: [blemish],
110
+ cost_mapping: {mahogany: 500.0,
111
+ pine: 250.0,
112
+ plywood: 60.0})
113
+
114
+ table = Table.new(wood_finish: wood_finish)
115
+
116
+ diff = TableDiff.new(table)
117
+
118
+ wood_finish = table.wood_finish
119
+ wood_finish.blemishes.first.severity = 'not_that_bad'
120
+ wood_finish.cost_mapping[:pine] = 200.0
121
+
122
+ assert_equal [{:path => [:wood_finish, :cost_mapping],
123
+ :old => {mahogany: 500.0, pine: 250.0, plywood: 60.0},
124
+ :new => {mahogany: 500.0, pine: 200.0, plywood: 60.0}},
125
+ {:path => [:wood_finish, :blemishes, :severity],
126
+ :old => ["super_bad"],
127
+ :new => ["not_that_bad"]}], diff.changes
128
+ end
129
+
130
+ def test_abstract_class
131
+ e = assert_raises RuntimeError do
132
+ TreeDiff.new(Object.new)
133
+ end
134
+
135
+ assert_includes e.message, 'is an abstract'
136
+ end
137
+
138
+ def test_requires_observations
139
+ e = assert_raises RuntimeError do
140
+ EmptyDiff.new(Object.new)
141
+ end
142
+
143
+ assert_includes e.message, 'some observations first'
28
144
  end
29
145
  end
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.0.0'
3
+ s.version = '1.1.0'
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.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michael Nelson
@@ -77,6 +77,7 @@ files:
77
77
  - ".ruby-version"
78
78
  - Gemfile
79
79
  - Gemfile.lock
80
+ - LICENSE
80
81
  - lib/tree_diff.rb
81
82
  - test/helper.rb
82
83
  - test/tree_diff_test.rb