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 +4 -4
- data/LICENSE +21 -0
- data/lib/tree_diff.rb +44 -36
- data/test/helper.rb +76 -0
- data/test/tree_diff_test.rb +122 -6
- data/tree_diff.gemspec +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e3699e4d25cd4ef5e40056955c4cb5a72f34d022d91d0cdcf1987cc285f338be
|
4
|
+
data.tar.gz: 0f9da5732ba16921b1dfbd46e16051618787b6369a287536f8ced41c8a137282
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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.
|
49
|
-
|
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 |
|
101
|
-
if
|
102
|
-
|
103
|
-
|
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(
|
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
|
124
|
-
source_value =
|
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
|
-
|
127
|
-
source_value =
|
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
|
-
|
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(:
|
163
|
-
object_and_method.fetch(:
|
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(
|
178
|
-
result, reference_result = follow_call_chain(
|
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
|
-
{
|
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
|
data/test/tree_diff_test.rb
CHANGED
@@ -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
|
-
|
11
|
-
|
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]
|
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]}
|
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.
|
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.
|
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
|