octoball 0.1.0
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.
- 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
|