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 +4 -4
- data/.yardopts +1 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +1 -1
- data/README.md +43 -0
- data/Rakefile +7 -0
- data/bin/rake +29 -0
- data/lib/tree_diff.rb +105 -26
- data/test/tree_diff_test.rb +15 -2
- data/tree_diff.gemspec +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 36d4d811a88254040e9ccd3693b1e4c5e667cdf83c5cfa14b3ec1fa94a52f23f
|
4
|
+
data.tar.gz: 4e6ab356040d07e5db26f5f828456e4b52196e4202e39c32dfb3c17f8d23ef9d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 6c067170e623ee334dea672c92f28ec2dc8692facc9d8a139353b541fc7ce67e0faef09dea321ea49465b31ff3b086f7e0f33c1970b43b6d7a8632fab1493ad4
|
7
|
+
data.tar.gz: 72c485c7563360466b9f82210c2ba7505a90d663a7569877597e9813c3ffdfeddca95e3cd017520d7b0319533f3895cda76bd02d938338a23fd2942c81fee7e9
|
data/.yardopts
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--markup markdown
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
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
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
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
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(:@@
|
31
|
-
class_variable_set(:@@
|
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
|
-
|
41
|
-
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
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.
|
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.
|
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 =
|
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,
|
data/test/tree_diff_test.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
require_relative '../lib/tree_diff'
|
2
|
-
|
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.
|
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.
|
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
|