acts_as_revisable 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,148 @@
1
+ module WithoutScope
2
+ module ActsAsRevisable
3
+ # This module is mixed into the revision classes.
4
+ #
5
+ # ==== Callbacks
6
+ #
7
+ # * +before_restore+ is called on the revision class before it is
8
+ # restored as the current record.
9
+ # * +after_restore+ is called on the revision class after it is
10
+ # restored as the current record.
11
+ module Revision
12
+ def self.included(base) #:nodoc:
13
+ base.send(:extend, ClassMethods)
14
+
15
+ class << base
16
+ attr_accessor :revisable_revisable_class, :revisable_cloned_associations
17
+ end
18
+
19
+ base.instance_eval do
20
+ set_table_name(revisable_class.table_name)
21
+ default_scope :conditions => {:revisable_is_current => false}
22
+
23
+ define_callbacks :before_restore, :after_restore
24
+ before_create :revision_setup
25
+ after_create :grab_my_branches
26
+
27
+ named_scope :deleted, :conditions => ["? is not null", :revisable_deleted_at]
28
+
29
+ [:current_revision, revisable_association_name.to_sym].each do |a|
30
+ belongs_to a, :class_name => revisable_class_name, :foreign_key => :revisable_original_id
31
+ end
32
+
33
+ [[:ancestors, "<"], [:descendants, ">"]].each do |a|
34
+ # Jumping through hoops here to try and make sure the
35
+ # :finder_sql is cross-database compatible. :finder_sql
36
+ # in a plugin is evil but, I see no other option.
37
+ has_many a.first, :class_name => revision_class_name, :finder_sql => "select * from #{quoted_table_name} where #{quote_bound_value(:revisable_original_id)} = \#{revisable_original_id} and #{quote_bound_value(:revisable_number)} #{a.last} \#{revisable_number} and #{quote_bound_value(:revisable_is_current)} = #{quote_value(false)} order by #{quote_bound_value(:revisable_number)} #{(a.last.eql?("<") ? "DESC" : "ASC")}"
38
+ end
39
+ end
40
+ end
41
+
42
+ def find_revision(*args)
43
+ current_revision.find_revision(*args)
44
+ end
45
+
46
+ # Return the revision prior to this one.
47
+ def previous_revision
48
+ self.class.find(:first, :conditions => {:revisable_original_id => revisable_original_id, :revisable_number => revisable_number - 1})
49
+ end
50
+
51
+ # Return the revision after this one.
52
+ def next_revision
53
+ self.class.find(:first, :conditions => {:revisable_original_id => revisable_original_id, :revisable_number => revisable_number + 1})
54
+ end
55
+
56
+ # Setter for revisable_name just to make external API more pleasant.
57
+ def revision_name=(val) #:nodoc:
58
+ self[:revisable_name] = val
59
+ end
60
+
61
+ # Accessor for revisable_name just to make external API more pleasant.
62
+ def revision_name #:nodoc:
63
+ self[:revisable_name]
64
+ end
65
+
66
+ # Sets some initial values for a new revision.
67
+ def revision_setup #:nodoc:
68
+ now = Time.current
69
+ prev = current_revision.revisions.first
70
+ prev.update_attribute(:revisable_revised_at, now) if prev
71
+ self[:revisable_current_at] = now + 1.second
72
+ self[:revisable_is_current] = false
73
+ self[:revisable_branched_from_id] = current_revision[:revisable_branched_from_id]
74
+ self[:revisable_type] = current_revision[:type] || current_revision.class.name
75
+ end
76
+
77
+ def grab_my_branches
78
+ self.class.revisable_class.update_all(["revisable_branched_from_id = ?", self[:id]], ["revisable_branched_from_id = ?", self[:revisable_original_id]])
79
+ end
80
+
81
+ def from_revisable
82
+ current_revision.for_revision
83
+ end
84
+
85
+ def reverting_from
86
+ from_revisable[:reverting_from]
87
+ end
88
+
89
+ def reverting_from=(val)
90
+ from_revisable[:reverting_from] = val
91
+ end
92
+
93
+ def reverting_to
94
+ from_revisable[:reverting_to]
95
+ end
96
+
97
+ def reverting_to=(val)
98
+ from_revisable[:reverting_to] = val
99
+ end
100
+
101
+ module ClassMethods
102
+ # Returns the +revisable_class_name+ as configured in
103
+ # +acts_as_revisable+.
104
+ def revisable_class_name #:nodoc:
105
+ self.revisable_options.revisable_class_name || self.name.gsub(/Revision/, '')
106
+ end
107
+
108
+ # Returns the actual +Revisable+ class based on the
109
+ # #revisable_class_name.
110
+ def revisable_class #:nodoc:
111
+ self.revisable_revisable_class ||= self.revisable_class_name.constantize
112
+ end
113
+
114
+ # Returns the revision_class which in this case is simply +self+.
115
+ def revision_class #:nodoc:
116
+ self
117
+ end
118
+
119
+ def revision_class_name #:nodoc:
120
+ self.name
121
+ end
122
+
123
+ # Returns the name of the association acts_as_revision
124
+ # creates.
125
+ def revisable_association_name #:nodoc:
126
+ revisable_class_name.underscore
127
+ end
128
+
129
+ # Returns an array of the associations that should be cloned.
130
+ def revision_cloned_associations #:nodoc:
131
+ clone_associations = self.revisable_options.clone_associations
132
+
133
+ self.revisable_cloned_associations ||= if clone_associations.blank?
134
+ []
135
+ elsif clone_associations.eql? :all
136
+ revisable_class.reflect_on_all_associations.map(&:name)
137
+ elsif clone_associations.is_a? [].class
138
+ clone_associations
139
+ elsif clone_associations[:only]
140
+ [clone_associations[:only]].flatten
141
+ elsif clone_associations[:except]
142
+ revisable_class.reflect_on_all_associations.map(&:name) - [clone_associations[:except]].flatten
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,54 @@
1
+ require 'acts_as_revisable/options'
2
+ require 'acts_as_revisable/quoted_columns'
3
+ require 'acts_as_revisable/validations'
4
+ require 'acts_as_revisable/acts/common'
5
+ require 'acts_as_revisable/acts/revision'
6
+ require 'acts_as_revisable/acts/revisable'
7
+ require 'acts_as_revisable/acts/deletable'
8
+
9
+ module WithoutScope
10
+ # define the columns used internall by AAR
11
+ REVISABLE_SYSTEM_COLUMNS = %w(revisable_original_id revisable_branched_from_id revisable_number revisable_name revisable_type revisable_current_at revisable_revised_at revisable_deleted_at revisable_is_current)
12
+
13
+ # define the ActiveRecord magic columns that should not be monitored
14
+ REVISABLE_UNREVISABLE_COLUMNS = %w(id type created_at updated_at)
15
+
16
+ module ActsAsRevisable
17
+ def self.included(base)
18
+ base.send(:extend, ClassMethods)
19
+ end
20
+
21
+ module ClassMethods
22
+
23
+ # This +acts_as+ extension provides for making a model the
24
+ # revisable model in an acts_as_revisable pair.
25
+ def acts_as_revisable(*args, &block)
26
+ revisable_shared_setup(args, block)
27
+ self.send(:include, Revisable)
28
+ self.send(:include, Deletable) if self.revisable_options.on_delete == :revise
29
+ end
30
+
31
+ # This +acts_as+ extension provides for making a model the
32
+ # revision model in an acts_as_revisable pair.
33
+ def acts_as_revision(*args, &block)
34
+ revisable_shared_setup(args, block)
35
+ self.send(:include, Revision)
36
+ end
37
+
38
+ private
39
+ # Performs the setup needed for both kinds of acts_as_revisable
40
+ # models.
41
+ def revisable_shared_setup(args, block)
42
+ class << self
43
+ attr_accessor :revisable_options
44
+ end
45
+ options = args.extract_options!
46
+ self.revisable_options = Options.new(options, &block)
47
+
48
+ self.send(:include, Common)
49
+ self.send(:extend, Validations) unless self.revisable_options.no_validation_scoping?
50
+ self.send(:include, WithoutScope::QuotedColumnConditions)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,18 @@
1
+ module WithoutScope #:nodoc:
2
+ module ActsAsRevisable
3
+ class GemSpecOptions
4
+ HASH = {
5
+ :name => "acts_as_revisable",
6
+ :version => WithoutScope::ActsAsRevisable::VERSION::STRING,
7
+ :summary => "acts_as_revisable enables revision tracking, querying, reverting and branching of ActiveRecord models. Inspired by acts_as_versioned.",
8
+ :email => "rich@withoutscope.com",
9
+ :homepage => "http://github.com/rich/acts_as_revisable",
10
+ :has_rdoc => true,
11
+ :authors => ["Rich Cavanaugh", "Stephen Caudill"],
12
+ :files => %w( LICENSE README.rdoc Rakefile ) + Dir["{spec,lib,generators,rails}/**/*"],
13
+ :rdoc_options => ["--main", "README.rdoc"],
14
+ :extra_rdoc_files => ["README.rdoc", "LICENSE"]
15
+ }
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,22 @@
1
+ module WithoutScope
2
+ module ActsAsRevisable
3
+ # This class provides for a flexible method of setting
4
+ # options and querying them. This is especially useful
5
+ # for giving users flexibility when using your plugin.
6
+ class Options
7
+ def initialize(*options, &block)
8
+ @options = options.extract_options!
9
+ instance_eval(&block) if block_given?
10
+ end
11
+
12
+ def method_missing(key, *args)
13
+ return (@options[key.to_s.gsub(/\?$/, '').to_sym].eql?(true)) if key.to_s.match(/\?$/)
14
+ if args.blank?
15
+ @options[key.to_sym]
16
+ else
17
+ @options[key.to_sym] = args.size == 1 ? args.first : args
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # This module is more about the pretty than anything else. This allows
2
+ # you to use symbols for column names in a conditions hash.
3
+ #
4
+ # User.find(:all, :conditions => ["? = ?", :name, "sam"])
5
+ #
6
+ # Would generate:
7
+ #
8
+ # select * from users where "users"."name" = 'sam'
9
+ #
10
+ # This is consistent with Rails and Ruby where symbols are used to
11
+ # represent methods. Only a symbol matching a column name will
12
+ # trigger this beavior.
13
+ module WithoutScope::QuotedColumnConditions
14
+ def self.included(base)
15
+ base.send(:extend, ClassMethods)
16
+ end
17
+
18
+ module ClassMethods
19
+ def quote_bound_value(value)
20
+ if value.is_a?(Symbol) && column_names.member?(value.to_s)
21
+ # code borrowed from sanitize_sql_hash_for_conditions
22
+ attr = value.to_s
23
+ table_name = quoted_table_name
24
+
25
+ return "#{table_name}.#{connection.quote_column_name(attr)}"
26
+ end
27
+
28
+ super(value)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,11 @@
1
+ module WithoutScope
2
+ module ActsAsRevisable
3
+ module Validations
4
+ def validates_uniqueness_of(*args)
5
+ options = args.extract_options!
6
+ (options[:scope] ||= []) << :revisable_is_current
7
+ super(*(args << options))
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module WithoutScope #:nodoc:
2
+ module ActsAsRevisable
3
+ module VERSION #:nodoc:
4
+ MAJOR = 1
5
+ MINOR = 1
6
+ TINY = 1
7
+
8
+ STRING = [MAJOR, MINOR, TINY].join('.')
9
+ end
10
+ end
11
+ end
@@ -0,0 +1 @@
1
+ require 'acts_as_revisable'
@@ -0,0 +1,22 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe WithoutScope::ActsAsRevisable do
4
+ after(:each) do
5
+ cleanup_db
6
+ end
7
+
8
+ before(:each) do
9
+ @project = Project.create(:name => "Rich", :notes => "this plugin's author")
10
+ @project.update_attribute(:name, "one")
11
+ @project.update_attribute(:name, "two")
12
+ @project.update_attribute(:name, "three")
13
+ end
14
+
15
+ it "should have a pretty named association" do
16
+ lambda { @project.sessions }.should_not raise_error
17
+ end
18
+
19
+ it "should return all the revisions" do
20
+ @project.revisions.size.should == 3
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ class Project
4
+ validates_presence_of :name
5
+ end
6
+
7
+ describe WithoutScope::ActsAsRevisable, "with branching" do
8
+ after(:each) do
9
+ cleanup_db
10
+ end
11
+
12
+ before(:each) do
13
+ @project = Project.create(:name => "Rich", :notes => "a note")
14
+ @project.update_attribute(:name, "Sam")
15
+ end
16
+
17
+ it "should allow for branch creation" do
18
+ @project.should == @project.branch.branch_source
19
+ end
20
+
21
+ it "should branch without saving" do
22
+ @project.branch.should be_new_record
23
+ end
24
+
25
+ it "should branch and save" do
26
+ @project.branch!.should_not be_new_record
27
+ end
28
+
29
+ it "should not raise an error for a valid branch" do
30
+ lambda { @project.branch!(:name => "A New User") }.should_not raise_error
31
+ end
32
+
33
+ it "should raise an error for invalid records" do
34
+ lambda { @project.branch!(:name => nil) }.should raise_error
35
+ end
36
+
37
+ it "should not save an invalid record" do
38
+ @branch = @project.branch(:name => nil)
39
+ @branch.save.should be_false
40
+ @branch.should be_new_record
41
+ end
42
+ end
@@ -0,0 +1,16 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe WithoutScope::ActsAsRevisable::Deletable do
4
+ after(:each) do
5
+ cleanup_db
6
+ end
7
+
8
+ before(:each) do
9
+ @person = Person.create(:name => "Rich", :notes => "a note")
10
+ @person.update_attribute(:name, "Sam")
11
+ end
12
+
13
+ it "should store a revision on destroy" do
14
+ lambda{ @person.destroy }.should change(OldPerson, :count).from(1).to(2)
15
+ end
16
+ end
@@ -0,0 +1,34 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe WithoutScope::ActsAsRevisable do
4
+ after(:each) do
5
+ cleanup_db
6
+ end
7
+
8
+ describe "with a single revision" do
9
+ before(:each) do
10
+ @project1 = Project.create(:name => "Rich", :notes => "a note")
11
+ @project1.update_attribute(:name, "Sam")
12
+ end
13
+
14
+ it "should just find the current revision by default" do
15
+ Project.find(:first).name.should == "Sam"
16
+ end
17
+
18
+ it "should accept the :with_revisions options" do
19
+ lambda { Project.find(:all, :with_revisions => true) }.should_not raise_error
20
+ end
21
+
22
+ it "should find current and revisions with the :with_revisions option" do
23
+ Project.find(:all, :with_revisions => true).size.should == 2
24
+ end
25
+
26
+ it "should find revisions with conditions" do
27
+ Project.find(:all, :conditions => {:name => "Rich"}, :with_revisions => true).should == [@project1.find_revision(:previous)]
28
+ end
29
+
30
+ it "should find last revision" do
31
+ @project1.find_revision(:last).should == @project1.find_revision(:previous)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,115 @@
1
+ require File.dirname(__FILE__) + '/spec_helper.rb'
2
+
3
+ describe WithoutScope::ActsAsRevisable do
4
+ after(:each) do
5
+ cleanup_db
6
+ end
7
+
8
+ before(:each) do
9
+ @project = Project.create(:name => "Rich", :notes => "this plugin's author")
10
+ @post = Post.create(:name => 'a name')
11
+ end
12
+
13
+ describe "with auto-detected revision class" do
14
+ it "should find the revision class" do
15
+ Post.revision_class.should == PostRevision
16
+ end
17
+
18
+ it "should find the revisable class" do
19
+ PostRevision.revisable_class.should == Post
20
+ end
21
+
22
+ it "should use the revision class" do
23
+ @post.update_attribute(:name, 'another name')
24
+ @post.revisions(true).first.class.should == PostRevision
25
+ end
26
+ end
27
+
28
+ describe "with auto-generated revision class" do
29
+ it "should have a revision class" do
30
+ Foo.revision_class.should == FooRevision
31
+ end
32
+ end
33
+
34
+ describe "without revisions" do
35
+ it "should have a revision_number of zero" do
36
+ @project.revision_number.should be_zero
37
+ end
38
+
39
+ it "should be the current revision" do
40
+ @project.revisable_is_current.should be_true
41
+ end
42
+
43
+ it "should respond to current_revision? positively" do
44
+ @project.current_revision?.should be_true
45
+ end
46
+
47
+ it "should not have any revisions in the generic association" do
48
+ @project.revisions.should be_empty
49
+ end
50
+
51
+ it "should not have any revisions in the pretty named association" do
52
+ @project.sessions.should be_empty
53
+ end
54
+ end
55
+
56
+ describe "with revisions" do
57
+ before(:each) do
58
+ @project.update_attribute(:name, "Stephen")
59
+ end
60
+
61
+ it "should have a revision_number of one" do
62
+ @project.revision_number.should == 1
63
+ end
64
+
65
+ it "should have a single revision in the generic association" do
66
+ @project.revisions.size.should == 1
67
+ end
68
+
69
+ it "should have a single revision in the pretty named association" do
70
+ @project.sessions.size.should == 1
71
+ end
72
+
73
+ it "should have a single revision with a revision_number of zero" do
74
+ @project.revisions.collect{ |rev| rev.revision_number }.should == [0]
75
+ end
76
+
77
+ it "should return an instance of the revision class" do
78
+ @project.revisions.first.should be_an_instance_of(Session)
79
+ end
80
+
81
+ it "should have the original revision's data" do
82
+ @project.revisions.first.name.should == "Rich"
83
+ end
84
+ end
85
+
86
+ describe "with multiple revisions" do
87
+ before(:each) do
88
+ @project.update_attribute(:name, "Stephen")
89
+ @project.update_attribute(:name, "Michael")
90
+ end
91
+
92
+ it "should have a revision_number of two" do
93
+ @project.revision_number.should == 2
94
+ end
95
+
96
+ it "should have revisions with revision_number values of zero and one" do
97
+ @project.revisions.collect{ |rev| rev.revision_number }.should == [1,0]
98
+ end
99
+ end
100
+
101
+
102
+ describe "with excluded columns modified" do
103
+ before(:each) do
104
+ @project.update_attribute(:unimportant, "a new value")
105
+ end
106
+
107
+ it "should maintain the revision_number at zero" do
108
+ @project.revision_number.should be_zero
109
+ end
110
+
111
+ it "should not have any revisions" do
112
+ @project.revisions.should be_empty
113
+ end
114
+ end
115
+ end