diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1380c73 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2009 MaurĂ­cio Linhares + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README b/README new file mode 100644 index 0000000..ac2b320 --- /dev/null +++ b/README @@ -0,0 +1,37 @@ +master_slave_adapter - maurĂ­cio DOT linhares AT gmail DOT com +==== + +This simple plugin acts as a common ActiveRecord adapter and allows you to +setup a master-slave environment using any database you like (and is supported +by ActiveRecord). + +This plugin works by handling two connections, one to a master database, +that will receive all non-"SELECT" statements, and another to a slave database +that that is going to receive all SELECT statements. It also tries to do as +little black magic as possible, it works just like any other ActiveRecord database +adapter and performs no monkeypatching at all, so it's easy and simple to use +and understand. + +The master database connection will also receive SELECT calls if a transaction +is active at the moment or if a command is executed inside a "with_master" block: + +ActiveRecord::Base.with_master do # :with_master instructs the adapter + @users = User.all # to use the master connection inside the block +end + +To use this adapter you just have to install the plugin: + +ruby script/plugin install git://github.com/mauricio/master_database_adapter.git + +And then configure it at your database.yml file: + +development: + database: sample_development + username: root + adapter: master_slave # the adapter must be set to "master_slave" + host: 10.21.34.80 + master_slave_adapter: mysql # here's where you'll place the real database adapter name + master: # and here's where you'll add the master database configuration + database: talkies_development # you shouldn't specify an "adapter" here, the value at "master_slave_adapter" is going to be used + username: root + host: 10.21.34.82 \ No newline at end of file diff --git a/init.rb b/init.rb new file mode 100644 index 0000000..b3a8aea --- /dev/null +++ b/init.rb @@ -0,0 +1,2 @@ +# The plugin should not require anything, ActiveRecord itself will try +# to load the active_record/connection_adapters/master_slave_adapter \ No newline at end of file diff --git a/lib/active_record/connection_adapters/master_slave_adapter.rb b/lib/active_record/connection_adapters/master_slave_adapter.rb new file mode 100644 index 0000000..43bedaf --- /dev/null +++ b/lib/active_record/connection_adapters/master_slave_adapter.rb @@ -0,0 +1,6 @@ +require 'active_record/connection_adapters/abstract/database_statements' +require 'active_record/connection_adapters/abstract/schema_statements' + +require 'master_slave_adapter/adapter' +require 'master_slave_adapter/instance_methods_generation' +require 'master_slave_adapter/active_record_extensions' \ No newline at end of file diff --git a/lib/master_slave_adapter/active_record_extensions.rb b/lib/master_slave_adapter/active_record_extensions.rb new file mode 100644 index 0000000..2a1e5b4 --- /dev/null +++ b/lib/master_slave_adapter/active_record_extensions.rb @@ -0,0 +1,64 @@ +ActiveRecord::Base.class_eval do + + def reload_with_master( options = nil ) + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.with_master do + reload_without_master( options ) + end + end + + alias_method_chain :reload, :master + + class << self + + # + # Call this method to force a block of code to use the master connection + # instead of the slave: + # + # ActiveRecord::Base.with_master do + # User.count( :conditions => { :login => 'testuser' } ) + # end + # + # + def with_master + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.with_master do + yield + end + end + + def master_slave_connection( config ) + config = config.symbolize_keys + raise "You must provide a configuration for the master database" if config[:master].blank? + raise "You must provide a 'master_slave_adapter' value at your database config file" if config[:master_slave_adapter].blank? + + unless self.respond_to?( "#{config[:master_slave_adapter]}_connection" ) + + raise "there's no method called #{config[:master_slave_adapter]}_connection" + + begin + require 'rubygems' + gem "activerecord-#{config[:master_slave_adapter]}-adapter" + require "active_record/connection_adapters/#{config[:master_slave_adapter]}_adapter" + rescue LoadError + begin + require "active_record/connection_adapters/#{config[:master_slave_adapter]}_adapter" + rescue LoadError + raise "Please install the #{config[:master_slave_adapter]} adapter: `gem install activerecord-#{config[:master_slave_adapter]}-adapter` (#{$!})" + end + end + + end + + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.new( config ) + end + + def columns_with_master + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.with_master do + columns_without_master + end + end + + alias_method_chain :columns, :master + + end + +end \ No newline at end of file diff --git a/lib/master_slave_adapter/adapter.rb b/lib/master_slave_adapter/adapter.rb new file mode 100644 index 0000000..128f02f --- /dev/null +++ b/lib/master_slave_adapter/adapter.rb @@ -0,0 +1,100 @@ +module ActiveRecord + + module ConnectionAdapters + + class MasterSlaveAdapter + + SELECT_METHODS = [ :select_all, :select_one, :select_rows, :select_value, :select_values ] + + include ActiveSupport::Callbacks + define_callbacks :checkout, :checkin + + checkout :test_connections + + attr_accessor :connections + attr_accessor :database_config + + + delegate :select_all, :select_one, :select_rows, :select_value, :select_values, :to => :slave_connection + + def initialize( config ) + self.database_config = config + self.connections = [] + end + + def slave_connection + if ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.master_enabled? + master_connection + elsif @master_connection && @master_connection.open_transactions > 0 + master_connection + else + @slave_connection ||= ActiveRecord::Base.send( "#{self.database_config[:master_slave_adapter]}_connection", self.database_config.symbolize_keys ) + end + end + + def reconnect! + @active = true + self.connections.each { |c| c.reconnect! } + end + + def disconnect! + @active = false + self.connections.each { |c| c.disconnect! } + end + + def reset! + self.connections.each { |c| c.reset! } + end + + def method_missing( name, *args, &block ) + self.master_connection.send( name.to_sym, *args, &block ) + end + + def master_connection + @master_connection ||= ActiveRecord::Base.send( "#{self.database_config[:master_slave_adapter]}_connection", self.database_config[:master].symbolize_keys ) + end + + def connections + [ @master_connection, @slave_connection ].compact + end + + def test_connections + self.connections.each do |c| + begin + c.select_value( 'SELECT 1', 'test select' ) + rescue + c.reconnect! + end + end + end + + class << self + + def with_master + enable_master + begin + yield + ensure + disable_master + end + end + + def master_enabled? + Thread.current[ :master_slave_enabled ] + end + + def enable_master + Thread.current[ :master_slave_enabled ] = true + end + + def disable_master + Thread.current[ :master_slave_enabled ] = nil + end + + end + + end + + end + +end \ No newline at end of file diff --git a/lib/master_slave_adapter/instance_methods_generation.rb b/lib/master_slave_adapter/instance_methods_generation.rb new file mode 100644 index 0000000..7ef9d30 --- /dev/null +++ b/lib/master_slave_adapter/instance_methods_generation.rb @@ -0,0 +1,20 @@ +ignored_methods = ActiveRecord::ConnectionAdapters::MasterSlaveAdapter::SELECT_METHODS + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.instance_methods +ignored_methods.uniq! +ignored_methods.map! { |v| v.to_sym } + +instance_methods = ActiveRecord::ConnectionAdapters::DatabaseStatements.instance_methods + ActiveRecord::ConnectionAdapters::SchemaStatements.instance_methods +instance_methods.uniq! +instance_methods.map! { |v| v.to_sym } +instance_methods.reject! { |v| ignored_methods.include?( v ) } + +instance_methods.each do |method| + + ActiveRecord::ConnectionAdapters::MasterSlaveAdapter.class_eval %Q! + + def #{method}( *args, &block ) + self.master_connection.#{method}( *args, &block ) + end + + ! + +end \ No newline at end of file diff --git a/specs/specs.rb b/specs/specs.rb new file mode 100644 index 0000000..7a993b9 --- /dev/null +++ b/specs/specs.rb @@ -0,0 +1,90 @@ +require 'rubygems' +require 'active_record' +require 'spec' + +$LOAD_PATH << File.expand_path(File.join( File.dirname( __FILE__ ), '..', 'lib' )) + +require 'active_record/connection_adapters/master_slave_adapter' + +ActiveRecord::Base.instance_eval do + + def test_connection( config ) + config = config.symbolize_keys + + config[:master_slave_adapter] ? _slave : _master + end + + def _master=( new_master ) + @master = new_master + end + + def _master + @master + end + + def _slave=( new_slave ) + @slave = new_slave + end + + def _slave + @slave + end + +end + +describe ActiveRecord::ConnectionAdapters::MasterSlaveAdapter do + + before(:all) do + + @mocked_methods = { :verify! => true, :reconnect! => true, :run_callbacks => true, :disconnect! => true } + + ActiveRecord::Base._master = mock( 'master connection', @mocked_methods.merge( :open_transactions => 0 ) ) + ActiveRecord::Base._slave = mock( 'slave connection', @mocked_methods ) + + @master_connection = ActiveRecord::Base._master + @slave_connection = ActiveRecord::Base._slave + + @database_setup = { + :adapter => 'master_slave', + :username => 'root', + :database => 'master_slave_test', + :master_slave_adapter => 'test', + :master => { :username => 'root', :database => 'master_slave_test' } + } + + ActiveRecord::Base.establish_connection( @database_setup ) + + end + + [ :select_all, :select_one, :select_rows, :select_value, :select_values ].each do |method| + + it "Should send the method '#{method}' to the slave connection" do + @master_connection.stub!( :open_transactions ).and_return( 0 ) + @slave_connection.should_receive( method ).and_return( true ) + ActiveRecord::Base.connection.send( method ) + end + + it "Should send the method '#{method}' to the master connection if with_master was specified" do + @master_connection.should_receive( method ).and_return( true ) + ActiveRecord::Base.with_master do + ActiveRecord::Base.connection.send( method ) + end + end + + end + + it 'Should be a master slave connection' do + ActiveRecord::Base.connection.class.should == ActiveRecord::ConnectionAdapters::MasterSlaveAdapter + end + + it 'Should have a master connection' do + ActiveRecord::Base.connection.master_connection.should == @master_connection + end + + it 'Should have a slave connection' do + @master_connection.stub!( :open_transactions ).and_return( 0 ) + ActiveRecord::Base.connection.slave_connection.should == @slave_connection + end + + +end \ No newline at end of file