tree_diff 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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