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 +54 -0
- data/Rakefile +22 -0
- data/init.rb +1 -0
- data/lib/sharded_database/aggregate.rb +61 -0
- data/lib/sharded_database/aggregate_proxy.rb +51 -0
- data/lib/sharded_database/core_extensions.rb +11 -0
- data/lib/sharded_database.rb +9 -0
- data/test/helper.rb +26 -0
- data/test/lib/boot.rb +20 -0
- data/test/lib/database.yml +20 -0
- data/test/lib/models.rb +35 -0
- data/test/lib/test_case.rb +73 -0
- data/test/sharded_database/association_test.rb +24 -0
- data/test/sharded_database/connection_test.rb +22 -0
- data/test/sharded_database/instance_test.rb +35 -0
- metadata +67 -0
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 © 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
|
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
|
data/test/lib/models.rb
ADDED
@@ -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
|