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 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
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ group :test do
4
+ gem 'minitest'
5
+ gem 'm'
6
+ gem 'sqlite3'
7
+ gem 'activerecord'
8
+ end
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