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