tree_diff 1.1.0 → 1.1.1
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/.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
|