kkorach-acts_as_revisable 0.9.7
Sign up to get free protection for your applications and to get access to all the features.
- data/LICENSE +20 -0
- data/README.rdoc +222 -0
- data/Rakefile +44 -0
- data/generators/revisable_migration/revisable_migration_generator.rb +9 -0
- data/generators/revisable_migration/templates/migration.rb +13 -0
- data/lib/acts_as_revisable/acts/common.rb +231 -0
- data/lib/acts_as_revisable/acts/deletable.rb +29 -0
- data/lib/acts_as_revisable/acts/revisable.rb +479 -0
- data/lib/acts_as_revisable/acts/revision.rb +151 -0
- data/lib/acts_as_revisable/acts/scoped_model.rb +75 -0
- data/lib/acts_as_revisable/base.rb +66 -0
- data/lib/acts_as_revisable/clone_associations.rb +36 -0
- data/lib/acts_as_revisable/gem_spec_options.rb +18 -0
- data/lib/acts_as_revisable/options.rb +22 -0
- data/lib/acts_as_revisable/quoted_columns.rb +35 -0
- data/lib/acts_as_revisable/version.rb +11 -0
- data/lib/acts_as_revisable.rb +14 -0
- data/rails/init.rb +1 -0
- data/spec/associations_spec.rb +22 -0
- data/spec/branch_spec.rb +42 -0
- data/spec/find_spec.rb +38 -0
- data/spec/general_spec.rb +73 -0
- data/spec/options_spec.rb +83 -0
- data/spec/quoted_columns_spec.rb +19 -0
- data/spec/revert_spec.rb +42 -0
- data/spec/spec.opts +1 -0
- data/spec/spec_helper.rb +79 -0
- metadata +86 -0
@@ -0,0 +1,151 @@
|
|
1
|
+
require 'acts_as_revisable/clone_associations'
|
2
|
+
|
3
|
+
module FatJam
|
4
|
+
module ActsAsRevisable
|
5
|
+
# This module is mixed into the revision classes.
|
6
|
+
#
|
7
|
+
# ==== Callbacks
|
8
|
+
#
|
9
|
+
# * +before_restore+ is called on the revision class before it is
|
10
|
+
# restored as the current record.
|
11
|
+
# * +after_restore+ is called on the revision class after it is
|
12
|
+
# restored as the current record.
|
13
|
+
module Revision
|
14
|
+
def self.included(base) #:nodoc:
|
15
|
+
base.send(:extend, ClassMethods)
|
16
|
+
|
17
|
+
class << base
|
18
|
+
attr_accessor :revisable_revisable_class, :revisable_cloned_associations
|
19
|
+
end
|
20
|
+
|
21
|
+
base.instance_eval do
|
22
|
+
set_table_name(revisable_class.table_name)
|
23
|
+
acts_as_scoped_model :find => {:conditions => {:revisable_is_current => false}}
|
24
|
+
|
25
|
+
CloneAssociations.clone_associations(revisable_class, self)
|
26
|
+
|
27
|
+
define_callbacks :before_restore, :after_restore
|
28
|
+
before_create :revision_setup
|
29
|
+
after_create :grab_my_branches
|
30
|
+
|
31
|
+
[:current_revision, revisable_association_name.to_sym].each do |a|
|
32
|
+
belongs_to a, :class_name => revisable_class_name, :foreign_key => :revisable_original_id
|
33
|
+
end
|
34
|
+
|
35
|
+
[[:ancestors, "<"], [:descendants, ">"]].each do |a|
|
36
|
+
# Jumping through hoops here to try and make sure the
|
37
|
+
# :finder_sql is cross-database compatible. :finder_sql
|
38
|
+
# in a plugin is evil but, I see no other option.
|
39
|
+
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")}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def find_revision(*args)
|
45
|
+
current_revision.find_revision(*args)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Return the revision prior to this one.
|
49
|
+
def previous_revision
|
50
|
+
self.class.find(:first, :conditions => {:revisable_original_id => revisable_original_id, :revisable_number => revisable_number - 1})
|
51
|
+
end
|
52
|
+
|
53
|
+
# Return the revision after this one.
|
54
|
+
def next_revision
|
55
|
+
self.class.find(:first, :conditions => {:revisable_original_id => revisable_original_id, :revisable_number => revisable_number + 1})
|
56
|
+
end
|
57
|
+
|
58
|
+
# Setter for revisable_name just to make external API more pleasant.
|
59
|
+
def revision_name=(val) #:nodoc:
|
60
|
+
self[:revisable_name] = val
|
61
|
+
end
|
62
|
+
|
63
|
+
# Accessor for revisable_name just to make external API more pleasant.
|
64
|
+
def revision_name #:nodoc:
|
65
|
+
self[:revisable_name]
|
66
|
+
end
|
67
|
+
|
68
|
+
# Sets some initial values for a new revision.
|
69
|
+
def revision_setup #:nodoc:
|
70
|
+
now = Time.now
|
71
|
+
prev = current_revision.revisions.first
|
72
|
+
prev.update_attribute(:revisable_revised_at, now) if prev
|
73
|
+
self[:revisable_current_at] = now + 1.second
|
74
|
+
self[:revisable_is_current] = false
|
75
|
+
self[:revisable_branched_from_id] = current_revision[:revisable_branched_from_id]
|
76
|
+
self[:revisable_type] = current_revision[:type]
|
77
|
+
self[:revisable_number] = (self.class.maximum(:revisable_number, :conditions => {:revisable_original_id => self[:revisable_original_id]}) || 0) + 1
|
78
|
+
end
|
79
|
+
|
80
|
+
def grab_my_branches
|
81
|
+
self.class.revisable_class.update_all(["revisable_branched_from_id = ?", self[:id]], ["revisable_branched_from_id = ?", self[:revisable_original_id]])
|
82
|
+
end
|
83
|
+
|
84
|
+
def from_revisable
|
85
|
+
current_revision.for_revision
|
86
|
+
end
|
87
|
+
|
88
|
+
def reverting_from
|
89
|
+
from_revisable[:reverting_from]
|
90
|
+
end
|
91
|
+
|
92
|
+
def reverting_from=(val)
|
93
|
+
from_revisable[:reverting_from] = val
|
94
|
+
end
|
95
|
+
|
96
|
+
def reverting_to
|
97
|
+
from_revisable[:reverting_to]
|
98
|
+
end
|
99
|
+
|
100
|
+
def reverting_to=(val)
|
101
|
+
from_revisable[:reverting_to] = val
|
102
|
+
end
|
103
|
+
|
104
|
+
module ClassMethods
|
105
|
+
# Returns the +revisable_class_name+ as configured in
|
106
|
+
# +acts_as_revisable+.
|
107
|
+
def revisable_class_name #:nodoc:
|
108
|
+
self.revisable_options.revisable_class_name || self.class_name.gsub(/Revision/, '')
|
109
|
+
end
|
110
|
+
|
111
|
+
# Returns the actual +Revisable+ class based on the
|
112
|
+
# #revisable_class_name.
|
113
|
+
def revisable_class #:nodoc:
|
114
|
+
self.revisable_revisable_class ||= revisable_class_name.constantize
|
115
|
+
end
|
116
|
+
|
117
|
+
# Returns the revision_class which in this case is simply +self+.
|
118
|
+
def revision_class #:nodoc:
|
119
|
+
self
|
120
|
+
end
|
121
|
+
|
122
|
+
def revision_class_name #:nodoc:
|
123
|
+
self.name
|
124
|
+
end
|
125
|
+
|
126
|
+
# Returns the name of the association acts_as_revision
|
127
|
+
# creates.
|
128
|
+
def revisable_association_name #:nodoc:
|
129
|
+
revisable_class_name.downcase
|
130
|
+
end
|
131
|
+
|
132
|
+
# Returns an array of the associations that should be cloned.
|
133
|
+
def revision_cloned_associations #:nodoc:
|
134
|
+
clone_associations = self.revisable_options.clone_associations
|
135
|
+
|
136
|
+
self.revisable_cloned_associations ||= if clone_associations.blank?
|
137
|
+
[]
|
138
|
+
elsif clone_associations.eql? :all
|
139
|
+
revisable_class.reflect_on_all_associations.map(&:name)
|
140
|
+
elsif clone_associations.is_a? [].class
|
141
|
+
clone_associations
|
142
|
+
elsif clone_associations[:only]
|
143
|
+
[clone_associations[:only]].flatten
|
144
|
+
elsif clone_associations[:except]
|
145
|
+
revisable_class.reflect_on_all_associations.map(&:name) - [clone_associations[:except]].flatten
|
146
|
+
end
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
module FatJam
|
2
|
+
module ActsAsScopedModel
|
3
|
+
def self.included(base)
|
4
|
+
base.send(:extend, ClassMethods)
|
5
|
+
end
|
6
|
+
|
7
|
+
module ClassMethods
|
8
|
+
SCOPED_METHODS = %w(construct_calculation_sql construct_finder_sql update_all delete_all destroy_all).freeze
|
9
|
+
|
10
|
+
def call_method_with_static_scope(meth, args)
|
11
|
+
return send(meth, *args) unless self.scoped_model_enabled?
|
12
|
+
|
13
|
+
with_scope(self.scoped_model_static_scope) do
|
14
|
+
send(meth, *args)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
SCOPED_METHODS.each do |m|
|
19
|
+
module_eval <<-EVAL
|
20
|
+
def #{m}_with_static_scope(*args)
|
21
|
+
call_method_with_static_scope(:#{m}_without_static_scope, args)
|
22
|
+
end
|
23
|
+
EVAL
|
24
|
+
end
|
25
|
+
|
26
|
+
def without_model_scope
|
27
|
+
return unless block_given?
|
28
|
+
|
29
|
+
begin
|
30
|
+
self.scoped_model_enabled = false
|
31
|
+
rv = yield
|
32
|
+
ensure
|
33
|
+
self.scoped_model_enabled = true
|
34
|
+
end
|
35
|
+
|
36
|
+
rv
|
37
|
+
end
|
38
|
+
|
39
|
+
def disable_model_scope!
|
40
|
+
self.scoped_model_disable_count += 1
|
41
|
+
end
|
42
|
+
|
43
|
+
def enable_model_scope!
|
44
|
+
self.scoped_model_disable_count -= 1
|
45
|
+
end
|
46
|
+
|
47
|
+
def scoped_model_enabled?
|
48
|
+
self.scoped_model_disable_count == 0
|
49
|
+
end
|
50
|
+
|
51
|
+
def scoped_model_enabled
|
52
|
+
self.scoped_model_enabled?
|
53
|
+
end
|
54
|
+
|
55
|
+
def scoped_model_enabled=(value)
|
56
|
+
if value == false
|
57
|
+
disable_model_scope!
|
58
|
+
else
|
59
|
+
enable_model_scope!
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def acts_as_scoped_model(*args)
|
64
|
+
class << self
|
65
|
+
attr_accessor :scoped_model_static_scope, :scoped_model_disable_count
|
66
|
+
SCOPED_METHODS.each do |m|
|
67
|
+
alias_method_chain m.to_sym, :static_scope
|
68
|
+
end
|
69
|
+
end
|
70
|
+
self.scoped_model_disable_count = 0
|
71
|
+
self.scoped_model_static_scope = args.extract_options!
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'acts_as_revisable/options'
|
2
|
+
require 'acts_as_revisable/acts/common'
|
3
|
+
require 'acts_as_revisable/acts/revision'
|
4
|
+
require 'acts_as_revisable/acts/revisable'
|
5
|
+
require 'acts_as_revisable/acts/deletable'
|
6
|
+
|
7
|
+
module FatJam
|
8
|
+
# define the columns used internall by AAR
|
9
|
+
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)
|
10
|
+
|
11
|
+
# define the ActiveRecord magic columns that should not be monitored
|
12
|
+
REVISABLE_UNREVISABLE_COLUMNS = %w(id type created_at updated_at)
|
13
|
+
|
14
|
+
module ActsAsRevisable
|
15
|
+
def self.included(base)
|
16
|
+
base.send(:extend, ClassMethods)
|
17
|
+
end
|
18
|
+
|
19
|
+
module ClassMethods
|
20
|
+
|
21
|
+
# This +acts_as+ extension provides for making a model the
|
22
|
+
# revisable model in an acts_as_revisable pair.
|
23
|
+
def acts_as_revisable(*args, &block)
|
24
|
+
revisable_shared_setup(args, block)
|
25
|
+
self.send(:include, Revisable)
|
26
|
+
self.send(:include, Deletable) if self.revisable_options.on_delete == :revise
|
27
|
+
end
|
28
|
+
|
29
|
+
# This +acts_as+ extension provides for making a model the
|
30
|
+
# revision model in an acts_as_revisable pair.
|
31
|
+
def acts_as_revision(*args, &block)
|
32
|
+
revisable_shared_setup(args, block)
|
33
|
+
self.send(:include, Revision)
|
34
|
+
end
|
35
|
+
|
36
|
+
# This +acts_as+ extension provides for making a Single Table Inheritance model the
|
37
|
+
# revisable model in an acts_as_revisable pair.
|
38
|
+
def acts_as_revisable_sti(*args, &block)
|
39
|
+
revisable_shared_setup(args, block)
|
40
|
+
self.send(:extend, Common::ClassMethods)
|
41
|
+
self.send(:extend, Revisable::ClassMethods)
|
42
|
+
end
|
43
|
+
|
44
|
+
# This +acts_as+ extension provides for making a Single Table Inheritance model the
|
45
|
+
# revision model in an acts_as_revisable pair.
|
46
|
+
def acts_as_revision_sti(*args, &block)
|
47
|
+
revisable_shared_setup(args, block)
|
48
|
+
self.send(:extend, Common::ClassMethods)
|
49
|
+
self.send(:extend, Revision::ClassMethods)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
# Performs the setup needed for both kinds of acts_as_revisable
|
54
|
+
# models.
|
55
|
+
def revisable_shared_setup(args, block)
|
56
|
+
class << self
|
57
|
+
attr_accessor :revisable_options
|
58
|
+
end
|
59
|
+
options = args.extract_options!
|
60
|
+
self.revisable_options = Options.new(options, &block)
|
61
|
+
|
62
|
+
self.send(:include, Common)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
# This module encapsulates the methods used by ActsAsRevisable
|
2
|
+
# for cloning associations from one model to another.
|
3
|
+
module FatJam
|
4
|
+
module ActsAsRevisable
|
5
|
+
module CloneAssociations
|
6
|
+
class << self
|
7
|
+
def clone_associations(from, to)
|
8
|
+
return unless from.descends_from_active_record? && to.descends_from_active_record?
|
9
|
+
|
10
|
+
to.revision_cloned_associations.each do |key|
|
11
|
+
assoc = from.reflect_on_association(key)
|
12
|
+
meth = "clone_#{assoc.macro.to_s}_association"
|
13
|
+
meth = "clone_association" unless respond_to? meth
|
14
|
+
send(meth, assoc, to)
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def clone_association(association, to)
|
19
|
+
options = association.options.clone
|
20
|
+
options[:foreign_key] ||= "revisable_original_id"
|
21
|
+
to.send(association.macro, association.name, options)
|
22
|
+
end
|
23
|
+
|
24
|
+
def clone_belongs_to_association(association, to)
|
25
|
+
to.send(association.macro, association.name, association.options.clone)
|
26
|
+
end
|
27
|
+
|
28
|
+
def clone_has_many_association(association, to)
|
29
|
+
options = association.options.clone
|
30
|
+
options[:foreign_key] ||= "revisable_original_id"
|
31
|
+
to.send(association.macro, association.name, options)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module FatJam #:nodoc:
|
2
|
+
module ActsAsRevisable
|
3
|
+
class GemSpecOptions
|
4
|
+
HASH = {
|
5
|
+
:name => "fatjam-acts_as_revisable",
|
6
|
+
:version => FatJam::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 => "cavanaugh@fatjam.com",
|
9
|
+
:homepage => "http://github.com/fatjam/acts_as_revisable/tree/master",
|
10
|
+
:has_rdoc => true,
|
11
|
+
:authors => ["Rich Cavanaugh of JamLab, LLC.", "Stephen Caudill of JamLab, LLC."],
|
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 FatJam
|
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,35 @@
|
|
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 FatJam::QuotedColumnConditions
|
14
|
+
def self.included(base)
|
15
|
+
base.send(:extend, ClassMethods)
|
16
|
+
|
17
|
+
class << base
|
18
|
+
alias_method_chain :quote_bound_value, :quoted_column
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
module ClassMethods
|
23
|
+
def quote_bound_value_with_quoted_column(value)
|
24
|
+
if value.is_a?(Symbol) && column_names.member?(value.to_s)
|
25
|
+
# code borrowed from sanitize_sql_hash_for_conditions
|
26
|
+
attr = value.to_s
|
27
|
+
table_name = quoted_table_name
|
28
|
+
|
29
|
+
return "#{table_name}.#{connection.quote_column_name(attr)}"
|
30
|
+
end
|
31
|
+
|
32
|
+
quote_bound_value_without_quoted_column(value)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
$:.unshift(File.dirname(__FILE__)) unless
|
2
|
+
$:.include?(File.dirname(__FILE__)) || $:.include?(File.expand_path(File.dirname(__FILE__)))
|
3
|
+
|
4
|
+
require 'activesupport' unless defined? ActiveSupport
|
5
|
+
require 'activerecord' unless defined? ActiveRecord
|
6
|
+
|
7
|
+
require 'acts_as_revisable/version.rb'
|
8
|
+
require 'acts_as_revisable/acts/scoped_model'
|
9
|
+
require 'acts_as_revisable/quoted_columns'
|
10
|
+
require 'acts_as_revisable/base'
|
11
|
+
|
12
|
+
ActiveRecord::Base.send(:include, FatJam::ActsAsScopedModel)
|
13
|
+
ActiveRecord::Base.send(:include, FatJam::QuotedColumnConditions)
|
14
|
+
ActiveRecord::Base.send(:include, FatJam::ActsAsRevisable)
|
data/rails/init.rb
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require 'acts_as_revisable'
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require File.dirname(__FILE__) + '/spec_helper.rb'
|
2
|
+
|
3
|
+
describe FatJam::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
|