yesterday 0.2
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +5 -0
- data/Gemfile +8 -0
- data/README.rdoc +86 -0
- data/Rakefile +2 -0
- data/lib/generators/yesterday/install_generator.rb +18 -0
- data/lib/generators/yesterday/templates/create_changesets.rb +17 -0
- data/lib/yesterday/changeset.rb +40 -0
- data/lib/yesterday/differ.rb +99 -0
- data/lib/yesterday/historical_value.rb +0 -0
- data/lib/yesterday/model.rb +100 -0
- data/lib/yesterday/serializer.rb +88 -0
- data/lib/yesterday/version.rb +3 -0
- data/lib/yesterday/versioned_attribute.rb +21 -0
- data/lib/yesterday/versioned_object.rb +18 -0
- data/lib/yesterday/versioned_object_creator.rb +33 -0
- data/lib/yesterday/versioning.rb +40 -0
- data/lib/yesterday.rb +17 -0
- data/spec/database.yml +3 -0
- data/spec/differ_spec.rb +206 -0
- data/spec/models.rb +12 -0
- data/spec/schema.rb +41 -0
- data/spec/serializer_spec.rb +66 -0
- data/spec/spec_helper.rb +41 -0
- data/spec/versioned_object_creator_spec.rb +41 -0
- data/spec/yesterday_model_spec.rb +195 -0
- data/yesterday.gemspec +25 -0
- metadata +140 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/README.rdoc
ADDED
@@ -0,0 +1,86 @@
|
|
1
|
+
= Yesterday
|
2
|
+
|
3
|
+
The Yesterday gem lets you track changes made in ActiveRecord models. It's also possible to diff different versions. The big difference with this gem and other is, is that it is possible to diff all child objects (has_many, belongs_to & has_and_belongs_to_many) and it's changes.
|
4
|
+
|
5
|
+
== Note: work in progress
|
6
|
+
|
7
|
+
In the meanwhile:
|
8
|
+
http://www.youtube.com/watch?v=YgtByWlBSdA
|
9
|
+
|
10
|
+
== Installation
|
11
|
+
|
12
|
+
Install the gem:
|
13
|
+
gem install yesterday
|
14
|
+
|
15
|
+
And then run the generator in your Rails 3 project:
|
16
|
+
rails generate yesterday:install
|
17
|
+
|
18
|
+
== Use tracking in your models
|
19
|
+
|
20
|
+
class Contact < ActiveRecord::Base
|
21
|
+
has_many :addresses
|
22
|
+
tracks_changes
|
23
|
+
end
|
24
|
+
|
25
|
+
class Address < ActiveRecord::Base
|
26
|
+
has_many :companies
|
27
|
+
exclude_tracking_for :associations => :companies
|
28
|
+
end
|
29
|
+
|
30
|
+
== Checking version number
|
31
|
+
|
32
|
+
some_contact = Contact.find(10)
|
33
|
+
some_contact.version_number
|
34
|
+
|
35
|
+
== Viewing historical data of an object
|
36
|
+
|
37
|
+
Viewing a version from an already found active record model:
|
38
|
+
some_contact = Contact.find(10)
|
39
|
+
some_contact.version(2)
|
40
|
+
|
41
|
+
Or using in a scope chain:
|
42
|
+
Contact.where(:first_name => 'foo').last.version(3)
|
43
|
+
|
44
|
+
Both of the above examples will return a Yesterday::VersionedObject
|
45
|
+
contact = Contact.create :first_name => 'foo'
|
46
|
+
contact.update_attribute :first_name => 'baz'
|
47
|
+
|
48
|
+
puts contact.version(1).first_name # -> foo
|
49
|
+
puts contact.version(2).first_name # -> baz
|
50
|
+
|
51
|
+
== Diffing two versions
|
52
|
+
|
53
|
+
Use version numbers two compare two versions:
|
54
|
+
contact = Contact.create :first_name => 'foo'
|
55
|
+
contact.update_attribute :first_name => 'baz'
|
56
|
+
|
57
|
+
diff = contact.diff_version(1, 2)
|
58
|
+
puts diff.first_name.current # -> baz
|
59
|
+
puts diff.first_name.previous # -> foo
|
60
|
+
|
61
|
+
Diff's within associations:
|
62
|
+
address = [Address.new(:address => 'blah')]
|
63
|
+
|
64
|
+
contact = Contact.new :first_name => 'foo'
|
65
|
+
contact.addresses = [address]
|
66
|
+
contact.save!
|
67
|
+
|
68
|
+
contact.addresses.first.address = 'blahblah'
|
69
|
+
contact.save!
|
70
|
+
|
71
|
+
diff = contact.diff_version(1, 2)
|
72
|
+
puts diff.addresses.first.address.current # -> blahblah
|
73
|
+
puts diff.addresses.first.address.previous # -> foo
|
74
|
+
|
75
|
+
|
76
|
+
To check if associations are created, just use the created_ prefix before the appropiate association:
|
77
|
+
contact.created_addresses
|
78
|
+
|
79
|
+
Or, for removed associations:
|
80
|
+
contact.destroyed_addresses
|
81
|
+
|
82
|
+
|
83
|
+
= License and credits
|
84
|
+
Use it and have fun with it! Comments, cakes and hugs are welcome! Just stick to the license!
|
85
|
+
|
86
|
+
Copyright 2011, Diederick Lawson - Altovista. Released under the FreeBSD license.
|
data/Rakefile
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
require 'rails/generators'
|
2
|
+
require 'rails/generators/migration'
|
3
|
+
require 'rails/generators/active_record/migration'
|
4
|
+
|
5
|
+
module Yesterday
|
6
|
+
class InstallGenerator < Rails::Generators::Base
|
7
|
+
include Rails::Generators::Migration
|
8
|
+
extend ActiveRecord::Generators::Migration
|
9
|
+
|
10
|
+
source_root File.expand_path('../templates', __FILE__)
|
11
|
+
|
12
|
+
desc 'Generates a migration to add the changesets table in order to use tracks_changes in your models'
|
13
|
+
def create_migration_file
|
14
|
+
migration_template 'create_changesets.rb', 'db/migrate/create_changesets.rb'
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
@@ -0,0 +1,17 @@
|
|
1
|
+
class CreateChangesets < ActiveRecord::Migration
|
2
|
+
def self.up
|
3
|
+
create_table :changesets do |t|
|
4
|
+
t.integer :changed_object_id
|
5
|
+
t.string :changed_object_type
|
6
|
+
t.text :object_attributes
|
7
|
+
t.integer :version_number
|
8
|
+
t.datetime :created_at
|
9
|
+
t.datetime :updated_at
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.down
|
14
|
+
drop_table :changesets
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class Changeset < ActiveRecord::Base
|
3
|
+
|
4
|
+
before_create :determine_version_number, :determine_object_attributes
|
5
|
+
belongs_to :changed_object, :polymorphic => true
|
6
|
+
|
7
|
+
serialize :object_attributes
|
8
|
+
|
9
|
+
def self.version(version_number)
|
10
|
+
where(:version_number => version_number)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.for_changed_object(object)
|
14
|
+
where(:changed_object_type => object.class.to_s, :changed_object_id => object.id)
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.last_for(object)
|
18
|
+
for_changed_object(object).order('created_at DESC').first
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.version_number_for(object)
|
22
|
+
last_for(object).try(:version_number) || 0
|
23
|
+
end
|
24
|
+
|
25
|
+
def object
|
26
|
+
@object ||= VersionedObjectCreator.new(object_attributes).to_object if object_attributes.present?
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def determine_version_number
|
32
|
+
self.version_number = self.class.version_number_for(changed_object) + 1
|
33
|
+
end
|
34
|
+
|
35
|
+
def determine_object_attributes
|
36
|
+
self.object_attributes = Yesterday::Serializer.new(changed_object).to_hash
|
37
|
+
end
|
38
|
+
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,99 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class Differ < Struct.new(:from, :to)
|
3
|
+
|
4
|
+
def diff
|
5
|
+
@diff ||= diff_object(from, to)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def diff_object(from, to)
|
11
|
+
diff = diff_attributes(from, to)
|
12
|
+
|
13
|
+
diff.merge! diff_collection(from, to)
|
14
|
+
diff.merge! diff_created_objects(from, to)
|
15
|
+
diff.merge! diff_destroyed_objects(from, to)
|
16
|
+
|
17
|
+
diff
|
18
|
+
end
|
19
|
+
|
20
|
+
def diff_attributes(from, to)
|
21
|
+
diff = {}
|
22
|
+
|
23
|
+
from.each do |attribute, old_value|
|
24
|
+
if attribute == 'id'
|
25
|
+
diff[attribute] = old_value
|
26
|
+
elsif !old_value.is_a?(Array)
|
27
|
+
new_value = to[attribute]
|
28
|
+
diff[attribute] = [old_value, new_value]
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
diff
|
33
|
+
end
|
34
|
+
|
35
|
+
def diff_collection(from, to)
|
36
|
+
diff = {}
|
37
|
+
|
38
|
+
from.each do |attribute, old_objects|
|
39
|
+
if old_objects.is_a? Array
|
40
|
+
new_objects = to[attribute]
|
41
|
+
|
42
|
+
old_objects.each do |old_object|
|
43
|
+
new_object = find_object(new_objects, old_object['id'])
|
44
|
+
|
45
|
+
if new_object
|
46
|
+
diff[attribute] ||= []
|
47
|
+
diff[attribute] << diff_object(old_object, new_object)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
diff
|
54
|
+
end
|
55
|
+
|
56
|
+
def diff_created_objects(from, to)
|
57
|
+
diff_object_creation from, to, 'created'
|
58
|
+
end
|
59
|
+
|
60
|
+
def diff_destroyed_objects(from, to)
|
61
|
+
diff_object_creation to, from, 'destroyed'
|
62
|
+
end
|
63
|
+
|
64
|
+
def diff_object_creation(from, to, event)
|
65
|
+
diff = {}
|
66
|
+
|
67
|
+
to.each do |attribute, to_objects|
|
68
|
+
if to_objects.is_a? Array
|
69
|
+
from_objects = from[attribute] || []
|
70
|
+
|
71
|
+
to_objects.each do |to_object|
|
72
|
+
from_object = find_object(from_objects, to_object['id'])
|
73
|
+
|
74
|
+
unless from_object
|
75
|
+
diff["#{event}_#{attribute}"] ||= []
|
76
|
+
diff["#{event}_#{attribute}"] << to_object
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
diff
|
83
|
+
end
|
84
|
+
|
85
|
+
def find_object(array, id)
|
86
|
+
index = array.find_index { |object| object['id'] == id }
|
87
|
+
array[index] if index
|
88
|
+
end
|
89
|
+
|
90
|
+
def is_same_object?(from, to)
|
91
|
+
from['id'] == to['id']
|
92
|
+
end
|
93
|
+
|
94
|
+
def has_collection?(value)
|
95
|
+
value.is_a? Array
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
File without changes
|
@@ -0,0 +1,100 @@
|
|
1
|
+
module Yesterday
|
2
|
+
module Model
|
3
|
+
|
4
|
+
def self.included(base)
|
5
|
+
base.extend ClassMethods
|
6
|
+
end
|
7
|
+
|
8
|
+
module ClassMethods
|
9
|
+
def exclude_tracking_for(options)
|
10
|
+
if options[:associations]
|
11
|
+
@excluded_tracked_associations ||= []
|
12
|
+
@excluded_tracked_associations += Array(options[:associations]).map(&:to_s)
|
13
|
+
end
|
14
|
+
|
15
|
+
if options[:attributes]
|
16
|
+
@excluded_tracked_attributes ||= []
|
17
|
+
@excluded_tracked_attributes += Array(options[:attributes]).map(&:to_s)
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def include_tracking_for(options)
|
22
|
+
if options[:associations]
|
23
|
+
@tracked_associations ||= []
|
24
|
+
@tracked_associations += Array(options[:associations]).map(&:to_s)
|
25
|
+
end
|
26
|
+
|
27
|
+
if options[:attributes]
|
28
|
+
@tracked_attributes ||= []
|
29
|
+
@tracked_attributes += Array(options[:attributes]).map(&:to_s)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def excluded_tracked_associations
|
34
|
+
@excluded_tracked_associations || []
|
35
|
+
end
|
36
|
+
|
37
|
+
def excluded_tracked_attributes
|
38
|
+
@excluded_tracked_attributes || []
|
39
|
+
end
|
40
|
+
|
41
|
+
def tracked_associations
|
42
|
+
@tracked_associations || []
|
43
|
+
end
|
44
|
+
|
45
|
+
def tracked_attributes
|
46
|
+
@tracked_attributes || []
|
47
|
+
end
|
48
|
+
|
49
|
+
def tracks_changes(options = {})
|
50
|
+
send :include, InstanceMethods
|
51
|
+
|
52
|
+
after_save :serialize_current_state
|
53
|
+
exclude_tracking_for :associations => :changesets
|
54
|
+
end
|
55
|
+
|
56
|
+
def version(version_number)
|
57
|
+
if object = first
|
58
|
+
Versioning.versioned_object_for(version_number, object)
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def diff_version(from_version_number, to_version_number)
|
63
|
+
if object = first
|
64
|
+
Versioning.diff_for(from_version_number, to_version_number, object)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
end
|
69
|
+
|
70
|
+
module InstanceMethods
|
71
|
+
def changesets
|
72
|
+
Versioning.changesets_for(self)
|
73
|
+
end
|
74
|
+
|
75
|
+
def version_number
|
76
|
+
Versioning.current_version_number_for(self)
|
77
|
+
end
|
78
|
+
|
79
|
+
def previous_version_number
|
80
|
+
version_number > 1 ? version_number - 1 : version_number
|
81
|
+
end
|
82
|
+
|
83
|
+
def version(version_number)
|
84
|
+
Versioning.versioned_object_for(version_number, self)
|
85
|
+
end
|
86
|
+
|
87
|
+
def diff_version(from_version_number, to_version_number)
|
88
|
+
Versioning.diff_for(from_version_number, to_version_number, self)
|
89
|
+
end
|
90
|
+
|
91
|
+
private
|
92
|
+
|
93
|
+
def serialize_current_state
|
94
|
+
Versioning.create_changeset_for self
|
95
|
+
end
|
96
|
+
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class Serializer < Struct.new(:object)
|
3
|
+
|
4
|
+
def to_hash
|
5
|
+
@hash ||= hash_object(object)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def hash_object(object)
|
11
|
+
hash = {}
|
12
|
+
hash.merge! attributes_for(object)
|
13
|
+
|
14
|
+
each_association_collection(object) do |association, association_name, item|
|
15
|
+
path << object
|
16
|
+
|
17
|
+
if assocation_has_collection?(association)
|
18
|
+
hash[association_name] ||= []
|
19
|
+
hash[association_name] << hash_object(item)
|
20
|
+
else
|
21
|
+
hash[association_name] = hash_object(item)
|
22
|
+
end
|
23
|
+
|
24
|
+
path.pop
|
25
|
+
end
|
26
|
+
|
27
|
+
hash
|
28
|
+
end
|
29
|
+
|
30
|
+
def assocation_has_collection?(association)
|
31
|
+
[:has_many, :has_and_belongs_to_many].include? association.macro
|
32
|
+
end
|
33
|
+
|
34
|
+
def have_visited_object?(object)
|
35
|
+
self.object == object || path.include?(object)
|
36
|
+
end
|
37
|
+
|
38
|
+
def each_association_collection(object)
|
39
|
+
associations_for(object).each do |association|
|
40
|
+
association_name = association.name.to_s
|
41
|
+
|
42
|
+
association_collection(object, association_name).each do |item|
|
43
|
+
yield(association, association_name, item) unless have_visited_object?(item)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def attributes_for(object)
|
49
|
+
attributes = object.attributes.dup
|
50
|
+
|
51
|
+
if object.class.respond_to?(:tracked_attributes) && object.class.tracked_attributes.present?
|
52
|
+
attributes.except!(*(attributes.keys - object.class.tracked_attributes))
|
53
|
+
end
|
54
|
+
|
55
|
+
if object.class.respond_to?(:excluded_tracked_attributes) && object.class.excluded_tracked_attributes.present?
|
56
|
+
attributes.except!(*object.class.excluded_tracked_attributes)
|
57
|
+
end
|
58
|
+
|
59
|
+
attributes
|
60
|
+
end
|
61
|
+
|
62
|
+
def associations_for(object)
|
63
|
+
associations = object.class.reflect_on_all_associations
|
64
|
+
|
65
|
+
tracked_associations = object.class.respond_to?(:tracked_associations) ? object.class.tracked_associations : []
|
66
|
+
ignored_associations = object.class.respond_to?(:excluded_tracked_associations) ? object.class.excluded_tracked_associations : []
|
67
|
+
|
68
|
+
associations.select do |association|
|
69
|
+
( !ignored_associations.include?(association.name.to_s) &&
|
70
|
+
( tracked_associations.empty? ||
|
71
|
+
tracked_associations.include?(association_name.to_s)) )
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def association_collection(object, association_name)
|
76
|
+
object.send(association_name).to_a
|
77
|
+
end
|
78
|
+
|
79
|
+
def visited_objects
|
80
|
+
@visited_objects ||= []
|
81
|
+
end
|
82
|
+
|
83
|
+
def path
|
84
|
+
@path ||= []
|
85
|
+
end
|
86
|
+
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class VersionedObject < Struct.new(:attributes)
|
3
|
+
|
4
|
+
def id
|
5
|
+
attributes['id']
|
6
|
+
end
|
7
|
+
|
8
|
+
def method_missing(method, *arguments, &block)
|
9
|
+
if attributes.has_key?(method.to_s)
|
10
|
+
attributes[method.to_s]
|
11
|
+
else
|
12
|
+
super
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class VersionedObjectCreator < Struct.new(:hash)
|
3
|
+
|
4
|
+
def to_object
|
5
|
+
@object ||= deserialize(hash)
|
6
|
+
end
|
7
|
+
|
8
|
+
private
|
9
|
+
|
10
|
+
def deserialize(hash)
|
11
|
+
attributes = {}
|
12
|
+
|
13
|
+
hash.each do |attribute, value|
|
14
|
+
if nested_value? value
|
15
|
+
value.each do |item|
|
16
|
+
attributes[attribute] ||= []
|
17
|
+
attributes[attribute] << deserialize(item)
|
18
|
+
end
|
19
|
+
|
20
|
+
elsif attribute != 'id'
|
21
|
+
attributes[attribute] = value.is_a?(Array) ? Yesterday::VersionedAttribute.new(value) : value
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
Yesterday::VersionedObject.new(attributes.merge({ 'id' => hash['id']}))
|
26
|
+
end
|
27
|
+
|
28
|
+
def nested_value?(value)
|
29
|
+
value.is_a?(Array) && value.first.is_a?(Hash)
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module Yesterday
|
2
|
+
class Versioning
|
3
|
+
class << self
|
4
|
+
def create_changeset_for(object)
|
5
|
+
Changeset.create :changed_object => object
|
6
|
+
end
|
7
|
+
|
8
|
+
def changesets_for(object)
|
9
|
+
Changeset.for_changed_object(object)
|
10
|
+
end
|
11
|
+
|
12
|
+
def current_version_number_for(object)
|
13
|
+
changesets_for(object).last.try(:version_number) || 0
|
14
|
+
end
|
15
|
+
|
16
|
+
def versioned_object_for(version_number, object)
|
17
|
+
changeset_for(version_number, object).try(:object)
|
18
|
+
end
|
19
|
+
|
20
|
+
def diff_for(from_version_number, to_version_number, object)
|
21
|
+
from_attributes = object_attributes_for(from_version_number, object)
|
22
|
+
to_attributes = object_attributes_for(to_version_number, object)
|
23
|
+
diff = Differ.new(from_attributes, to_attributes).diff
|
24
|
+
|
25
|
+
VersionedObjectCreator.new(diff).to_object
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def changeset_for(version_number, object)
|
31
|
+
Changeset.for_changed_object(object).version(version_number).first
|
32
|
+
end
|
33
|
+
|
34
|
+
def object_attributes_for(version_number, object)
|
35
|
+
changeset_for(version_number, object).object_attributes
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/yesterday.rb
ADDED
@@ -0,0 +1,17 @@
|
|
1
|
+
require 'active_support'
|
2
|
+
require 'active_support/core_ext'
|
3
|
+
require 'active_record'
|
4
|
+
|
5
|
+
require 'yesterday/changeset'
|
6
|
+
require 'yesterday/versioning'
|
7
|
+
require 'yesterday/differ'
|
8
|
+
require 'yesterday/versioned_object_creator'
|
9
|
+
require 'yesterday/versioned_object'
|
10
|
+
require 'yesterday/versioned_attribute'
|
11
|
+
require 'yesterday/model'
|
12
|
+
require 'yesterday/serializer'
|
13
|
+
require 'yesterday/version'
|
14
|
+
|
15
|
+
ActiveSupport.on_load(:active_record) do
|
16
|
+
include Yesterday::Model
|
17
|
+
end
|
data/spec/database.yml
ADDED