diffable 0.0.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.
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +22 -0
- data/README.md +112 -0
- data/Rakefile +12 -0
- data/diffable.gemspec +27 -0
- data/lib/diffable.rb +302 -0
- data/lib/diffable/version.rb +6 -0
- data/spec/db/schema.rb +35 -0
- data/spec/lib/diffable_spec.rb +240 -0
- metadata +145 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2013 Liz Conlan
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,112 @@
|
|
1
|
+
# Diffable
|
2
|
+
|
3
|
+
Diffable provides a mixin that can be used to extend any ActiveRecord object to provide diff
|
4
|
+
functionality. Calling the diff method compares the receiver against another
|
5
|
+
object and returns a Hash of differences found (presented as a description of
|
6
|
+
the changes between the second object and the receiver - as if trying to restore
|
7
|
+
the calling object from its replacement).
|
8
|
+
|
9
|
+
## Installation
|
10
|
+
|
11
|
+
Add this line to your application's Gemfile:
|
12
|
+
|
13
|
+
gem 'diffable'
|
14
|
+
|
15
|
+
And then execute:
|
16
|
+
|
17
|
+
$ bundle
|
18
|
+
|
19
|
+
Or install it yourself as:
|
20
|
+
|
21
|
+
$ gem install diffable
|
22
|
+
|
23
|
+
## Usage
|
24
|
+
|
25
|
+
Require the gem in the source file that includes the relevant model code:
|
26
|
+
|
27
|
+
require 'diffable'
|
28
|
+
|
29
|
+
Use the include statement to add Diffable support to each model that needs it:
|
30
|
+
|
31
|
+
class ModelA < ActiveRecord::Base
|
32
|
+
include Diffable
|
33
|
+
end
|
34
|
+
|
35
|
+
You can then call the `diff` method on any instance of that model class:
|
36
|
+
|
37
|
+
object1 = ModelA.new(:name => "test")
|
38
|
+
object2 = ModelA.new(:name => "test2")
|
39
|
+
difference = object1.diff(object2)
|
40
|
+
|
41
|
+
## Behaviour
|
42
|
+
|
43
|
+
There are 3 different types of change that can be returned by the diff method:
|
44
|
+
modified, new and deleted. These are indicated using a `:change_type`
|
45
|
+
key/value within the results Hash.
|
46
|
+
|
47
|
+
When an object is flagged as **modified**, its identifier and any of the altered
|
48
|
+
fields are returned, e.g.:
|
49
|
+
|
50
|
+
{:change_type => "modified", :id => 42, :name => "test1"}
|
51
|
+
|
52
|
+
When an object is flagged as **new**, only its identifier is returned. Sample output:
|
53
|
+
|
54
|
+
{:change_type => "new", :id => 42}
|
55
|
+
|
56
|
+
When an object is flagged as **deleted**, all of its attributes are returned in
|
57
|
+
the diff Hash. Sample output:
|
58
|
+
|
59
|
+
{:change_type => "deleted", :id => 42, :name => "test", :desc => "db test"}
|
60
|
+
|
61
|
+
### Excluding fields
|
62
|
+
|
63
|
+
If there are any database fields that should not be returned as part of
|
64
|
+
a **deleted** object's data, they can be excluded at the model level using
|
65
|
+
`set_excluded_fields`:
|
66
|
+
|
67
|
+
class ModelB < ActiveRecord::Base
|
68
|
+
include Diffable
|
69
|
+
set_excluded_fields :ignore_me
|
70
|
+
end
|
71
|
+
|
72
|
+
### Conditional fields
|
73
|
+
|
74
|
+
If a field value should always be included as part of a modified object's
|
75
|
+
data, this can be set at the model level using `set_conditional_fields`:
|
76
|
+
|
77
|
+
class ModelC < ActiveRecord::Base
|
78
|
+
include Diffable
|
79
|
+
set_conditional_fields :metadata, :history
|
80
|
+
end
|
81
|
+
|
82
|
+
### Using with related tables
|
83
|
+
|
84
|
+
If your model uses `has_many` or `has_one`, the changes to these dependent
|
85
|
+
objects can also be captured by the diff method, provided that their model
|
86
|
+
definitions also use the `include Diffable` statement (otherwise they will
|
87
|
+
be ignored). However, you will also need to inform the model which field can
|
88
|
+
be used to uniquely identify records within the set returned for a particular
|
89
|
+
parent object using `set_unique_within_group`. This should be a generated
|
90
|
+
field as `:id` is unlikely to be suitable.
|
91
|
+
|
92
|
+
class ModelD < ActiveRecord::Base
|
93
|
+
include Diffable
|
94
|
+
has_many :catalogue_entries
|
95
|
+
end
|
96
|
+
|
97
|
+
class CatalogueEntries < ActiveRecord::Base
|
98
|
+
include Diffable
|
99
|
+
belongs_to :model_d
|
100
|
+
set_unique_within_group :generated_identifier
|
101
|
+
|
102
|
+
...
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
## Contributing
|
107
|
+
|
108
|
+
1. Fork it
|
109
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
110
|
+
3. Commit your changes (`git commit -am 'Add some feature'`)
|
111
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
112
|
+
5. Create new Pull Request
|
data/Rakefile
ADDED
data/diffable.gemspec
ADDED
@@ -0,0 +1,27 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'diffable/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "diffable"
|
8
|
+
spec.version = Diffable::VERSION
|
9
|
+
spec.authors = ["Liz Conlan"]
|
10
|
+
spec.email = ["lizconlan@gmail.com"]
|
11
|
+
spec.description = %q{Facilitates Active::Record object diffing}
|
12
|
+
spec.summary = %q{Adds ability to compare 2 Active::Record objects; returns the differences as a hash}
|
13
|
+
spec.homepage = "https://github.com/lizconlan/diffable"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files`.split($/)
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_dependency "activerecord", ">= 3.2"
|
22
|
+
|
23
|
+
spec.add_development_dependency "bundler", "~> 1.3"
|
24
|
+
spec.add_development_dependency "rake"
|
25
|
+
spec.add_development_dependency "rspec"
|
26
|
+
spec.add_development_dependency "activerecord-nulldb-adapter"
|
27
|
+
end
|
data/lib/diffable.rb
ADDED
@@ -0,0 +1,302 @@
|
|
1
|
+
require "diffable/version"
|
2
|
+
|
3
|
+
# :main: README.rdoc
|
4
|
+
|
5
|
+
##
|
6
|
+
# Diffable provides a mixin that can be used to extend any ActiveRecord object
|
7
|
+
# to provide diff functionality. Calling the diff method compares the receiver
|
8
|
+
# against another object and returns a Hash of differences found (presented as
|
9
|
+
# a description of the changes between the second object and the receiver - as
|
10
|
+
# if trying to restore the calling object from its replacement).
|
11
|
+
|
12
|
+
module Diffable
|
13
|
+
def self.included base # :nodoc:
|
14
|
+
base.send :include, InstanceMethods
|
15
|
+
base.extend ClassMethods
|
16
|
+
end
|
17
|
+
|
18
|
+
module InstanceMethods
|
19
|
+
##
|
20
|
+
# Produces a Hash containing the differences between the calling object
|
21
|
+
# and the object passed in as a parameter
|
22
|
+
def diff(other)
|
23
|
+
check_class_compatibility(self, other)
|
24
|
+
|
25
|
+
self_attribs = self.get_attributes(self.class.excluded_fields)
|
26
|
+
other_attribs = other.get_attributes(other.class.excluded_fields)
|
27
|
+
|
28
|
+
change = compare_objects(self_attribs, other_attribs, self, other)
|
29
|
+
|
30
|
+
#the last bit - no change, no report; simples
|
31
|
+
if other.class.conditional_fields
|
32
|
+
other.class.conditional_fields.each do |key|
|
33
|
+
change[key.to_sym] = eval("other.#{key}") unless change.empty?
|
34
|
+
end
|
35
|
+
end
|
36
|
+
change
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# Fetches the attributes of the calling object, exluding the +id+ field
|
41
|
+
# and any fields specified passed as an array of symbols via the +excluded+
|
42
|
+
# parameter
|
43
|
+
def get_attributes(excluded)
|
44
|
+
attribs = attributes.dup
|
45
|
+
attribs.delete_if { |key, value|
|
46
|
+
(!excluded.nil? and excluded.include?(key)) or key == "id" }
|
47
|
+
end
|
48
|
+
|
49
|
+
##
|
50
|
+
# Uses reflection to fetch the eligible associated objects for the current
|
51
|
+
# object, excluding parent objects and child objects that do not include
|
52
|
+
# the Diffable mixin
|
53
|
+
def reflected_names(obj)
|
54
|
+
classes = obj.reflections
|
55
|
+
class_names = []
|
56
|
+
classes.each do |key, cl|
|
57
|
+
if eval(cl.class_name).respond_to?("diffable") \
|
58
|
+
and cl.association_class != ActiveRecord::Associations::BelongsToAssociation
|
59
|
+
class_names << key
|
60
|
+
end
|
61
|
+
end
|
62
|
+
class_names
|
63
|
+
end
|
64
|
+
|
65
|
+
private
|
66
|
+
|
67
|
+
def check_class_compatibility(current, other)
|
68
|
+
current_super = current.class.superclass
|
69
|
+
other_super = other.class.superclass
|
70
|
+
if current_super == ActiveRecord::Base || other_super == ActiveRecord::Base
|
71
|
+
if other.class != current.class && other.class != current_super && other_super != current.class
|
72
|
+
raise "Unable to compare #{current.class} to #{other.class}"
|
73
|
+
end
|
74
|
+
else
|
75
|
+
if current.class != other.class && other.class.superclass != current.class.superclass
|
76
|
+
raise "Unable to compare #{current.class} to #{other.class}"
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
def find_in_array_by_ident(arr, value)
|
82
|
+
arr.select { |x| eval(%Q|x.#{x.class.unique_within_group}|) == value }.first
|
83
|
+
end
|
84
|
+
|
85
|
+
def map_obj_idents(obj)
|
86
|
+
obj.map { |x| x.attributes[x.class.unique_within_group] }
|
87
|
+
end
|
88
|
+
|
89
|
+
def ident_in_list?(ident, ident_list)
|
90
|
+
return true if ident_list.include?(ident)
|
91
|
+
false
|
92
|
+
end
|
93
|
+
|
94
|
+
def compare_current_subs(current_obj_idents, previous_obj_idents, current_subs, previous_subs)
|
95
|
+
objects = []
|
96
|
+
current_obj_idents.each do |idnt|
|
97
|
+
current_sub = find_in_array_by_ident(current_subs, idnt)
|
98
|
+
previous_sub = find_in_array_by_ident(previous_subs, idnt)
|
99
|
+
|
100
|
+
if ident_in_list?(idnt, previous_obj_idents)
|
101
|
+
#pre-existing thing, compare the differences...
|
102
|
+
current_attribs = current_sub.get_attributes(current_sub.class.excluded_fields)
|
103
|
+
previous_attribs = previous_sub.get_attributes(previous_sub.class.excluded_fields)
|
104
|
+
|
105
|
+
obj = compare_objects(current_attribs, previous_attribs, current_sub, previous_sub, obj)
|
106
|
+
|
107
|
+
#...and only store if something's changed
|
108
|
+
unless obj.empty?
|
109
|
+
obj[:change_type] = "modified"
|
110
|
+
objects << obj
|
111
|
+
end
|
112
|
+
else
|
113
|
+
#a new thing, just need to note its arrival
|
114
|
+
unique_field = current_sub.class.unique_within_group
|
115
|
+
objects << {unique_field.to_sym => eval("current_sub.#{unique_field}"), :change_type => "new"}
|
116
|
+
end
|
117
|
+
end
|
118
|
+
objects
|
119
|
+
end
|
120
|
+
|
121
|
+
def preserve_deleted_by_ident(deleted_idents, previous_subs, previous_obj, sub)
|
122
|
+
objects = []
|
123
|
+
deleted_idents.each do |ident|
|
124
|
+
previous_sub = find_in_array_by_ident(eval("previous_obj.#{sub}.to_a"), ident)
|
125
|
+
obj = preserve_deleted_obj(previous_sub)
|
126
|
+
objects << obj
|
127
|
+
end
|
128
|
+
objects
|
129
|
+
end
|
130
|
+
|
131
|
+
def compare_attributes(current, previous, current_obj, change={})
|
132
|
+
previous.each do |key, value|
|
133
|
+
change[key.to_sym] = value if value != current[key]
|
134
|
+
end
|
135
|
+
unless change.empty?
|
136
|
+
if current_obj.class.unique_within_group
|
137
|
+
unique_field = current_obj.class.unique_within_group
|
138
|
+
change[unique_field.to_sym] = eval("current_obj.#{unique_field}")
|
139
|
+
end
|
140
|
+
end
|
141
|
+
change
|
142
|
+
end
|
143
|
+
|
144
|
+
def analyze_subobjects(current_obj, previous_obj, change={})
|
145
|
+
#need both - comparable objects need not have the same reflections
|
146
|
+
current_subs = reflected_names(current_obj)
|
147
|
+
previous_subs = reflected_names(previous_obj)
|
148
|
+
|
149
|
+
#things that are available to the current object
|
150
|
+
current_subs.each do |sub|
|
151
|
+
objects = []
|
152
|
+
current_objects = current_obj.association(sub).target
|
153
|
+
previous_objects = previous_obj.respond_to?(sub) ? eval("previous_obj.#{sub}.to_a") : []
|
154
|
+
current_obj_idents = map_obj_idents(current_objects)
|
155
|
+
previous_obj_idents = map_obj_idents(previous_objects)
|
156
|
+
|
157
|
+
#loop through the ids in the current block
|
158
|
+
objects += compare_current_subs(current_obj_idents, previous_obj_idents, current_objects, previous_objects)
|
159
|
+
|
160
|
+
#look for ids that only exist in the previous block
|
161
|
+
objects += preserve_deleted_by_ident((previous_obj_idents - current_obj_idents), previous_subs, previous_obj, sub)
|
162
|
+
|
163
|
+
#update time_blocks if any changes were found
|
164
|
+
change[sub] = objects unless objects.empty?
|
165
|
+
end
|
166
|
+
|
167
|
+
#things that are only available to the previous object
|
168
|
+
(previous_subs - current_subs).each do |sub|
|
169
|
+
objects = []
|
170
|
+
previous_obj_idents = map_obj_idents(previous_obj)
|
171
|
+
objects += preserve_deleted_by_ident(previous_obj_idents, (previous_subs - current_subs), previous_obj, sub)
|
172
|
+
change[sub] = objects unless objects.empty?
|
173
|
+
end
|
174
|
+
change
|
175
|
+
end
|
176
|
+
|
177
|
+
def preserve_deleted_obj(deleted, excluded_fields=self.class.excluded_fields)
|
178
|
+
obj = {}
|
179
|
+
#get attributes of object marked for deletion...
|
180
|
+
attribs = deleted.get_attributes(deleted.class.excluded_fields)
|
181
|
+
#...and copy them for preservation
|
182
|
+
attribs.keys.each do |att|
|
183
|
+
value = nil
|
184
|
+
if deleted.respond_to?(att)
|
185
|
+
value = eval("deleted.#{att}")
|
186
|
+
end
|
187
|
+
|
188
|
+
obj[att.to_sym] = value unless value.nil?
|
189
|
+
end
|
190
|
+
|
191
|
+
#look to see if our target object has sub-objects of its own
|
192
|
+
previous_sub_keys = reflected_names(deleted)
|
193
|
+
|
194
|
+
#preserve subs
|
195
|
+
obj = preserve_deleted_subs(previous_sub_keys, deleted, obj)
|
196
|
+
|
197
|
+
unless obj.empty?
|
198
|
+
if deleted.class.conditional_fields
|
199
|
+
deleted.class.conditional_fields.each do |key|
|
200
|
+
obj[key.to_sym] = eval("deleted.#{key}") unless obj.empty?
|
201
|
+
end
|
202
|
+
end
|
203
|
+
obj[:change_type] = "deleted"
|
204
|
+
end
|
205
|
+
obj
|
206
|
+
end
|
207
|
+
|
208
|
+
def compare_objects(current_attribs, other_attribs, current, other, change={})
|
209
|
+
#compare the simple values
|
210
|
+
change = compare_attributes(current_attribs, other_attribs, current)
|
211
|
+
|
212
|
+
#analyse the subobjects
|
213
|
+
change = analyze_subobjects(current, other, change)
|
214
|
+
|
215
|
+
if other.class.conditional_fields
|
216
|
+
other.class.conditional_fields.each do |key|
|
217
|
+
change[key.to_sym] = eval("other.#{key}") unless change.empty?
|
218
|
+
end
|
219
|
+
end
|
220
|
+
change
|
221
|
+
end
|
222
|
+
|
223
|
+
def preserve_deleted_subs(keys, deleted, change={})
|
224
|
+
keys.each do |sub|
|
225
|
+
subs = []
|
226
|
+
previous_subs = deleted.respond_to?(sub) ? eval("deleted.#{sub}.to_a") : []
|
227
|
+
previous_subs.each do |deleted_sub|
|
228
|
+
preserved = preserve_deleted_obj(deleted_sub)
|
229
|
+
subs << preserved
|
230
|
+
end
|
231
|
+
change[sub] = subs unless subs.empty?
|
232
|
+
end
|
233
|
+
change
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
module ClassMethods
|
238
|
+
##
|
239
|
+
# Holds an array of excluded fields which will not be used
|
240
|
+
# for comparison tests or when copying deleted values
|
241
|
+
attr_reader :excluded_fields
|
242
|
+
|
243
|
+
##
|
244
|
+
# String value corresponding to the field that uniquely identifies
|
245
|
+
# a child record from among its siblings
|
246
|
+
# (should not be id unless id is being generated)
|
247
|
+
attr_reader :unique_within_group
|
248
|
+
|
249
|
+
##
|
250
|
+
# Holds an array of fields which will be added to a modified change Hash
|
251
|
+
# (regardless of whether its value has changed or not) unless there are
|
252
|
+
# no other changes
|
253
|
+
attr_reader :conditional_fields
|
254
|
+
|
255
|
+
##
|
256
|
+
# A shortcut, used to quickly check whether a class implements Diffable
|
257
|
+
attr_reader :diffable
|
258
|
+
|
259
|
+
@diffable = true
|
260
|
+
|
261
|
+
##
|
262
|
+
# Sets the class's conditional_fields values.
|
263
|
+
#
|
264
|
+
# If required, should be placed in the model definition code:
|
265
|
+
#
|
266
|
+
# class ModelA < ActiveRecord::Base
|
267
|
+
# include Diffable
|
268
|
+
# set_conditional_fields :meta
|
269
|
+
# end
|
270
|
+
def set_conditional_fields(*h)
|
271
|
+
@conditional_fields = []
|
272
|
+
h.each { |key| eval(%Q|@conditional_fields << "#{key.to_s}"|) }
|
273
|
+
end
|
274
|
+
|
275
|
+
##
|
276
|
+
# Sets the class's excluded_fields values.
|
277
|
+
#
|
278
|
+
# If required, should be placed in the model definition code:
|
279
|
+
#
|
280
|
+
# class ModelB < ActiveRecord::Base
|
281
|
+
# include Diffable
|
282
|
+
# set_excluded_fields :ignore_me, :test
|
283
|
+
# end
|
284
|
+
def set_excluded_fields(*h)
|
285
|
+
@excluded_fields = []
|
286
|
+
h.each { |key| eval(%Q|@excluded_fields << "#{key.to_s}"|) }
|
287
|
+
end
|
288
|
+
|
289
|
+
##
|
290
|
+
# Sets the class's unique_within_group value
|
291
|
+
#
|
292
|
+
# If required, should be placed in the model definition code:
|
293
|
+
#
|
294
|
+
# class ModelC < ActiveRecord::Base
|
295
|
+
# include Diffable
|
296
|
+
# set_unique_within_set :generated_identifier
|
297
|
+
# end
|
298
|
+
def set_unique_within_group(value)
|
299
|
+
eval(%Q|@unique_within_group = "#{value.to_s}"|)
|
300
|
+
end
|
301
|
+
end
|
302
|
+
end
|
data/spec/db/schema.rb
ADDED
@@ -0,0 +1,35 @@
|
|
1
|
+
ActiveRecord::Schema.define do
|
2
|
+
create_table(:notdiff) do |t|
|
3
|
+
t.string :name
|
4
|
+
end
|
5
|
+
|
6
|
+
create_table(:hazdiffs) do |t|
|
7
|
+
t.string :name
|
8
|
+
t.integer :price
|
9
|
+
end
|
10
|
+
|
11
|
+
create_table(:multidiffs) do |t|
|
12
|
+
t.string :name
|
13
|
+
t.integer :price
|
14
|
+
end
|
15
|
+
|
16
|
+
create_table(:subrecs) do |t|
|
17
|
+
t.integer :hazdiff_id
|
18
|
+
t.string :name
|
19
|
+
t.string :ident
|
20
|
+
end
|
21
|
+
|
22
|
+
create_table(:alt_subrecs) do |t|
|
23
|
+
t.integer :hazdiff_id
|
24
|
+
t.string :name
|
25
|
+
t.string :ident
|
26
|
+
t.string :ignore_me
|
27
|
+
t.string :tracker
|
28
|
+
end
|
29
|
+
|
30
|
+
create_table(:subrec_no_diffs) do |t|
|
31
|
+
t.integer :hazdiff_id
|
32
|
+
t.string :name
|
33
|
+
t.string :ident
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,240 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'rspec'
|
3
|
+
require 'nulldb_rspec'
|
4
|
+
|
5
|
+
require "./lib/diffable"
|
6
|
+
|
7
|
+
class Hazdiff < ActiveRecord::Base
|
8
|
+
include Diffable
|
9
|
+
end
|
10
|
+
|
11
|
+
class Notdiff < ActiveRecord::Base
|
12
|
+
end
|
13
|
+
|
14
|
+
class Multidiff < ActiveRecord::Base
|
15
|
+
include Diffable
|
16
|
+
has_many :subrecs
|
17
|
+
has_many :alt_subrecs
|
18
|
+
has_many :subrec_no_diffs
|
19
|
+
end
|
20
|
+
|
21
|
+
class Subrec < ActiveRecord::Base
|
22
|
+
include Diffable
|
23
|
+
set_unique_within_group :ident
|
24
|
+
belongs_to :multidiff
|
25
|
+
end
|
26
|
+
|
27
|
+
class SubrecNoDiff < ActiveRecord::Base
|
28
|
+
belongs_to :multidiff
|
29
|
+
end
|
30
|
+
|
31
|
+
class AltSubrec < ActiveRecord::Base
|
32
|
+
include Diffable
|
33
|
+
set_unique_within_group :ident
|
34
|
+
set_excluded_fields :ignore_me
|
35
|
+
set_conditional_fields :tracker
|
36
|
+
end
|
37
|
+
|
38
|
+
class Altdiff < Hazdiff
|
39
|
+
end
|
40
|
+
|
41
|
+
class AltdiffToo < Hazdiff
|
42
|
+
end
|
43
|
+
|
44
|
+
describe Diffable do
|
45
|
+
before(:all) do
|
46
|
+
NullDB.configure {|ndb| ndb.project_root = './spec/'}
|
47
|
+
ActiveRecord::Base.establish_connection :adapter => :nulldb
|
48
|
+
ActiveRecord::Migration.verbose = false
|
49
|
+
end
|
50
|
+
|
51
|
+
def should_have_column(klass, col_name, col_type)
|
52
|
+
col = klass.columns_hash[col_name.to_s]
|
53
|
+
col.should_not be_nil
|
54
|
+
col.type.should == col_type
|
55
|
+
end
|
56
|
+
|
57
|
+
context "sanity checks" do
|
58
|
+
it "should remember columns defined in migrations" do
|
59
|
+
should_have_column(Hazdiff, :name, :string)
|
60
|
+
end
|
61
|
+
|
62
|
+
it "should be defined" do
|
63
|
+
Diffable::VERSION.is_a?(String)
|
64
|
+
!Diffable::VERSION.empty?
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
context "checking class compatibility" do
|
69
|
+
it "should be able to compare 2 things of the same class" do
|
70
|
+
hd1 = Hazdiff.new()
|
71
|
+
hd2 = Hazdiff.new()
|
72
|
+
lambda { hd1.diff(hd2) }.should_not raise_error
|
73
|
+
end
|
74
|
+
|
75
|
+
it "should not be able to compare 2 things with different base classes" do
|
76
|
+
hd = Hazdiff.new
|
77
|
+
md = Multidiff.new
|
78
|
+
lambda { hd.diff(md) }.should raise_error
|
79
|
+
lambda { md.diff(hd) }.should raise_error
|
80
|
+
end
|
81
|
+
|
82
|
+
it "should be able to compare 2 things with the same base class" do
|
83
|
+
ad1 = Altdiff.new
|
84
|
+
ad2 = AltdiffToo.new
|
85
|
+
lambda { ad1.diff(ad2) }.should_not raise_error
|
86
|
+
end
|
87
|
+
|
88
|
+
it "should be able to compare base object with inherited object" do
|
89
|
+
hd = Hazdiff.new
|
90
|
+
ad = Altdiff.new
|
91
|
+
lambda { hd.diff(ad) }.should_not raise_error
|
92
|
+
lambda { ad.diff(hd) }.should_not raise_error
|
93
|
+
end
|
94
|
+
end
|
95
|
+
|
96
|
+
context "an object not including Diffable" do
|
97
|
+
it "should not inherit the instance methods" do
|
98
|
+
object = Notdiff.new
|
99
|
+
object.respond_to?(:diff).should be_false
|
100
|
+
object.respond_to?(:get_attributes).should be_false
|
101
|
+
object.respond_to?(:reflected_names).should be_false
|
102
|
+
object.class.respond_to?(:diffable).should be_false
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
context "a simple object including Diffable" do
|
107
|
+
it "should inherit the expected instance methods" do
|
108
|
+
object = Hazdiff.new(:name => "meep")
|
109
|
+
object.respond_to?(:diff).should be_true
|
110
|
+
object.respond_to?(:get_attributes).should be_true
|
111
|
+
object.respond_to?(:reflected_names).should be_true
|
112
|
+
object.class.respond_to?(:diffable).should be_true
|
113
|
+
end
|
114
|
+
|
115
|
+
describe "when asked for diff" do
|
116
|
+
describe "and given an object with identical attribute values" do
|
117
|
+
it "should return a blank hash" do
|
118
|
+
obj1 = Hazdiff.new(:name => "test1", :price => 0)
|
119
|
+
obj2 = Hazdiff.new(:name => "test1", :price => 0)
|
120
|
+
obj1.diff(obj2).should eq({})
|
121
|
+
end
|
122
|
+
end
|
123
|
+
|
124
|
+
describe "and given an object with different attribute values" do
|
125
|
+
it "should return a hash of differences" do
|
126
|
+
obj1 = Hazdiff.new(:name => "test1", :price => 0)
|
127
|
+
obj2 = Hazdiff.new(:name => "test2", :price => 0)
|
128
|
+
obj3 = Hazdiff.new(:name => "test2", :price => 1)
|
129
|
+
obj1.diff(obj2).should eq({:name => "test2"})
|
130
|
+
obj2.diff(obj1).should eq({:name => "test1"})
|
131
|
+
obj1.diff(obj3).should eq({:name => "test2", :price => 1})
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
context "an object including Diffable that has subobjects" do
|
138
|
+
describe "when asked for diff" do
|
139
|
+
describe "and the subobjects do not include Diffable" do
|
140
|
+
it "should ignore the ineligible subobjects" do
|
141
|
+
obj1 = Multidiff.new(:name => "test1")
|
142
|
+
sub1 = SubrecNoDiff.new(:name => "sub1")
|
143
|
+
|
144
|
+
obj2 = Multidiff.new(:name => "test1")
|
145
|
+
sub2 = SubrecNoDiff.new(:name => "sub2")
|
146
|
+
|
147
|
+
obj1.diff(obj2).should eq({})
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
describe "and there are no difference between the subobjects" do
|
152
|
+
it "should return a blank hash" do
|
153
|
+
obj1 = Multidiff.new(:name => "test1")
|
154
|
+
sub1 = Subrec.new(:name => "sub1")
|
155
|
+
obj1.subrecs << sub1
|
156
|
+
obj2 = Multidiff.new(:name => "test1")
|
157
|
+
obj2.subrecs << sub1.dup
|
158
|
+
|
159
|
+
obj1.diff(obj2).should eq({})
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
describe "and there are 2 different sets of subobjects" do
|
164
|
+
it "should return 1 deleted subobject and 1 new one" do
|
165
|
+
obj1 = Multidiff.new(:name => "test1")
|
166
|
+
sub1 = Subrec.new(:name => "sub1", :ident => "s1")
|
167
|
+
obj1.subrecs << sub1
|
168
|
+
|
169
|
+
obj2 = Multidiff.new(:name => "test1")
|
170
|
+
sub2 = Subrec.new(:name => "sub2", :ident => "s2")
|
171
|
+
obj2.subrecs << sub2
|
172
|
+
|
173
|
+
obj1.diff(obj2).should eq({:subrecs=>[{:ident=>"s1", :change_type=>"new"}, {:name=>"sub2", :ident=>"s2", :change_type=>"deleted"}]})
|
174
|
+
end
|
175
|
+
end
|
176
|
+
|
177
|
+
describe "and there are changes to the subobject" do
|
178
|
+
it "should return the subobject changes" do
|
179
|
+
obj1 = Multidiff.new(:name => "test1")
|
180
|
+
sub1 = Subrec.new(:name => "sub1", :ident => "s1")
|
181
|
+
obj1.subrecs << sub1
|
182
|
+
|
183
|
+
obj2 = Multidiff.new(:name => "test1")
|
184
|
+
sub2 = Subrec.new(:name => "sub01", :ident => "s1")
|
185
|
+
obj2.subrecs << sub2
|
186
|
+
|
187
|
+
obj1.diff(obj2).should eq({:subrecs=>[{:name=>"sub01", :ident=>"s1", :change_type=>"modified"}]})
|
188
|
+
end
|
189
|
+
|
190
|
+
it "should include conditional fields where there has been a change" do
|
191
|
+
obj1 = Multidiff.new(:name => "test1")
|
192
|
+
sub1 = AltSubrec.new(:name => "sub1", :ident => "s1", :tracker => "t")
|
193
|
+
obj1.alt_subrecs << sub1
|
194
|
+
|
195
|
+
obj2 = Multidiff.new(:name => "test1")
|
196
|
+
sub2 = AltSubrec.new(:name => "sub01", :ident => "s1", :tracker => "t")
|
197
|
+
obj2.alt_subrecs << sub2
|
198
|
+
|
199
|
+
obj1.diff(obj2).should eq({:alt_subrecs=>[{:name=>"sub01", :ident=>"s1", :tracker => "t", :change_type=>"modified"}]})
|
200
|
+
end
|
201
|
+
end
|
202
|
+
|
203
|
+
describe "and the subobject has been removed" do
|
204
|
+
#diffs are retrospective, this is where things get a bit weird
|
205
|
+
it "should return a new subobject" do
|
206
|
+
obj1 = Multidiff.new(:name => "test1")
|
207
|
+
sub1 = Subrec.new(:name => "sub1", :ident => "s1")
|
208
|
+
obj1.subrecs << sub1
|
209
|
+
|
210
|
+
obj2 = Multidiff.new(:name => "test1")
|
211
|
+
|
212
|
+
obj1.diff(obj2).should eq({:subrecs=>[{:ident=>"s1", :change_type=>"new"}]})
|
213
|
+
end
|
214
|
+
end
|
215
|
+
|
216
|
+
describe "and a subobject has been added" do
|
217
|
+
#diffs are retrospective, this is where things get a bit weird
|
218
|
+
it "should return a deleted subobject" do
|
219
|
+
obj1 = Multidiff.new(:name => "test1")
|
220
|
+
obj2 = Multidiff.new(:name => "test1")
|
221
|
+
sub1 = Subrec.new(:name => "sub1", :ident => "s1")
|
222
|
+
obj2.subrecs << sub1
|
223
|
+
|
224
|
+
obj1.diff(obj2).should eq({:subrecs=>[{:ident=>"s1", :name => "sub1", :change_type=>"deleted"}]})
|
225
|
+
end
|
226
|
+
|
227
|
+
it "should not return excluded fields" do
|
228
|
+
obj1 = Multidiff.new(:name => "test1")
|
229
|
+
obj2 = Multidiff.new(:name => "test1")
|
230
|
+
sub1 = AltSubrec.new(:name => "sub1", :ident => "s1", :tracker => "t", :ignore_me => "??")
|
231
|
+
obj2.alt_subrecs << sub1
|
232
|
+
|
233
|
+
obj1.diff(obj2).should eq({:alt_subrecs=>[{:name => "sub1", :ident=>"s1", :tracker => "t", :change_type=>"deleted"}]})
|
234
|
+
end
|
235
|
+
end
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
|
240
|
+
ActiveRecord::Base.configurations['test'] = {'adapter' => 'nulldb'}
|
metadata
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: diffable
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.0.1
|
5
|
+
prerelease:
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Liz Conlan
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
date: 2013-11-13 00:00:00.000000000 Z
|
13
|
+
dependencies:
|
14
|
+
- !ruby/object:Gem::Dependency
|
15
|
+
name: activerecord
|
16
|
+
requirement: !ruby/object:Gem::Requirement
|
17
|
+
none: false
|
18
|
+
requirements:
|
19
|
+
- - ! '>='
|
20
|
+
- !ruby/object:Gem::Version
|
21
|
+
version: '3.2'
|
22
|
+
type: :runtime
|
23
|
+
prerelease: false
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ! '>='
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
version: '3.2'
|
30
|
+
- !ruby/object:Gem::Dependency
|
31
|
+
name: bundler
|
32
|
+
requirement: !ruby/object:Gem::Requirement
|
33
|
+
none: false
|
34
|
+
requirements:
|
35
|
+
- - ~>
|
36
|
+
- !ruby/object:Gem::Version
|
37
|
+
version: '1.3'
|
38
|
+
type: :development
|
39
|
+
prerelease: false
|
40
|
+
version_requirements: !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
version: '1.3'
|
46
|
+
- !ruby/object:Gem::Dependency
|
47
|
+
name: rake
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
49
|
+
none: false
|
50
|
+
requirements:
|
51
|
+
- - ! '>='
|
52
|
+
- !ruby/object:Gem::Version
|
53
|
+
version: '0'
|
54
|
+
type: :development
|
55
|
+
prerelease: false
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
57
|
+
none: false
|
58
|
+
requirements:
|
59
|
+
- - ! '>='
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
- !ruby/object:Gem::Dependency
|
63
|
+
name: rspec
|
64
|
+
requirement: !ruby/object:Gem::Requirement
|
65
|
+
none: false
|
66
|
+
requirements:
|
67
|
+
- - ! '>='
|
68
|
+
- !ruby/object:Gem::Version
|
69
|
+
version: '0'
|
70
|
+
type: :development
|
71
|
+
prerelease: false
|
72
|
+
version_requirements: !ruby/object:Gem::Requirement
|
73
|
+
none: false
|
74
|
+
requirements:
|
75
|
+
- - ! '>='
|
76
|
+
- !ruby/object:Gem::Version
|
77
|
+
version: '0'
|
78
|
+
- !ruby/object:Gem::Dependency
|
79
|
+
name: activerecord-nulldb-adapter
|
80
|
+
requirement: !ruby/object:Gem::Requirement
|
81
|
+
none: false
|
82
|
+
requirements:
|
83
|
+
- - ! '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '0'
|
86
|
+
type: :development
|
87
|
+
prerelease: false
|
88
|
+
version_requirements: !ruby/object:Gem::Requirement
|
89
|
+
none: false
|
90
|
+
requirements:
|
91
|
+
- - ! '>='
|
92
|
+
- !ruby/object:Gem::Version
|
93
|
+
version: '0'
|
94
|
+
description: Facilitates Active::Record object diffing
|
95
|
+
email:
|
96
|
+
- lizconlan@gmail.com
|
97
|
+
executables: []
|
98
|
+
extensions: []
|
99
|
+
extra_rdoc_files: []
|
100
|
+
files:
|
101
|
+
- .gitignore
|
102
|
+
- Gemfile
|
103
|
+
- LICENSE.txt
|
104
|
+
- README.md
|
105
|
+
- Rakefile
|
106
|
+
- diffable.gemspec
|
107
|
+
- lib/diffable.rb
|
108
|
+
- lib/diffable/version.rb
|
109
|
+
- spec/db/schema.rb
|
110
|
+
- spec/lib/diffable_spec.rb
|
111
|
+
homepage: https://github.com/lizconlan/diffable
|
112
|
+
licenses:
|
113
|
+
- MIT
|
114
|
+
post_install_message:
|
115
|
+
rdoc_options: []
|
116
|
+
require_paths:
|
117
|
+
- lib
|
118
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
119
|
+
none: false
|
120
|
+
requirements:
|
121
|
+
- - ! '>='
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: '0'
|
124
|
+
segments:
|
125
|
+
- 0
|
126
|
+
hash: -907652915963392506
|
127
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
128
|
+
none: false
|
129
|
+
requirements:
|
130
|
+
- - ! '>='
|
131
|
+
- !ruby/object:Gem::Version
|
132
|
+
version: '0'
|
133
|
+
segments:
|
134
|
+
- 0
|
135
|
+
hash: -907652915963392506
|
136
|
+
requirements: []
|
137
|
+
rubyforge_project:
|
138
|
+
rubygems_version: 1.8.24
|
139
|
+
signing_key:
|
140
|
+
specification_version: 3
|
141
|
+
summary: Adds ability to compare 2 Active::Record objects; returns the differences
|
142
|
+
as a hash
|
143
|
+
test_files:
|
144
|
+
- spec/db/schema.rb
|
145
|
+
- spec/lib/diffable_spec.rb
|