rollbacker 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,8 @@
1
+ coverage
2
+ rdoc
3
+ *.gem
4
+ .bundle
5
+ Gemfile.lock
6
+ pkg/*
7
+ tmp/*
8
+ .DS_Store
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "http://rubygems.org"
2
+
3
+ gemspec
data/LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright (c) 2009 Near Infinity Corporation
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,16 @@
1
+ ## Rollbacker ##
2
+
3
+ Rollbacker is a manage tool for auditing changes to your ActiveRecord.
4
+ The changes of objects are added to a queue where the auditor can approve and reject those changes.
5
+
6
+ ## Installation ##
7
+
8
+ To use it with your Rails 3 project, add the following line to your Gemfile
9
+
10
+ gem 'rollbacker'
11
+
12
+ Generate the migration and create the rollbacker_changes table
13
+
14
+ rails generate rollbacker:migration
15
+ rake db:migrate
16
+
data/Rakefile ADDED
@@ -0,0 +1,33 @@
1
+ $:.unshift File.expand_path("../lib", __FILE__)
2
+
3
+ require 'rake'
4
+ require 'rdoc/task'
5
+ require 'rspec/core/rake_task'
6
+ require 'bundler'
7
+
8
+ Bundler::GemHelper.install_tasks
9
+
10
+ desc 'Default: run specs'
11
+ task :default => :spec
12
+
13
+ desc "Run specs"
14
+ RSpec::Core::RakeTask.new do |t|
15
+ t.rspec_opts = %w(-fs --color)
16
+ end
17
+
18
+ desc "Run specs with RCov"
19
+ RSpec::Core::RakeTask.new(:rcov) do |t|
20
+ t.rspec_opts = %w(-fs --color)
21
+ t.rcov = true
22
+ t.rcov_opts = %w(--exclude "spec/*,gems/*")
23
+ end
24
+
25
+ desc 'Generate documentation for the gem.'
26
+ Rake::RDocTask.new(:rdoc) do |rdoc|
27
+ rdoc.rdoc_dir = 'rdoc'
28
+ rdoc.title = 'Rollbacker'
29
+ rdoc.options << '--line-numbers' << '--inline-source'
30
+ rdoc.rdoc_files.include('README.md')
31
+ rdoc.rdoc_files.include('lib/**/*.rb')
32
+ end
33
+
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'rollbacker'
@@ -0,0 +1,26 @@
1
+ require 'rails/generators'
2
+ require 'rails/generators/migration'
3
+
4
+ module Rollbacker
5
+ module Generators
6
+ class MigrationGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ desc "Create migration for Rollbacker rollbacker_changes table"
10
+
11
+ source_root File.expand_path("../templates", __FILE__)
12
+
13
+ def self.next_migration_number(dirname)
14
+ if ActiveRecord::Base.timestamped_migrations
15
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
16
+ else
17
+ "%.3d" % (current_migration_number(dirname) + 1)
18
+ end
19
+ end
20
+
21
+ def create_migration_file
22
+ migration_template 'migration.rb', 'db/migrate/create_rollbacker_changes_table.rb'
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ class CreateRollbackerChangesTable < ActiveRecord::Migration
2
+ def self.up
3
+ create_table :rollbacker_changes, :force => true do |t|
4
+ t.column :rollbackable_id, :integer
5
+ t.column :rollbackable_type, :string
6
+ t.column :user_id, :integer
7
+ t.column :user_type, :string
8
+ t.column :action, :string
9
+ t.column :rollbacked_changes, :text
10
+ t.column :created_at, :datetime
11
+ t.column :updated_at, :datetime
12
+ end
13
+
14
+ add_index :rollbacker_changes, [:rollbackable_id, :rollbackable_type], :name => 'rollbackable_index'
15
+ add_index :rollbacker_changes, [:user_id, :user_type], :name => 'user_index'
16
+ add_index :rollbacker_changes, :created_at
17
+ end
18
+
19
+ def self.down
20
+ drop_table :rollbacker_changes
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ module Rollbacker
2
+ module Generators
3
+ class Base < Rails::Generators::NamedBase
4
+ def self.source_root
5
+ File.expand_path(File.join(File.dirname(__FILE__), 'rollbacker', generator_name, 'templates'))
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,49 @@
1
+ require 'rollbacker/status'
2
+ require 'rollbacker/config'
3
+ require 'rollbacker/database_rollback'
4
+ require 'rollbacker/recorder'
5
+
6
+ module Rollbacker
7
+ module Base
8
+
9
+ def self.included(base)
10
+ base.extend(ClassMethods)
11
+ end
12
+
13
+ module ClassMethods
14
+ def rollbacker(*args, &blk)
15
+ unless self.included_modules.include?(ActiveRecord::Transactions::ClassMethods)
16
+ include ActiveRecord::Transactions::ClassMethods
17
+ end
18
+ unless self.included_modules.include?(Rollbacker::Base::InstanceMethods)
19
+ include InstanceMethods
20
+ include Rollbacker::Status unless self.included_modules.include?(Rollbacker::Status)
21
+ has_many :rollbacker_changes, :as => :rollbackable
22
+ end
23
+
24
+ config = Rollbacker::Config.new(*args)
25
+ config.actions.each do |action|
26
+ send "around_#{action}", Rollbacker::DatabaseRollback.new(config.options)
27
+ # send :after_rollback, Rollbacker::Recorder.new(action, config.options, &blk), :on => action
28
+ end
29
+ send :after_rollback, Rollbacker::Recorder.new(config.options, &blk)
30
+ end
31
+
32
+ def rollbacker!(*args, &blk)
33
+ if args.last.kind_of?(Hash)
34
+ args.last[:fail_on_error] = true
35
+ else
36
+ args << { :fail_on_error => true }
37
+ end
38
+
39
+ rollbacker(*args, &blk)
40
+ end
41
+ end
42
+
43
+ module InstanceMethods
44
+ attr_accessor :_rollbacker_action
45
+
46
+ end
47
+
48
+ end
49
+ end
@@ -0,0 +1,30 @@
1
+ module Rollbacker
2
+ class ChangeValidator
3
+ attr_reader :action
4
+ attr_reader :options
5
+
6
+ def initialize(action, options, model)
7
+ @action = action
8
+ @options = options
9
+ @model = model
10
+ end
11
+
12
+ def valid?
13
+ case @action
14
+ when :destroy
15
+ @model.persisted?
16
+ when :update, :create
17
+ self.changes.any?
18
+ end
19
+ end
20
+
21
+ def changes(other_changes={})
22
+ chg = @model.changes.dup
23
+ chg.reverse_merge!( (other_changes || {}).with_indifferent_access )
24
+ chg.reject!{|key, value| @options[:except].include?(key) } unless @options[:except].blank?
25
+ chg.reject!{|key, value| !@options[:only].include?(key) } unless @options[:only].blank?
26
+ chg
27
+ end
28
+
29
+ end
30
+ end
@@ -0,0 +1,34 @@
1
+ module Rollbacker
2
+ class Config
3
+ attr_reader :actions
4
+ attr_reader :options
5
+
6
+ def self.valid_actions
7
+ @valid_actions ||= [:create, :update, :destroy]
8
+ end
9
+
10
+ def initialize(*args)
11
+ @options = (args.pop if args.last.kind_of?(Hash)) || {}
12
+ normalize_options(@options)
13
+
14
+ @actions = args.map(&:to_sym)
15
+ validate_actions(@actions)
16
+ end
17
+
18
+ private
19
+
20
+ def normalize_options(options)
21
+ options.each_pair { |k, v| options[k.to_sym] = options.delete(k) unless k.kind_of? Symbol }
22
+ options[:only] ||= []
23
+ options[:except] ||= []
24
+ options[:only] = Array(options[:only]).map(&:to_s)
25
+ options[:except] = Array(options[:except]).map(&:to_s)
26
+ end
27
+
28
+ def validate_actions(actions)
29
+ raise Rollbacker::Error.new "at least one action in #{Config.valid_actions.inspect} must be specified" if actions.empty?
30
+ raise Rollbacker::Error.new "#{Config.valid_actions.inspect} are the only valid actions" unless actions.all? { |a| Config.valid_actions.include?(a.to_sym) }
31
+ end
32
+
33
+ end
34
+ end
@@ -0,0 +1,28 @@
1
+ require 'rollbacker/status'
2
+ require 'rollbacker/change_validator'
3
+
4
+ module Rollbacker
5
+ class DatabaseRollback
6
+ include Status
7
+
8
+ def initialize(args)
9
+ @options = args
10
+ end
11
+
12
+ [:create, :update, :destroy].each do |action|
13
+ define_method("around_#{action}") do |model|
14
+ rollback_model_changes(model, action)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def rollback_model_changes(model, action)
21
+ model._rollbacker_action = action
22
+
23
+ if rollbacker_enabled? && ChangeValidator.new(action, @options, model).valid?
24
+ raise ActiveRecord::Rollback
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ require 'rollbacker/status'
2
+
3
+ module Rollbacker
4
+ class Recorder
5
+ include Status
6
+
7
+ # def initialize(action, options, &blk)
8
+ # @action = action
9
+ # @options = options
10
+ # @blk = blk
11
+ # end
12
+
13
+ def initialize(options, &blk)
14
+ @options = options
15
+ @blk = blk
16
+ end
17
+
18
+ def after_rollback(model)
19
+ create_or_update_changes(model)
20
+ end
21
+
22
+ private
23
+ # Should just return @action if after_rollback(:on=>:destroy) should set the correct action.. Take a look at this issue:
24
+ # https://github.com/rails/rails/issues/7640
25
+ #
26
+ # **Also remove everything about _rollbacker_action instance method.**
27
+ #
28
+ # def action
29
+ # @action
30
+ # end
31
+
32
+ def change_validator(model)
33
+ ChangeValidator.new(model._rollbacker_action, @options, model)
34
+ end
35
+
36
+ def create_or_update_changes(model)
37
+ return if rollbacker_disabled?
38
+ validator = change_validator(model)
39
+ return unless validator.valid?
40
+ user = Rollbacker::User.current_user
41
+
42
+ record = \
43
+ if model.new_record?
44
+ RollbackerChange.new(rollbackable_type: model.class.name, action: validator.action)
45
+ else
46
+ RollbackerChange.find_or_initialize_by_rollbackable_id_and_rollbackable_type_and_action(model.id, model.class.name, validator.action)
47
+ end
48
+ if model.changed?
49
+ record.rollbacked_changes = validator.changes(record.rollbacked_changes)
50
+ end
51
+
52
+ @blk.call(model, record, user, validator.action) if @blk
53
+
54
+ @options[:fail_on_error] ? record.save! : record.save
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,25 @@
1
+ require 'active_record'
2
+ require 'rollbacker/config'
3
+
4
+ class RollbackerChange < ActiveRecord::Base
5
+ belongs_to :rollbackable, :polymorphic => true
6
+ belongs_to :user, :polymorphic => true
7
+
8
+ before_create :set_user
9
+
10
+ serialize :rollbacked_changes
11
+
12
+ def new_attributes
13
+ (rollbacked_changes || {}).inject({}.with_indifferent_access) do |attrs,(attr,values)|
14
+ attrs[attr] = values.is_a?(Array) ? values.last : values
15
+ attrs
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def set_user
22
+ self.user = Rollbacker::User.current_user if self.user_id.nil?
23
+ end
24
+
25
+ end
@@ -0,0 +1,20 @@
1
+ module Rollbacker
2
+ module SpecHelpers
3
+ include Rollbacker::Status
4
+
5
+ def self.included(base)
6
+ base.class_eval do
7
+ before(:each) do
8
+ disable_rollbacker!
9
+ end
10
+
11
+ after(:each) do
12
+ enable_rollbacker!
13
+ end
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+
20
+
@@ -0,0 +1,62 @@
1
+ require 'rollbacker/user'
2
+
3
+ module Rollbacker
4
+ module Status
5
+
6
+ def rollbacker_disabled?
7
+ Thread.current[:rollbacker_disabled] == true
8
+ end
9
+
10
+ def rollbacker_enabled?
11
+ Thread.current[:rollbacker_disabled].nil? || Thread.current[:rollbacker_disabled] == false
12
+ end
13
+
14
+ def disable_rollbacker!
15
+ Thread.current[:rollbacker_disabled] = true
16
+ end
17
+
18
+ def enable_rollbacker!
19
+ Thread.current[:rollbacker_disabled] = false
20
+ end
21
+
22
+ def without_rollbacker
23
+ previously_disabled = rollbacker_disabled?
24
+
25
+ begin
26
+ disable_rollbacker!
27
+ result = yield if block_given?
28
+ ensure
29
+ enable_rollbacker! unless previously_disabled
30
+ end
31
+
32
+ result
33
+ end
34
+
35
+ def with_rollbacker
36
+ previously_disabled = rollbacker_disabled?
37
+
38
+ begin
39
+ enable_rollbacker!
40
+ result = yield if block_given?
41
+ ensure
42
+ disable_rollbacker! if previously_disabled
43
+ end
44
+
45
+ result
46
+ end
47
+
48
+ def rollbacker_as(user)
49
+ previous_user = Rollbacker::User.current_user
50
+
51
+ begin
52
+ Rollbacker::User.current_user = user
53
+ result = yield if block_given?
54
+ ensure
55
+ Rollbacker::User.current_user = previous_user
56
+ end
57
+
58
+ result
59
+ end
60
+
61
+ end
62
+ end
@@ -0,0 +1,15 @@
1
+ module Rollbacker
2
+ module User
3
+
4
+ def current_user
5
+ Thread.current[:rollbacker_user]
6
+ end
7
+
8
+ def current_user=(user)
9
+ Thread.current[:rollbacker_user] = user
10
+ end
11
+
12
+ module_function :current_user, :current_user=
13
+
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module Rollbacker
2
+ VERSION = "1.0.0"
3
+ end
data/lib/rollbacker.rb ADDED
@@ -0,0 +1,21 @@
1
+ require 'rollbacker/rollbacker_change'
2
+ require 'rollbacker/base'
3
+
4
+ module Rollbacker
5
+ class Error < StandardError; end
6
+ end
7
+
8
+ ActiveRecord::Base.send :include, Rollbacker::Base
9
+
10
+ if defined?(ActionController) and defined?(ActionController::Base)
11
+
12
+ require 'rollbacker/user'
13
+
14
+ ActionController::Base.class_eval do
15
+ before_filter do |c|
16
+ Rollbacker::User.current_user = c.send(:current_user) if c.respond_to?(:current_user)
17
+ end
18
+ end
19
+
20
+ end
21
+
@@ -0,0 +1,22 @@
1
+ # -*- encoding: utf-8 -*-
2
+ require File.expand_path('../lib/rollbacker/version', __FILE__)
3
+
4
+ Gem::Specification.new do |gem|
5
+ gem.authors = ["Marcos G. Zimmermann"]
6
+ gem.email = ["mgzmaster@gmail.com"]
7
+ gem.homepage = "http://github.com/marcosgz/rollbacker"
8
+ gem.name = "rollbacker"
9
+ gem.summary = %q{Rollbacker is a manage tool for auditing changes to your ActiveRecord}
10
+ gem.description = %q{Rollbacker allows you to declaratively specify what CRUD operations should be audited. The changes of objects are added to a queue where the auditor can approve and reject those changes.}
11
+ gem.version = Rollbacker::VERSION
12
+
13
+ gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
14
+ gem.files = `git ls-files`.split("\n")
15
+ gem.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
16
+ gem.require_paths = ["lib"]
17
+
18
+ gem.add_dependency('activerecord', '>= 3.0')
19
+
20
+ gem.add_development_dependency('rspec')
21
+ gem.add_development_dependency('sqlite3-ruby')
22
+ end
data/spec/base_spec.rb ADDED
@@ -0,0 +1,127 @@
1
+ require File.dirname(__FILE__) + '/spec_helper'
2
+ require 'rollbacker/user'
3
+ require 'rollbacker/status'
4
+
5
+ describe Rollbacker::Base do
6
+ include Rollbacker::Status
7
+
8
+ before(:each) do
9
+ @user = User.create
10
+ @original_model = Model
11
+ Rollbacker::User.current_user = @user
12
+ end
13
+
14
+ after(:each) do
15
+ reset_model
16
+ end
17
+
18
+ it 'should be created a :create change record' do
19
+ redefine_model { rollbacker!(:create) }
20
+
21
+ m = Model.new(:name => 'new')
22
+ m.should be_valid
23
+ m.should be_changed
24
+ lambda {
25
+ lambda { m.save }.should change(RollbackerChange, :count).by(1)
26
+ }.should_not change(Model, :count)
27
+
28
+ verify_change(RollbackerChange.last, m, :create, { 'name' => [nil, 'new'], 'id' => [nil, m.id] })
29
+ end
30
+
31
+ it 'should be created a :update change record' do
32
+ redefine_model { rollbacker!(:update) }
33
+ m = Model.new(:name => 'new')
34
+ without_rollbacker { m.save }
35
+
36
+ lambda {
37
+ lambda { m.update_attributes(:name => 'newer') }.should change(RollbackerChange, :count).by(1)
38
+ }.should_not change(Model, :count)
39
+
40
+ verify_change(RollbackerChange.last, m, :update, { 'name' => ['new', 'newer'] })
41
+ end
42
+
43
+
44
+ it 'should be created a :destroy change record' do
45
+ redefine_model { rollbacker!(:destroy) }
46
+ m = without_rollbacker { Model.create(:name => 'new') }
47
+
48
+ lambda {
49
+ lambda { m.destroy }.should change(RollbackerChange, :count).by(1)
50
+ }.should_not change(Model, :count)
51
+
52
+ verify_change(RollbackerChange.last, m, :destroy)
53
+ end
54
+
55
+ it 'should allow multiple actions to be specified with one rollbacker statment' do
56
+ redefine_model { rollbacker!(:update, :destroy) }
57
+ m = Model.new(:name => 'new')
58
+ lambda {
59
+ m.save
60
+ }.should_not change(RollbackerChange, :count)
61
+ m.should be_persisted
62
+ lambda {
63
+ m.update_attributes({:name => 'newer'})
64
+ }.should change(RollbackerChange, :count).by(1)
65
+ m.should be_persisted
66
+ lambda {
67
+ m.destroy
68
+ }.should change(RollbackerChange, :count).by(1)
69
+
70
+ RollbackerChange.count.should == 2
71
+ edit1 = RollbackerChange.first
72
+ edit1.action.should == 'update'
73
+ edit2 = RollbackerChange.last
74
+ edit2.action.should == 'destroy'
75
+ end
76
+
77
+ it 'should be able to turn off rollbacker for a especific field' do
78
+ redefine_model { rollbacker!(:update, :except => :name) }
79
+
80
+ m = Model.new(:name => 'name')
81
+ lambda {
82
+ m.save
83
+ }.should_not change(RollbackerChange, :count)
84
+ lambda {
85
+ m.update_attributes({:name => 'new'})
86
+ }.should_not change(RollbackerChange, :count)
87
+ lambda {
88
+ m.update_attributes({:value => 'new'})
89
+ }.should change(RollbackerChange, :count).by(1)
90
+ end
91
+
92
+ it 'should be able to to track rollbacker changes for a especific field' do
93
+ redefine_model { rollbacker!(:update, :only => :value) }
94
+
95
+ m = Model.new(:name => 'name')
96
+ lambda {
97
+ m.save
98
+ }.should_not change(RollbackerChange, :count)
99
+ lambda {
100
+ m.update_attributes({:name => 'new'})
101
+ }.should_not change(RollbackerChange, :count)
102
+ lambda {
103
+ m.update_attributes({:value => 'new'})
104
+ }.should change(RollbackerChange, :count).by(1)
105
+ end
106
+
107
+ def verify_change(change_record, model, action, changes=nil)
108
+ change_record.should_not be_nil
109
+ change_record.rollbackable_id.should == model.id
110
+ change_record.rollbackable_type.should == model.class.to_s
111
+ change_record.action.should == action.to_s
112
+ change_record.user.should == @user
113
+ change_record.rollbacked_changes.should == changes.reject{|k,v|v.map(&:nil?).all?} unless changes.nil?
114
+ end
115
+
116
+ def redefine_model(&blk)
117
+ clazz = Class.new(ActiveRecord::Base, &blk)
118
+ Object.send :remove_const, 'Model'
119
+ Object.send :const_set, 'Model', clazz
120
+ end
121
+
122
+ def reset_model
123
+ Object.send :remove_const, 'Model'
124
+ Object.send :const_set, 'Model', @original_model
125
+ end
126
+
127
+ end