acts_as_revisable 1.1.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.
@@ -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