tree_diff 1.0.0 → 1.1.0

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: 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