yesterday 0.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *.gem
2
+ .bundle
3
+ Gemfile.lock
4
+ pkg/*
5
+ yesterday.db
data/Gemfile ADDED
@@ -0,0 +1,8 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in yesterday.gemspec
4
+ gemspec
5
+
6
+ gem 'rails'
7
+ gem 'rspec'
8
+ gem 'sqlite3'
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,2 @@
1
+ require 'bundler'
2
+ Bundler::GemHelper.install_tasks
@@ -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,3 @@
1
+ module Yesterday
2
+ VERSION = "0.2"
3
+ end
@@ -0,0 +1,21 @@
1
+ module Yesterday
2
+ class VersionedAttribute < Struct.new(:diff)
3
+
4
+ def current
5
+ diff.last
6
+ end
7
+
8
+ def previous
9
+ diff.first
10
+ end
11
+
12
+ def to_s
13
+ current.to_s
14
+ end
15
+
16
+ def to_i
17
+ current.to_i
18
+ end
19
+
20
+ end
21
+ 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
@@ -0,0 +1,3 @@
1
+ test:
2
+ adapter: sqlite3
3
+ database: yesterday.db