octoball 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +46 -0
- data/.gitignore +7 -0
- data/Gemfile +3 -0
- data/README.md +107 -0
- data/Rakefile +54 -0
- data/lib/octoball.rb +30 -0
- data/lib/octoball/association.rb +61 -0
- data/lib/octoball/association_shard_check.rb +44 -0
- data/lib/octoball/connection_adapters.rb +18 -0
- data/lib/octoball/connection_handling.rb +18 -0
- data/lib/octoball/current_shard_tracker.rb +30 -0
- data/lib/octoball/log_subscriber.rb +21 -0
- data/lib/octoball/persistence.rb +29 -0
- data/lib/octoball/relation_proxy.rb +99 -0
- data/lib/octoball/version.rb +5 -0
- data/octoball.gemspec +28 -0
- data/spec/migration/1_test_tables.rb +84 -0
- data/spec/migration/2_alone_shard_tables.rb +19 -0
- data/spec/models/application_record.rb +13 -0
- data/spec/octoball/association_shard_tracking_spec.rb +1024 -0
- data/spec/octoball/collection_proxy_spec.rb +17 -0
- data/spec/octoball/log_subscriber_spec.rb +19 -0
- data/spec/octoball/model_spec.rb +688 -0
- data/spec/octoball/relation_proxy_spec.rb +130 -0
- data/spec/octoball/scope_proxy_spec.rb +97 -0
- data/spec/rails_helper.rb +0 -0
- data/spec/spec_helper.rb +31 -0
- data/spec/support/database_connection.rb +22 -0
- data/spec/support/database_models.rb +115 -0
- data/spec/support/query_count.rb +17 -0
- data/spec/support/shared_contexts.rb +18 -0
- data/spec/support/test_helper.rb +14 -0
- metadata +174 -0
@@ -0,0 +1,130 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Octoball::RelationProxy do
|
4
|
+
describe 'shard tracking' do
|
5
|
+
before :each do
|
6
|
+
@client = Client.using(:canada).create!
|
7
|
+
@client.items << Item.using(:canada).create!
|
8
|
+
@relation = @client.items
|
9
|
+
end
|
10
|
+
|
11
|
+
it 'remembers the shard on which a relation was created' do
|
12
|
+
expect(@relation.current_shard).to eq(:canada)
|
13
|
+
end
|
14
|
+
|
15
|
+
it 'can define collection association with the same name as ancestor private method' do
|
16
|
+
@client.comments << Comment.using(:canada).create!(open: true)
|
17
|
+
expect(@client.comments.open.ar_relation).to be_a_kind_of(ActiveRecord::Relation)
|
18
|
+
end
|
19
|
+
|
20
|
+
it 'can be dumped and loaded' do
|
21
|
+
expect(Marshal.load(Marshal.dump(@relation))).to eq @relation
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'is flattenable' do
|
25
|
+
expect([@relation].flatten).to eq @relation.to_a
|
26
|
+
end
|
27
|
+
|
28
|
+
it 'maintains the current shard when using where.not(...)' do
|
29
|
+
where_chain = @relation.where
|
30
|
+
expect(where_chain.current_shard).to eq(@relation.current_shard)
|
31
|
+
not_relation = where_chain.not("1=0")
|
32
|
+
expect(not_relation.current_shard).to eq(@relation.current_shard)
|
33
|
+
end
|
34
|
+
|
35
|
+
context 'when a new relation is constructed from the original relation' do
|
36
|
+
context 'and a where(...) is used' do
|
37
|
+
it 'does not tamper with the original relation' do
|
38
|
+
relation = Item.using(:canada).where(id: 1)
|
39
|
+
original_sql = relation.to_sql
|
40
|
+
new_relation = relation.where(id: 2)
|
41
|
+
expect(relation.to_sql).to eq(original_sql)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
context 'and a where.not(...) is used' do
|
46
|
+
it 'does not tamper with the original relation' do
|
47
|
+
relation = Item.using(:canada).where(id: 1)
|
48
|
+
original_sql = relation.to_sql
|
49
|
+
new_relation = relation.where.not(id: 2)
|
50
|
+
expect(relation.to_sql).to eq(original_sql)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
context 'when comparing to other Relation objects' do
|
56
|
+
before :each do
|
57
|
+
@relation.reset
|
58
|
+
end
|
59
|
+
|
60
|
+
it 'is equal to its clone' do
|
61
|
+
expect(@relation).to eq(@relation.clone)
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
it "can deliver methods in ActiveRecord::Batches correctly when given a block" do
|
66
|
+
expect { @relation.find_each(&:inspect) }.not_to raise_error
|
67
|
+
end
|
68
|
+
|
69
|
+
it "can deliver methods in ActiveRecord::Batches correctly as an enumerator" do
|
70
|
+
expect { @relation.find_each.each(&:inspect) }.not_to raise_error
|
71
|
+
end
|
72
|
+
|
73
|
+
it "can deliver methods in ActiveRecord::Batches correctly as a lazy enumerator" do
|
74
|
+
expect { @relation.find_each.lazy.each(&:inspect) }.not_to raise_error
|
75
|
+
end
|
76
|
+
|
77
|
+
it 'should be able to return its ActiveRecord::Relation' do
|
78
|
+
expect(@relation.ar_relation.is_a? ActiveRecord::Relation).to eq true
|
79
|
+
end
|
80
|
+
|
81
|
+
it 'is equal to an identically-defined, but different, RelationProxy' do
|
82
|
+
i = @client.items
|
83
|
+
expect(@relation).to eq(i)
|
84
|
+
expect(@relation.__id__).not_to eq(i.__id__)
|
85
|
+
end
|
86
|
+
|
87
|
+
it 'is equal to its own underlying ActiveRecord::Relation' do
|
88
|
+
expect(@relation).to eq(@relation.ar_relation)
|
89
|
+
expect(@relation.ar_relation).to eq(@relation)
|
90
|
+
end
|
91
|
+
|
92
|
+
context 'when no explicit shard context is provided' do
|
93
|
+
it 'uses the correct shard' do
|
94
|
+
expect(@relation.count).to eq(1)
|
95
|
+
end
|
96
|
+
|
97
|
+
it 'lazily evaluates on the correct shard' do
|
98
|
+
# Do something to force Client.connection_proxy.current_shard to change
|
99
|
+
_some_count = Client.using(:brazil).count
|
100
|
+
expect(@relation.select(:client_id).count).to eq(1)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
context 'when an explicit, but different, shard context is provided' do
|
105
|
+
it 'uses the correct shard' do
|
106
|
+
expect(Item.using(:brazil).count).to eq(0)
|
107
|
+
_clients_on_brazil = Client.using(:brazil).all
|
108
|
+
Octoball.using(:brazil) do
|
109
|
+
expect(@relation.count).to eq(1)
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
it 'uses the correct shard in block when method_missing is triggered on CollectionProxy objects' do
|
114
|
+
Octoball.using(:brazil) do
|
115
|
+
@client.items.each do |item|
|
116
|
+
expect(item.current_shard).to eq(:canada)
|
117
|
+
expect(ActiveRecord::Base.connection.current_shard).to eq(:brazil)
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
it 'lazily evaluates on the correct shard' do
|
123
|
+
expect(Item.using(:brazil).count).to eq(0)
|
124
|
+
Octoball.using(:brazil) do
|
125
|
+
expect(@relation.select(:client_id).count).to eq(1)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Octoball do
|
4
|
+
it 'should allow nested queries' do
|
5
|
+
@user1 = User.using(:brazil).create!(:name => 'Thiago P', :number => 3)
|
6
|
+
@user2 = User.using(:brazil).create!(:name => 'Thiago', :number => 1)
|
7
|
+
@user3 = User.using(:brazil).create!(:name => 'Thiago', :number => 2)
|
8
|
+
|
9
|
+
expect(User.using(:brazil).where(:name => 'Thiago').where(:number => 4).order(:number).all).to eq([])
|
10
|
+
expect(User.using(:brazil).where(:name => 'Thiago').using(:canada).where(:number => 2).using(:brazil).order(:number).all).to eq([@user3])
|
11
|
+
expect(User.using(:brazil).where(:name => 'Thiago').using(:canada).where(:number => 4).using(:brazil).order(:number).all).to eq([])
|
12
|
+
end
|
13
|
+
|
14
|
+
context 'When array-like-selecting an item in a group' do
|
15
|
+
before(:each) do
|
16
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 1)
|
17
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 2)
|
18
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 3)
|
19
|
+
@evans = User.using(:brazil).where(:name => 'Evan')
|
20
|
+
end
|
21
|
+
|
22
|
+
it 'allows a block to select an item' do
|
23
|
+
expect(@evans.select { |u| u.number == 2 }.first.number).to eq(2)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
context 'When selecting a field within a scope' do
|
28
|
+
before(:each) do
|
29
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 4)
|
30
|
+
@evan = User.using(:brazil).where(:name => 'Evan')
|
31
|
+
end
|
32
|
+
|
33
|
+
it 'allows single field selection' do
|
34
|
+
expect(@evan.select('name').first.name).to eq('Evan')
|
35
|
+
end
|
36
|
+
|
37
|
+
it 'allows selection by array' do
|
38
|
+
expect(@evan.select(['name']).first.name).to eq('Evan')
|
39
|
+
end
|
40
|
+
|
41
|
+
it 'allows multiple selection by string' do
|
42
|
+
expect(@evan.select('id, name').first.id).to be_a(Fixnum)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'allows multiple selection by array' do
|
46
|
+
expect(@evan.select(%w(id name)).first.id).to be_a(Fixnum)
|
47
|
+
end
|
48
|
+
|
49
|
+
it 'allows multiple selection by symbol' do
|
50
|
+
expect(@evan.select(:id, :name).first.id).to be_a(Fixnum)
|
51
|
+
end
|
52
|
+
|
53
|
+
it 'allows multiple selection by string and symbol' do
|
54
|
+
expect(@evan.select(:id, 'name').first.id).to be_a(Fixnum)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
it "should raise a exception when trying to send a query to a shard that don't exists" do
|
59
|
+
expect { User.using(:dont_exists).first }.to raise_exception(ActiveRecord::ConnectionNotEstablished)
|
60
|
+
end
|
61
|
+
|
62
|
+
context "dup / clone" do
|
63
|
+
before(:each) do
|
64
|
+
User.using(:brazil).create!(:name => 'Thiago', :number => 1)
|
65
|
+
end
|
66
|
+
|
67
|
+
it "should change it's object id" do
|
68
|
+
user = User.using(:brazil).where(id: 1)
|
69
|
+
dupped_object = user.dup
|
70
|
+
cloned_object = user.clone
|
71
|
+
|
72
|
+
expect(dupped_object.object_id).not_to eq(user.object_id)
|
73
|
+
expect(cloned_object.object_id).not_to eq(user.object_id)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
context 'When iterated with Enumerable methods' do
|
78
|
+
before(:each) do
|
79
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 1)
|
80
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 2)
|
81
|
+
User.using(:brazil).create!(:name => 'Evan', :number => 3)
|
82
|
+
@evans = User.using(:brazil).where(:name => 'Evan')
|
83
|
+
end
|
84
|
+
|
85
|
+
it 'allows each method' do
|
86
|
+
expect(@evans.each.count).to eq(3)
|
87
|
+
end
|
88
|
+
|
89
|
+
it 'allows each_with_index method' do
|
90
|
+
expect(@evans.each_with_index.to_a.flatten.count).to eq(6)
|
91
|
+
end
|
92
|
+
|
93
|
+
it 'allows map method' do
|
94
|
+
expect(@evans.map(&:number)).to eq([1, 2, 3])
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
File without changes
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'bundler/setup'
|
3
|
+
require 'octoball'
|
4
|
+
|
5
|
+
Octoball.instance_variable_set(:@directory, File.dirname(__FILE__))
|
6
|
+
|
7
|
+
# Requires supporting files with custom matchers and macros, etc,
|
8
|
+
# in ./support/ and its subdirectories.
|
9
|
+
Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f }
|
10
|
+
|
11
|
+
RSpec.configure do |config|
|
12
|
+
mysql_spec = {
|
13
|
+
adapter: 'mysql2',
|
14
|
+
host: (ENV['MYSQL_HOST'] || 'localhost'),
|
15
|
+
username: (ENV['MYSQL_USER'] || 'root'),
|
16
|
+
encoding: 'utf8mb4',
|
17
|
+
}
|
18
|
+
ActiveRecord::Base.configurations = {
|
19
|
+
"test" => {
|
20
|
+
shard1: mysql_spec.merge(database: 'octoball_shard_1'),
|
21
|
+
shard2: mysql_spec.merge(database: 'octoball_shard_2'),
|
22
|
+
shard3: mysql_spec.merge(database: 'octoball_shard_3'),
|
23
|
+
shard4: mysql_spec.merge(database: 'octoball_shard_4'),
|
24
|
+
shard5: mysql_spec.merge(database: 'octoball_shard_5'),
|
25
|
+
}
|
26
|
+
}
|
27
|
+
|
28
|
+
config.before(:each) do |example|
|
29
|
+
TestHelper.clean_all_shards(example.metadata[:shards])
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
require 'logger'
|
2
|
+
|
3
|
+
ActiveRecord::Base.logger = Logger.new(File.open('database.log', 'a'))
|
4
|
+
|
5
|
+
mysql_spec = {
|
6
|
+
adapter: 'mysql2',
|
7
|
+
host: (ENV['MYSQL_HOST'] || 'localhost'),
|
8
|
+
username: (ENV['MYSQL_USER'] || 'root'),
|
9
|
+
encoding: 'utf8mb4',
|
10
|
+
}
|
11
|
+
|
12
|
+
ActiveRecord::Base.configurations = {
|
13
|
+
"test" => {
|
14
|
+
shard1: mysql_spec.merge(database: 'octoball_shard_1'),
|
15
|
+
shard2: mysql_spec.merge(database: 'octoball_shard_2'),
|
16
|
+
shard3: mysql_spec.merge(database: 'octoball_shard_3'),
|
17
|
+
shard4: mysql_spec.merge(database: 'octoball_shard_4'),
|
18
|
+
shard5: mysql_spec.merge(database: 'octoball_shard_5'),
|
19
|
+
}
|
20
|
+
}
|
21
|
+
ActiveRecord::Base.establish_connection(
|
22
|
+
ActiveRecord::Base.configurations.configs_for(env_name: "test").first)
|
@@ -0,0 +1,115 @@
|
|
1
|
+
require './spec/models/application_record.rb'
|
2
|
+
|
3
|
+
# The user class is just sharded, not replicated
|
4
|
+
class User < ApplicationRecord
|
5
|
+
scope :thiago, -> { where(:name => 'Thiago') }
|
6
|
+
|
7
|
+
def awesome_queries
|
8
|
+
Octoball.using(:canada) do
|
9
|
+
User.create(:name => 'teste')
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
# The client class isn't replicated
|
15
|
+
class Client < ApplicationRecord
|
16
|
+
has_many :items
|
17
|
+
has_many :comments, :as => :commentable
|
18
|
+
end
|
19
|
+
|
20
|
+
# This class sets its own connection
|
21
|
+
class CustomConnectionBase < ActiveRecord::Base
|
22
|
+
self.abstract_class = true
|
23
|
+
establish_connection(:adapter => 'mysql2', :host => (ENV['MYSQL_HOST'] || 'localhost'), :database => 'octoball_shard_2', :username => "#{ENV['MYSQL_USER'] || 'root'}", :password => '')
|
24
|
+
connects_to shards: {
|
25
|
+
custom_shard: { writing: :shard3 }
|
26
|
+
}
|
27
|
+
end
|
28
|
+
|
29
|
+
class CustomConnection < CustomConnectionBase
|
30
|
+
self.table_name = 'custom'
|
31
|
+
end
|
32
|
+
|
33
|
+
# This items belongs to a client
|
34
|
+
class Item < ApplicationRecord
|
35
|
+
belongs_to :client
|
36
|
+
has_many :parts
|
37
|
+
end
|
38
|
+
|
39
|
+
class Part < ApplicationRecord
|
40
|
+
belongs_to :item
|
41
|
+
end
|
42
|
+
|
43
|
+
class Keyboard < ApplicationRecord
|
44
|
+
validates_uniqueness_of(:name, case_sensitive: true)
|
45
|
+
belongs_to :computer
|
46
|
+
end
|
47
|
+
|
48
|
+
class Computer < ApplicationRecord
|
49
|
+
has_one :keyboard
|
50
|
+
end
|
51
|
+
|
52
|
+
class Role < ApplicationRecord
|
53
|
+
has_and_belongs_to_many :permissions
|
54
|
+
end
|
55
|
+
|
56
|
+
class Permission < ApplicationRecord
|
57
|
+
has_and_belongs_to_many :roles
|
58
|
+
end
|
59
|
+
|
60
|
+
class Assignment < ApplicationRecord
|
61
|
+
belongs_to :programmer
|
62
|
+
belongs_to :project
|
63
|
+
end
|
64
|
+
|
65
|
+
class Programmer < ApplicationRecord
|
66
|
+
has_many :assignments
|
67
|
+
has_many :projects, :through => :assignments
|
68
|
+
end
|
69
|
+
|
70
|
+
class Project < ApplicationRecord
|
71
|
+
has_many :assignments
|
72
|
+
has_many :programmers, :through => :assignments
|
73
|
+
end
|
74
|
+
|
75
|
+
class Comment < ApplicationRecord
|
76
|
+
belongs_to :commentable, :polymorphic => true
|
77
|
+
scope :open, -> { where(open: true) }
|
78
|
+
end
|
79
|
+
|
80
|
+
class Bacon < ApplicationRecord
|
81
|
+
self.table_name = 'yummy'
|
82
|
+
end
|
83
|
+
|
84
|
+
class Cheese < ApplicationRecord
|
85
|
+
self.table_name = 'yummy'
|
86
|
+
end
|
87
|
+
|
88
|
+
class Ham < ApplicationRecord
|
89
|
+
self.table_name = 'yummy'
|
90
|
+
end
|
91
|
+
|
92
|
+
# This class sets its own connection
|
93
|
+
class Advert < ApplicationRecord
|
94
|
+
end
|
95
|
+
|
96
|
+
class MmorpgPlayer < ApplicationRecord
|
97
|
+
has_many :weapons
|
98
|
+
has_many :skills
|
99
|
+
end
|
100
|
+
|
101
|
+
class Weapon < ApplicationRecord
|
102
|
+
belongs_to :mmorpg_player, :inverse_of => :weapons
|
103
|
+
validates :hand, :uniqueness => { :scope => :mmorpg_player_id }
|
104
|
+
validates_presence_of :mmorpg_player
|
105
|
+
has_many :skills
|
106
|
+
end
|
107
|
+
|
108
|
+
class Skill < ApplicationRecord
|
109
|
+
belongs_to :weapon, :inverse_of => :skills
|
110
|
+
belongs_to :mmorpg_player, :inverse_of => :skills
|
111
|
+
|
112
|
+
validates_presence_of :weapon
|
113
|
+
validates_presence_of :mmorpg_player
|
114
|
+
validates :name, :uniqueness => { :scope => :mmorpg_player_id }
|
115
|
+
end
|
@@ -0,0 +1,17 @@
|
|
1
|
+
module ActiveRecord
|
2
|
+
class QueryCounter
|
3
|
+
attr_accessor :query_count
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@query_count = 0
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_proc
|
10
|
+
lambda(&method(:callback))
|
11
|
+
end
|
12
|
+
|
13
|
+
def callback(_name, _start, _finish, _message_id, values)
|
14
|
+
@query_count += 1 unless %w(CACHE SCHEMA).include?(values[:name])
|
15
|
+
end
|
16
|
+
end
|
17
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
shared_context 'with query cache enabled' do
|
2
|
+
let!(:counter) { ActiveRecord::QueryCounter.new }
|
3
|
+
|
4
|
+
before(:each) do
|
5
|
+
ActiveRecord::Base.connection.enable_query_cache!
|
6
|
+
counter.query_count = 0
|
7
|
+
end
|
8
|
+
|
9
|
+
after(:each) do
|
10
|
+
ActiveRecord::Base.connection.disable_query_cache!
|
11
|
+
end
|
12
|
+
|
13
|
+
around(:each) do |example|
|
14
|
+
active_support_subscribed(counter.to_proc, 'sql.active_record') do
|
15
|
+
example.run
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module TestHelper
|
2
|
+
def self.clean_all_shards(shards)
|
3
|
+
(shards || [:master, :brazil, :canada, :russia, :alone_shard]).each do |shard_symbol|
|
4
|
+
%w(users clients cats items keyboards computers permissions_roles roles permissions assignments projects programmers yummy adverts).each do |tables|
|
5
|
+
ApplicationRecord.using(shard_symbol).connection.execute("DELETE FROM #{tables}")
|
6
|
+
end
|
7
|
+
if shard_symbol == :alone_shard
|
8
|
+
%w(mmorpg_players weapons skills).each do |table|
|
9
|
+
ApplicationRecord.using(shard_symbol).connection.execute("DELETE FROM #{table}")
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|