tree_diff 1.0.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 +7 -0
- data/.ruby-version +1 -0
- data/Gemfile +8 -0
- data/Gemfile.lock +40 -0
- data/lib/tree_diff.rb +201 -0
- data/test/helper.rb +28 -0
- data/test/tree_diff_test.rb +29 -0
- data/tree_diff.gemspec +22 -0
- metadata +111 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 0e1a59222b215b096b927b8278b6be165ff26d96f6e7c9a6714996b459b8f5b4
|
4
|
+
data.tar.gz: cb2363e020e496f6f8a8380fed116f0dbeb544140145e8584f3840bdd17109c8
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: '08d71f0379f871769f2a039d5f1856252f48bbbab5574fa2b4ad7d7939a919e56a608ee8527a3ff4496a24615f517620b4b0a6077ec9ab88ef1e370328d0ca3e'
|
7
|
+
data.tar.gz: c30c915a69acb00c0399909ae57143d732de9d82535373ff6b88270aea8f7ca539c77cfb0d784be2ec09803d725897466aae4e24b11cc44fd5807b176e636378
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.5.1
|
data/Gemfile
ADDED
data/Gemfile.lock
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
GEM
|
2
|
+
remote: https://rubygems.org/
|
3
|
+
specs:
|
4
|
+
activemodel (5.2.3)
|
5
|
+
activesupport (= 5.2.3)
|
6
|
+
activerecord (5.2.3)
|
7
|
+
activemodel (= 5.2.3)
|
8
|
+
activesupport (= 5.2.3)
|
9
|
+
arel (>= 9.0)
|
10
|
+
activesupport (5.2.3)
|
11
|
+
concurrent-ruby (~> 1.0, >= 1.0.2)
|
12
|
+
i18n (>= 0.7, < 2)
|
13
|
+
minitest (~> 5.1)
|
14
|
+
tzinfo (~> 1.1)
|
15
|
+
arel (9.0.0)
|
16
|
+
concurrent-ruby (1.1.5)
|
17
|
+
i18n (1.6.0)
|
18
|
+
concurrent-ruby (~> 1.0)
|
19
|
+
m (1.5.1)
|
20
|
+
method_source (>= 0.6.7)
|
21
|
+
rake (>= 0.9.2.2)
|
22
|
+
method_source (0.9.2)
|
23
|
+
minitest (5.11.3)
|
24
|
+
rake (12.3.2)
|
25
|
+
sqlite3 (1.4.1)
|
26
|
+
thread_safe (0.3.6)
|
27
|
+
tzinfo (1.2.5)
|
28
|
+
thread_safe (~> 0.1)
|
29
|
+
|
30
|
+
PLATFORMS
|
31
|
+
ruby
|
32
|
+
|
33
|
+
DEPENDENCIES
|
34
|
+
activerecord
|
35
|
+
m
|
36
|
+
minitest
|
37
|
+
sqlite3
|
38
|
+
|
39
|
+
BUNDLED WITH
|
40
|
+
1.17.3
|
data/lib/tree_diff.rb
ADDED
@@ -0,0 +1,201 @@
|
|
1
|
+
class TreeDiff
|
2
|
+
Mold = Class.new
|
3
|
+
|
4
|
+
class Change
|
5
|
+
attr_reader :path, :old, :new
|
6
|
+
|
7
|
+
def initialize(path, old, new)
|
8
|
+
@path = path
|
9
|
+
@old = old
|
10
|
+
@new = new
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.original_observations
|
15
|
+
class_variable_set(:@@original_observations, nil) unless class_variable_defined?(:@@original_observations)
|
16
|
+
class_variable_get(:@@original_observations)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.observations
|
20
|
+
class_variable_set(:@@observations, nil) unless class_variable_defined?(:@@observations)
|
21
|
+
class_variable_get(:@@observations)
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.conditions
|
25
|
+
class_variable_set(:@@conditions, nil) unless class_variable_defined?(:@@conditions)
|
26
|
+
class_variable_get(:@@conditions)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.observe(*defs)
|
30
|
+
class_variable_set(:@@original_observations, defs)
|
31
|
+
class_variable_set(:@@observations, [])
|
32
|
+
observe_chain [], defs
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.observe_chain(chain, attributes)
|
36
|
+
attributes.each do |method_name|
|
37
|
+
if method_name.respond_to?(:keys)
|
38
|
+
method_name.each do |child_method, child_attributes|
|
39
|
+
observe_chain chain + [child_method], child_attributes
|
40
|
+
end
|
41
|
+
else
|
42
|
+
observations << chain + [method_name]
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
private_class_method :observe_chain
|
47
|
+
|
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)
|
57
|
+
end
|
58
|
+
|
59
|
+
def initialize(original_object)
|
60
|
+
check_observations
|
61
|
+
|
62
|
+
self.class.class_variable_set(:@@conditions, [])
|
63
|
+
@old_object_as_mold = create_mold Mold.new, original_object, self.class.original_observations
|
64
|
+
@current_object = original_object
|
65
|
+
end
|
66
|
+
|
67
|
+
def saw_any_change?
|
68
|
+
changes.present?
|
69
|
+
end
|
70
|
+
|
71
|
+
def changes
|
72
|
+
iterate_observations(@old_object_as_mold, @current_object)
|
73
|
+
end
|
74
|
+
|
75
|
+
def changes_as_objects
|
76
|
+
changes.map { |c| Change.new(c.fetch(:path), c.fetch(:old), c.fetch(:new)) }
|
77
|
+
end
|
78
|
+
|
79
|
+
def changed_paths
|
80
|
+
changes_as_objects.map(&:path)
|
81
|
+
end
|
82
|
+
|
83
|
+
def changes_at(path)
|
84
|
+
changes_as_objects.detect { |c| c.path == path }
|
85
|
+
end
|
86
|
+
|
87
|
+
private
|
88
|
+
|
89
|
+
def check_observations
|
90
|
+
if self.class == TreeDiff
|
91
|
+
raise 'TreeDiff is an abstract class - write a child class with observations'
|
92
|
+
end
|
93
|
+
|
94
|
+
if self.class.original_observations.empty?
|
95
|
+
raise 'you need to define some observations first'
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
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 }
|
114
|
+
end
|
115
|
+
else
|
116
|
+
mold.define_singleton_method(a, &copied_value(source_object, a))
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
mold
|
121
|
+
end
|
122
|
+
|
123
|
+
def copied_value(object, attribute_name)
|
124
|
+
source_value = object.public_send(attribute_name)
|
125
|
+
|
126
|
+
# TODO This properly clones hashes but is a dependency on Rails. Figure out a better way.
|
127
|
+
source_value = source_value.deep_dup
|
128
|
+
|
129
|
+
-> { source_value }
|
130
|
+
end
|
131
|
+
|
132
|
+
def iterate_observations(mold, current_object)
|
133
|
+
self.class.observations.each.with_object([]) do |path, changes|
|
134
|
+
old_obj_and_method = try_path mold, path
|
135
|
+
new_obj_and_method = try_path current_object, path, mold
|
136
|
+
|
137
|
+
old_value = call_method_on_object(old_obj_and_method, path)
|
138
|
+
new_value = call_method_on_object(new_obj_and_method, path)
|
139
|
+
|
140
|
+
changes << {path: path, old: old_value, new: new_value} if old_value != new_value
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def call_method_on_object(object_and_method, path)
|
145
|
+
callable = method_caller_with_condition(path)
|
146
|
+
|
147
|
+
if object_and_method.is_a?(Array)
|
148
|
+
object_and_method.each.with_object([]) do |r, c|
|
149
|
+
result = callable.(r)
|
150
|
+
c << result if result
|
151
|
+
end
|
152
|
+
else
|
153
|
+
result = callable.(object_and_method)
|
154
|
+
result
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def method_caller_with_condition(path)
|
159
|
+
->(object_and_method) do
|
160
|
+
condition = self.class.condition_for(path)
|
161
|
+
|
162
|
+
if !condition || condition.call(object_and_method.fetch(:object))
|
163
|
+
object_and_method.fetch(:object).public_send(object_and_method.fetch(:method_name))
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# By design this tries to be as loosely typed and flexible as possible. Sometimes, calling an attribute
|
169
|
+
# name on a collection of them yields an enumerable object. For example, active record's enum column returns
|
170
|
+
# a hash indicating the enumeration definition. That would lead to incorrectly comparing this value, a hash,
|
171
|
+
# rather than the actual data point in each item of the collection.
|
172
|
+
#
|
173
|
+
# I overcome this by passing the mold, or the original object, as a reference, and following each result of
|
174
|
+
# the call chain on not only the changed/current object, but that original object too. The mold is created
|
175
|
+
# with correct 1:many relationships and does not have the aforementioned problem, so by verifying the path
|
176
|
+
# 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)
|
179
|
+
|
180
|
+
if result.respond_to?(:each)
|
181
|
+
result.map.with_index do |o, idx|
|
182
|
+
ref = reference_result[idx] if reference_result
|
183
|
+
try_path(o, path[1..-1], ref)
|
184
|
+
end
|
185
|
+
else
|
186
|
+
{object: result, method_name: path.last}
|
187
|
+
end
|
188
|
+
end
|
189
|
+
|
190
|
+
# Execute the call chain given by path. i.e. [:method, :foo, :bar] until just before the last method
|
191
|
+
def follow_call_chain(receiver, chain, reference)
|
192
|
+
chain[0...-1].each do |method_name|
|
193
|
+
if receiver.respond_to?(method_name) && (!reference || reference.respond_to?(method_name))
|
194
|
+
receiver = receiver.public_send(method_name)
|
195
|
+
reference = reference.public_send(method_name) if reference
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
[receiver, reference]
|
200
|
+
end
|
201
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
|
3
|
+
ActiveRecord::Base.establish_connection(
|
4
|
+
adapter: 'sqlite3',
|
5
|
+
database: ':memory:'
|
6
|
+
)
|
7
|
+
|
8
|
+
ActiveRecord::Schema.define do
|
9
|
+
create_table :orders, force: true do |t|
|
10
|
+
t.string :number
|
11
|
+
end
|
12
|
+
|
13
|
+
create_table :line_items, force: true do |t|
|
14
|
+
t.string :description
|
15
|
+
t.integer :price_usd_cents
|
16
|
+
t.belongs_to :order, index: true
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
# Define the models
|
21
|
+
class Order < ActiveRecord::Base
|
22
|
+
has_many :line_items, inverse_of: :order
|
23
|
+
accepts_nested_attributes_for :line_items, allow_destroy: true
|
24
|
+
end
|
25
|
+
|
26
|
+
class LineItem < ActiveRecord::Base
|
27
|
+
belongs_to :order, inverse_of: :line_items
|
28
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require_relative '../lib/tree_diff'
|
2
|
+
require 'helper'
|
3
|
+
require 'minitest/autorun'
|
4
|
+
|
5
|
+
class TreeDiffTest < Minitest::Test
|
6
|
+
class OrderDiff < TreeDiff
|
7
|
+
observe :number, line_items: [:description, :price_usd_cents]
|
8
|
+
end
|
9
|
+
|
10
|
+
def test_todo
|
11
|
+
thing = LineItem.new(description: 'thing', price_usd_cents: 1234)
|
12
|
+
other_thing = LineItem.new(description: 'other thing', price_usd_cents: 5678)
|
13
|
+
order = Order.create!(number: 'XYZ123', line_items: [thing, other_thing])
|
14
|
+
|
15
|
+
diff = OrderDiff.new(order)
|
16
|
+
|
17
|
+
assert_empty diff.changes
|
18
|
+
|
19
|
+
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},
|
21
|
+
{description: 'new thing', price_usd_cents: 1357}])
|
22
|
+
|
23
|
+
assert_equal [[:line_items, :description],
|
24
|
+
[:line_items, :price_usd_cents]], diff.changed_paths
|
25
|
+
|
26
|
+
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
|
28
|
+
end
|
29
|
+
end
|
data/tree_diff.gemspec
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Gem::Specification.new do |s|
|
2
|
+
s.name = 'tree_diff'
|
3
|
+
s.version = '1.0.0'
|
4
|
+
s.date = '2019-07-13'
|
5
|
+
s.summary = "Observe attribute changes in big complex object trees, like a generic & standalone ActiveModel::Dirty"
|
6
|
+
s.description = "Given a tree of relationships in a similar format to strong params, analyzes attribute changes by " \
|
7
|
+
"call chain. Call just before and after you make a change. Completely ORM and framework agnostic."
|
8
|
+
s.authors = ["Michael Nelson"]
|
9
|
+
s.email = 'michael@nelsonware.com'
|
10
|
+
s.files = `git ls-files`.split("\n")
|
11
|
+
s.test_files = `git ls-files -- test/*`.split("\n")
|
12
|
+
s.homepage = 'https://gitlab.com/mcnelson/tree_diff'
|
13
|
+
s.license = 'MIT'
|
14
|
+
|
15
|
+
# s.require_paths = ["lib"]
|
16
|
+
s.required_ruby_version = '>= 2.5.1' # TODO
|
17
|
+
|
18
|
+
s.add_development_dependency "minitest", "~> 5.11"
|
19
|
+
s.add_development_dependency "m", "~> 1.5"
|
20
|
+
s.add_development_dependency "sqlite3", "~> 1.4"
|
21
|
+
s.add_development_dependency "activerecord", "~> 5.2"
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: tree_diff
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Michael Nelson
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2019-07-13 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: minitest
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '5.11'
|
20
|
+
type: :development
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '5.11'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: m
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '1.5'
|
34
|
+
type: :development
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '1.5'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: sqlite3
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '1.4'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '1.4'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: activerecord
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - "~>"
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '5.2'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - "~>"
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '5.2'
|
69
|
+
description: Given a tree of relationships in a similar format to strong params, analyzes
|
70
|
+
attribute changes by call chain. Call just before and after you make a change. Completely
|
71
|
+
ORM and framework agnostic.
|
72
|
+
email: michael@nelsonware.com
|
73
|
+
executables: []
|
74
|
+
extensions: []
|
75
|
+
extra_rdoc_files: []
|
76
|
+
files:
|
77
|
+
- ".ruby-version"
|
78
|
+
- Gemfile
|
79
|
+
- Gemfile.lock
|
80
|
+
- lib/tree_diff.rb
|
81
|
+
- test/helper.rb
|
82
|
+
- test/tree_diff_test.rb
|
83
|
+
- tree_diff.gemspec
|
84
|
+
homepage: https://gitlab.com/mcnelson/tree_diff
|
85
|
+
licenses:
|
86
|
+
- MIT
|
87
|
+
metadata: {}
|
88
|
+
post_install_message:
|
89
|
+
rdoc_options: []
|
90
|
+
require_paths:
|
91
|
+
- lib
|
92
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: 2.5.1
|
97
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
98
|
+
requirements:
|
99
|
+
- - ">="
|
100
|
+
- !ruby/object:Gem::Version
|
101
|
+
version: '0'
|
102
|
+
requirements: []
|
103
|
+
rubyforge_project:
|
104
|
+
rubygems_version: 2.7.6
|
105
|
+
signing_key:
|
106
|
+
specification_version: 4
|
107
|
+
summary: Observe attribute changes in big complex object trees, like a generic & standalone
|
108
|
+
ActiveModel::Dirty
|
109
|
+
test_files:
|
110
|
+
- test/helper.rb
|
111
|
+
- test/tree_diff_test.rb
|