decisiv-sharded_database 0.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.
data/README.rdoc ADDED
@@ -0,0 +1,54 @@
1
+ = sharded_database
2
+
3
+ == Overview
4
+
5
+ This ActiveRecord plugin is the end result of a real world problem - First, how to aggregate table data from across multiple databases for the purpose of pagination / searching. Second, how to transparently have one production instance have read/write access to a set of aggregated records pulled from a global view.
6
+
7
+ The first step was solved by creating a SQL view to pool all production records together for a given databse. This SQL view resides on a central database accessible by all instances of our application. From there, we query against this view to return a set of Aggregate* records. The second step, which this plugin handles, is proxying to the original database/record for the 'aggregate' instance and transparently interacting with the returned aggregate record as if it were the original object.
8
+
9
+
10
+ == Uses
11
+
12
+ Currently, sharded_database works by reflecting on a set of returned records, and taking the result of #determine_connection to delegate the connection to - for each instance. The path we chose had us creating a view that would return aggregate records, with an added 'source' column specifying the originating database. We then rigged our #determine_connection to return an abstract connection model depending on the value. Other ideas for implementation include an after_create callback that writes the source database / ID to a centralized table.
13
+
14
+
15
+ == Example and Usage
16
+
17
+ === Setup
18
+
19
+ Setup your Aggregate model, inheriting from a class that establishes a connection to your global datastore.
20
+
21
+ class AggregateFoo < ActiveRecord::Base
22
+ include ShardedDatabase::Aggregate
23
+
24
+ def determine_connection
25
+ Connections[source.to_sym]
26
+ end
27
+ end
28
+
29
+ The #determine_connection class above expects that there is a Connections constant that is a hash of key/value pairs of 'source' symbols and abstract models that connect to various databases. This method can be implemented in any way you desire to determine the connection source.
30
+
31
+
32
+ === Loading Aggregate Records
33
+
34
+ Assuming that AggregateFoo is a model that is bound to your aggregate view/table, you can now #find against this model as you would any other ActiveRecord class.
35
+
36
+ AggregateFoo.all # => [#<Foo(Connection::One) ..>, #<Foo(Connection::Two) ..>, #<Foo(Connection::One) ..>]
37
+
38
+ Updating an attribute on the first record in the above array would update the corresponding record located in the database that Connection::One connects to.
39
+
40
+
41
+ === Associations
42
+
43
+ Associations are also taken into account, and any associations that are defined on a Foo model will be sourced correctly. I have only tested this one level deep, though.
44
+
45
+
46
+ === Accessing 'raw' AggregateFoo objects
47
+
48
+ Easily done. Simply add a :raw option set to true to your finder queries.
49
+
50
+ AggreateFoo.all(:raw => true)
51
+
52
+
53
+
54
+ Copyright &copy; 2008, Brennan Dunn, Decisiv Inc. Released under the MIT license.
data/Rakefile ADDED
@@ -0,0 +1,22 @@
1
+ require 'rake'
2
+ require 'rake/testtask'
3
+ require 'rake/rdoctask'
4
+
5
+ desc 'Default: run unit tests.'
6
+ task :default => :test
7
+
8
+ desc 'Test the sharded_database plugin.'
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << 'lib'
11
+ t.pattern = 'test/**/*_test.rb'
12
+ t.verbose = true
13
+ end
14
+
15
+ desc 'Generate documentation for the sharded_database plugin.'
16
+ Rake::RDocTask.new(:rdoc) do |rdoc|
17
+ rdoc.rdoc_dir = 'rdoc'
18
+ rdoc.title = 'ShardedDatabase'
19
+ rdoc.options << '--line-numbers' << '--inline-source'
20
+ rdoc.rdoc_files.include('README')
21
+ rdoc.rdoc_files.include('lib/**/*.rb')
22
+ end
data/init.rb ADDED
@@ -0,0 +1 @@
1
+ require 'sharded_database'
@@ -0,0 +1,61 @@
1
+ module ShardedDatabase
2
+ class NoConnectionError < StandardError ; end
3
+
4
+ module Aggregate
5
+
6
+ def self.included(klass)
7
+ klass.extend ClassMethods
8
+ klass.send :include, InstanceMethods
9
+ klass.class_eval do
10
+ cattr_accessor :connection_field, :source_class
11
+ @connection_field = :oem
12
+
13
+ class << self
14
+ alias_method_chain :find, :raw
15
+ end
16
+ end
17
+ end
18
+
19
+ module ClassMethods
20
+
21
+ def find_with_raw(*args)
22
+ @raw = args.last.is_a?(Hash) && args.last.delete(:raw)
23
+ @raw ? temporarily_remove(:after_find) { find_without_raw(*args) } : find_without_raw(*args)
24
+ end
25
+
26
+ end
27
+
28
+ module InstanceMethods
29
+
30
+ def determine_connection
31
+ # stub method - implement your own!
32
+ end
33
+
34
+ def after_find
35
+ @klass = determine_connection || raise(ShardedDatabase::NoConnectionError, 'Cannot determine connection class')
36
+ @connection = @klass.respond_to?(:connection) ? @klass.connection : raise(ShardedDatabase::NoConnectionError, 'Connection class does not respond to :connection')
37
+ @foreign_id = foreign_id
38
+
39
+ metaclass.delegate :connection, :to => @klass
40
+
41
+ class << self
42
+ alias_method :proxy_class, :class
43
+
44
+ include AggregateProxy
45
+ instance_methods.each do |m|
46
+ undef_method(m) unless m =~ /^__|proxy_|inspect|foreign_id/
47
+ end
48
+ end
49
+
50
+ self.class.reflect_on_all_associations.each do |a|
51
+ metaclass.send :alias_method, "proxy_#{a.name}".to_sym, a.name.to_sym
52
+ metaclass.send :undef_method, a.name
53
+ end
54
+
55
+ end
56
+
57
+ end
58
+
59
+ end
60
+
61
+ end
@@ -0,0 +1,51 @@
1
+ module ShardedDatabase
2
+ module AggregateProxy
3
+
4
+ def ===(other)
5
+ other === load_target
6
+ end
7
+
8
+ def inspect
9
+ load_target.inspect.gsub(/#<([\w\:]+)\s(.*?)>/) { "#<#{$1}(#{@klass.name}) #{$2}>" }
10
+ end
11
+
12
+
13
+ private
14
+
15
+ def load_target
16
+ @target ||= begin
17
+ klass = (self.proxy_class.source_class || self.proxy_class.name.gsub('Aggregate','')).constantize
18
+ borrow_connection(klass, @klass) { |k| k.find(@foreign_id) }
19
+ end
20
+ end
21
+
22
+ def method_missing(method, *args, &block)
23
+ if association_method?(method)
24
+ apply_connection_to_association(method)
25
+ end
26
+ load_target.respond_to?(method) ? load_target.send(method, *args, &block) : super
27
+ end
28
+
29
+ def association_method?(method)
30
+ load_target.class.reflect_on_all_associations.any? { |a| a.name == method.to_sym }
31
+ end
32
+
33
+ def borrow_connection(requesting_class, target_class, &block)
34
+ eigen = requesting_class.metaclass
35
+ eigen.delegate :connection, :to => target_class
36
+ yield(requesting_class)
37
+ end
38
+
39
+ def apply_connection_to_association(method)
40
+ load_target.metaclass.send(:attr_accessor, :source_class)
41
+ load_target.source_class = @klass
42
+ load_target.class_eval %{
43
+ def #{method}(*args)
44
+ proxy_#{method}.proxy_reflection.klass.metaclass.delegate :connection, :to => self.source_class
45
+ proxy_#{method}
46
+ end
47
+ }
48
+ end
49
+
50
+ end
51
+ end
@@ -0,0 +1,11 @@
1
+ class Object
2
+
3
+ def temporarily_remove(method, &block)
4
+ alias_method "original_#{method}", method
5
+ undef_method(method)
6
+ return yield
7
+ ensure
8
+ alias_method method, "original_#{method}"
9
+ end
10
+
11
+ end
@@ -0,0 +1,9 @@
1
+ require 'sharded_database/aggregate_proxy'
2
+ require 'sharded_database/aggregate'
3
+ require 'sharded_database/core_extensions'
4
+
5
+ module ShardedDatabase
6
+
7
+ VERSION = '0.1'
8
+
9
+ end
data/test/helper.rb ADDED
@@ -0,0 +1,26 @@
1
+ require 'lib/boot'
2
+ require 'lib/test_case'
3
+ require 'sharded_database'
4
+ require 'lib/models'
5
+
6
+ module ShardedDatabase
7
+ class TestCase < Test::Unit::TestCase
8
+
9
+ self.new_backtrace_silencer(:shoulda) { |line| line.include? 'lib/shoulda' }
10
+ self.new_backtrace_silencer(:mocha) { |line| line.include? 'lib/mocha' }
11
+ self.backtrace_silencers << :shoulda << :mocha
12
+
13
+
14
+ def assert_connection(configuration, *objects)
15
+ expected_db = ::ActiveRecord::Base.configurations[configuration.to_s]['database']
16
+
17
+ objects.each do |object|
18
+ object_db = object.respond_to?(:connection) ? object.connection.current_database : nil
19
+ msg = "Expected #{object.inspect} to be connected to :#{expected_db}, but was :#{object_db}"
20
+ assert_equal expected_db, object_db, msg
21
+ end
22
+ end
23
+
24
+ end
25
+ end
26
+
data/test/lib/boot.rb ADDED
@@ -0,0 +1,20 @@
1
+ require 'rubygems'
2
+ require 'yaml'
3
+ require 'test/unit'
4
+ require 'active_record'
5
+ require 'active_support'
6
+ require 'fileutils'
7
+ require 'shoulda'
8
+ require 'quietbacktrace'
9
+
10
+ ActiveRecord::Base.logger = Logger.new(File.dirname(__FILE__)+'/../debug.log')
11
+ ActiveRecord::Base.configurations = $config = YAML::load(IO.read(File.dirname(__FILE__) + '/database.yml'))
12
+
13
+ class ActiveRecord::ConnectionAdapters::MysqlAdapter < ActiveRecord::ConnectionAdapters::AbstractAdapter
14
+ def log_info(sql, name, runtime)
15
+ if @logger && @logger.debug?
16
+ name = "#{name.nil? ? "SQL" : name} (DB:#{@config[:database]}) (#{sprintf("%f", runtime)})"
17
+ @logger.debug format_log_entry(name, sql.squeeze(' '))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,20 @@
1
+ master:
2
+ adapter: mysql
3
+ database: database_master
4
+ username: root
5
+ password:
6
+ host: localhost
7
+
8
+ one_db:
9
+ adapter: mysql
10
+ database: database_mck
11
+ username: root
12
+ password:
13
+ host: localhost
14
+
15
+ two_db:
16
+ adapter: mysql
17
+ database: database_vlv
18
+ username: root
19
+ password:
20
+ host: localhost
@@ -0,0 +1,35 @@
1
+ module Connection ; end
2
+
3
+ class Connection::One < ActiveRecord::Base
4
+ establish_connection :one_db
5
+ self.abstract_class = true
6
+ end
7
+
8
+ class Connection::Two < ActiveRecord::Base
9
+ establish_connection :two_db
10
+ self.abstract_class = true
11
+ end
12
+
13
+ Connections = { :one => Connection::One, :two => Connection::Two }
14
+
15
+ class GlobalConnection < ActiveRecord::Base
16
+ establish_connection :master
17
+ self.abstract_class = true
18
+ end
19
+
20
+ class AggregateEstimate < GlobalConnection
21
+ include ShardedDatabase::Aggregate
22
+
23
+ def determine_connection
24
+ Connections[source.to_sym]
25
+ end
26
+
27
+ end
28
+
29
+ class Estimate < ActiveRecord::Base
30
+ has_many :items
31
+ end
32
+
33
+ class Item < ActiveRecord::Base
34
+ belongs_to :estimate
35
+ end
@@ -0,0 +1,73 @@
1
+ module ShardedDatabase
2
+ class TestCase < Test::Unit::TestCase
3
+
4
+ def setup_environment(options={})
5
+ setup_database
6
+ setup_models
7
+ end
8
+
9
+ def breakdown_environment
10
+
11
+ end
12
+
13
+ def test_truth ; end
14
+
15
+
16
+ private
17
+
18
+ def setup_database
19
+ create_db_file = lambda do |file|
20
+ File.delete(file) if File.exist?(file)
21
+ SQLite3::Database.new(file)
22
+ end
23
+
24
+ # setup aggregate table
25
+ ::ActiveRecord::Base.establish_connection :master
26
+ ::ActiveRecord::Base.class_eval do
27
+ silence do
28
+ connection.create_table :aggregate_estimates, :force => true do |t|
29
+ t.string :source
30
+ t.integer :foreign_id
31
+ t.timestamp :created_at
32
+ end
33
+ end
34
+ end
35
+
36
+ # setup NUM_db.estimates
37
+ %w(one two).each do |num|
38
+ ::ActiveRecord::Base.establish_connection "#{num}_db".to_sym
39
+ ::ActiveRecord::Base.class_eval do
40
+ silence do
41
+ connection.create_table :estimates, :force => true do |t|
42
+ t.string :name
43
+ end
44
+
45
+ connection.create_table :items, :force => true do |t|
46
+ t.belongs_to :estimate
47
+ t.string :name
48
+ end
49
+ end
50
+ end
51
+ end
52
+
53
+ end
54
+
55
+ def setup_models
56
+ one_estimate = Class.new(Connection::One) { set_table_name 'estimates' ; has_many(:items) }
57
+ @one_1 = one_estimate.create :name => 'One Estimate'
58
+
59
+ two_estimate = Class.new(Connection::Two) { set_table_name 'estimates' ; has_many(:items) }
60
+ @two_1 = two_estimate.create :name => 'One Estimate 1'
61
+ @two_2 = two_estimate.create :name => 'Two Estimate 2'
62
+
63
+ one_item = Class.new(Connection::One) { set_table_name 'items' ; belongs_to(:estimate) }
64
+ one_item.create :name => 'One Test Item', :estimate_id => @one_1.id
65
+
66
+ AggregateEstimate.create :source => 'one', :foreign_id => @one_1.id
67
+ AggregateEstimate.create :source => 'two', :foreign_id => @two_1.id
68
+ AggregateEstimate.create :source => 'two', :foreign_id => @two_2.id
69
+ end
70
+
71
+ end
72
+ end
73
+
@@ -0,0 +1,24 @@
1
+ require File.dirname(__FILE__) + '/../helper'
2
+
3
+ class AssociationTest < ShardedDatabase::TestCase
4
+ def setup ; setup_environment ; end
5
+
6
+
7
+ context 'Connection delegation on has_many associations' do
8
+
9
+ setup do
10
+ @parent = AggregateEstimate.find_by_source('one')
11
+ end
12
+
13
+ should 'fetch items from the parent instance connection' do
14
+ assert ! @parent.items.empty?
15
+ assert_connection :one_db, @parent.items.first
16
+ end
17
+
18
+ should 'keep its connection when bubbling up to an associations parent' do
19
+ assert_equal @parent, @parent.items.first.estimate
20
+ end
21
+
22
+ end
23
+
24
+ end
@@ -0,0 +1,22 @@
1
+ require File.dirname(__FILE__) + '/../helper'
2
+
3
+ class ConnectionTest < ShardedDatabase::TestCase
4
+ def setup ; setup_environment ; end
5
+
6
+
7
+ should 'foundationally support instances having a different connection than their parent class' do
8
+ assert_not_equal AggregateEstimate.first.connection, AggregateEstimate.connection
9
+ end
10
+
11
+ should 'have instances of the same source share the same connection' do
12
+ first, second = AggregateEstimate.find_all_by_source('two', :limit => 2)
13
+
14
+ assert_connection :two_db, first, second
15
+ assert_equal first.connection.object_id, second.connection.object_id # ensure that delegation is working correctly
16
+ end
17
+
18
+ should 'display instance connection when inspecting' do
19
+ assert_match %{(Connection::One)}, AggregateEstimate.find_by_source('one').inspect
20
+ end
21
+
22
+ end
@@ -0,0 +1,35 @@
1
+ require File.dirname(__FILE__) + '/../helper'
2
+
3
+ class InstanceTest < ShardedDatabase::TestCase
4
+ def setup ; setup_environment ; end
5
+
6
+
7
+ context 'Loading raw aggregate objects' do
8
+
9
+ setup do
10
+ @aggregates = AggregateEstimate.all(:raw => true)
11
+ end
12
+
13
+ should 'all be AggregateEstimate instances' do
14
+ assert @aggregates.all? { |a| a.is_a?(AggregateEstimate) }
15
+ end
16
+
17
+ end
18
+
19
+ context "A transformed aggregate instance" do
20
+
21
+ setup do
22
+ @estimate = AggregateEstimate.first
23
+ end
24
+
25
+ should 'channel calls to #class to the proxy class' do
26
+ assert @estimate.is_a?(Estimate)
27
+ end
28
+
29
+ should 'have the same attribute fields as the proxy class' do
30
+ assert_same_elements @estimate.attributes.keys, Estimate.column_names
31
+ end
32
+
33
+ end
34
+
35
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: decisiv-sharded_database
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Brennan Dunn
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+
12
+ date: 2009-01-11 00:00:00 -08:00
13
+ default_executable:
14
+ dependencies: []
15
+
16
+ description: Allows for connection handling at the instance level.
17
+ email: me@brennandunn.com
18
+ executables: []
19
+
20
+ extensions: []
21
+
22
+ extra_rdoc_files:
23
+ - README.rdoc
24
+ files:
25
+ - Rakefile
26
+ - README.rdoc
27
+ - init.rb
28
+ - lib/sharded_database.rb
29
+ - lib/sharded_database/aggregate.rb
30
+ - lib/sharded_database/aggregate_proxy.rb
31
+ - lib/sharded_database/core_extensions.rb
32
+ has_rdoc: true
33
+ homepage: http://github.com/brennandunn/sharded_database/
34
+ post_install_message:
35
+ rdoc_options:
36
+ - --main
37
+ - README.rdoc
38
+ require_paths:
39
+ - lib
40
+ required_ruby_version: !ruby/object:Gem::Requirement
41
+ requirements:
42
+ - - ">="
43
+ - !ruby/object:Gem::Version
44
+ version: "0"
45
+ version:
46
+ required_rubygems_version: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: "0"
51
+ version:
52
+ requirements: []
53
+
54
+ rubyforge_project:
55
+ rubygems_version: 1.2.0
56
+ signing_key:
57
+ specification_version: 2
58
+ summary: Allows for connection handling at the instance level.
59
+ test_files:
60
+ - test/helper.rb
61
+ - test/sharded_database/association_test.rb
62
+ - test/sharded_database/connection_test.rb
63
+ - test/sharded_database/instance_test.rb
64
+ - test/lib/boot.rb
65
+ - test/lib/database.yml
66
+ - test/lib/models.rb
67
+ - test/lib/test_case.rb