consistency_fail 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +4 -0
- data/Gemfile +4 -0
- data/LICENSE +21 -0
- data/README.md +50 -0
- data/Rakefile +2 -0
- data/bin/consistency_fail +25 -0
- data/consistency_fail.gemspec +31 -0
- data/lib/consistency_fail.rb +16 -0
- data/lib/consistency_fail/index.rb +13 -0
- data/lib/consistency_fail/introspectors/has_one.rb +29 -0
- data/lib/consistency_fail/introspectors/table_data.rb +16 -0
- data/lib/consistency_fail/introspectors/validates_uniqueness_of.rb +29 -0
- data/lib/consistency_fail/models.rb +37 -0
- data/lib/consistency_fail/reporter.rb +14 -0
- data/lib/consistency_fail/reporters/base.rb +81 -0
- data/lib/consistency_fail/reporters/has_one.rb +13 -0
- data/lib/consistency_fail/reporters/validates_uniqueness_of.rb +13 -0
- data/lib/consistency_fail/version.rb +3 -0
- data/spec/consistency_fail_spec.rb +0 -0
- data/spec/index_spec.rb +36 -0
- data/spec/introspectors/has_one_spec.rb +63 -0
- data/spec/introspectors/table_data_spec.rb +52 -0
- data/spec/introspectors/validates_uniqueness_of_spec.rb +84 -0
- data/spec/models_spec.rb +55 -0
- data/spec/reporter_spec.rb +74 -0
- data/spec/spec_helper.rb +13 -0
- metadata +145 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
Copyright (c) 2011 Colin Jones
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
21
|
+
|
data/README.md
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
# Consistency Fail
|
2
|
+
|
3
|
+
## Description
|
4
|
+
consistency\_fail is a tool to detect missing unique indexes in Rails projects.
|
5
|
+
|
6
|
+
With more than one application server, `validates_uniqueness_of` becomes a lie.
|
7
|
+
Two app servers -> two requests -> two near-simultaneous uniqueness checks ->
|
8
|
+
two processes that commit to the database independently, violating this faux
|
9
|
+
constraint. You'll need a database-level constraint for cases like these.
|
10
|
+
|
11
|
+
consistency\_fail will find your missing unique indexes, so you can add them and
|
12
|
+
stop ignoring the C in ACID.
|
13
|
+
|
14
|
+
Similar problems arise with `has_one`, so consistency\_fail finds places where
|
15
|
+
database-level enforcement is lacking there as well.
|
16
|
+
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
gem install consistency_fail
|
21
|
+
|
22
|
+
## Limitations
|
23
|
+
|
24
|
+
Currently only Rails 2.x is supported. Rails 3 support is coming soon.
|
25
|
+
|
26
|
+
consistency\_fail depends on being able to find all your `ActiveRecord::Base`
|
27
|
+
subclasses with some `$LOAD_PATH` trickery. If any models are in a path either
|
28
|
+
not on your project's load path or in a path that doesn't include the word
|
29
|
+
"models", consistency\_fail won't be able to find or analyze them.
|
30
|
+
|
31
|
+
## Usage
|
32
|
+
|
33
|
+
The only run mode for now is to generate a report of the problematic spots in
|
34
|
+
your application. From your Rails project directory, run:
|
35
|
+
|
36
|
+
consistency_fail
|
37
|
+
|
38
|
+
from your terminal / shell. This will spit a report to standard output, which
|
39
|
+
you can view directly, redirect to a file as evidence to embarrass a teammate,
|
40
|
+
or simply beam in happiness at your application's perfect record for
|
41
|
+
`validates_uniqueness_of` and `has_one` usage.
|
42
|
+
|
43
|
+
## Coming Soon
|
44
|
+
|
45
|
+
* Rails 3 support
|
46
|
+
* Super-fail mode that monkey-patches explosions into your naughty models
|
47
|
+
|
48
|
+
## License
|
49
|
+
|
50
|
+
Released under the MIT License. See the LICENSE file for further details.
|
data/Rakefile
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "consistency_fail"
|
4
|
+
|
5
|
+
def problems(models, introspector)
|
6
|
+
problems = models.map do |m|
|
7
|
+
[m, introspector.missing_indexes(m)]
|
8
|
+
end.reject do |m, indexes|
|
9
|
+
indexes.empty?
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
models = ConsistencyFail::Models.new($LOAD_PATH)
|
14
|
+
models.preload_all
|
15
|
+
|
16
|
+
reporter = ConsistencyFail::Reporter.new
|
17
|
+
|
18
|
+
introspector = ConsistencyFail::Introspectors::ValidatesUniquenessOf.new
|
19
|
+
problems = problems(models.all, introspector)
|
20
|
+
reporter.report_validates_uniqueness_problems(problems)
|
21
|
+
|
22
|
+
introspector = ConsistencyFail::Introspectors::HasOne.new
|
23
|
+
problems = problems(models.all, introspector)
|
24
|
+
reporter.report_has_one_problems(problems)
|
25
|
+
|
@@ -0,0 +1,31 @@
|
|
1
|
+
# -*- encoding: utf-8 -*-
|
2
|
+
$:.push File.expand_path("../lib", __FILE__)
|
3
|
+
require "consistency_fail/version"
|
4
|
+
|
5
|
+
Gem::Specification.new do |s|
|
6
|
+
s.name = "consistency_fail"
|
7
|
+
s.version = ConsistencyFail::VERSION
|
8
|
+
s.platform = Gem::Platform::RUBY
|
9
|
+
s.authors = ["Colin Jones"]
|
10
|
+
s.email = ["colin@8thlight.com"]
|
11
|
+
s.homepage = "http://github.com/trptcolin/consistency_fail"
|
12
|
+
s.summary = %q{A tool to detect missing unique indexes}
|
13
|
+
s.description = <<-EOF
|
14
|
+
With more than one application server, validates_uniqueness_of becomes a lie.
|
15
|
+
Two app servers -> two requests -> two near-simultaneous uniqueness checks ->
|
16
|
+
two processes that commit to the database independently, violating this faux
|
17
|
+
constraint. You'll need a database-level constraint for cases like these.
|
18
|
+
|
19
|
+
consistency_fail will find your missing unique indexes, so you can add them and
|
20
|
+
stop ignoring the C in ACID.
|
21
|
+
EOF
|
22
|
+
|
23
|
+
s.add_dependency "validation_reflection", "~>0.3.8"
|
24
|
+
s.add_development_dependency "activerecord", "~>2.3"
|
25
|
+
s.add_development_dependency "rspec"
|
26
|
+
|
27
|
+
s.files = `git ls-files`.split("\n")
|
28
|
+
s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
|
29
|
+
s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
30
|
+
s.require_paths = ["lib"]
|
31
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
begin
|
2
|
+
require File.join(Dir.pwd, "config", "boot")
|
3
|
+
rescue LoadError => e
|
4
|
+
puts "\nUh-oh! You must be in the root directory of a Rails project.\n"
|
5
|
+
raise
|
6
|
+
end
|
7
|
+
|
8
|
+
require 'active_record'
|
9
|
+
require 'validation_reflection'
|
10
|
+
require File.join(Dir.pwd, "config", "environment")
|
11
|
+
|
12
|
+
require 'consistency_fail/models'
|
13
|
+
require 'consistency_fail/introspectors/table_data'
|
14
|
+
require 'consistency_fail/introspectors/validates_uniqueness_of'
|
15
|
+
require 'consistency_fail/introspectors/has_one'
|
16
|
+
require 'consistency_fail/reporter'
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ConsistencyFail
|
2
|
+
class Index
|
3
|
+
attr_reader :table_name, :columns
|
4
|
+
def initialize(table_name, columns)
|
5
|
+
@table_name = table_name
|
6
|
+
@columns = columns.map(&:to_s)
|
7
|
+
end
|
8
|
+
|
9
|
+
def ==(other)
|
10
|
+
self.table_name == other.table_name && self.columns.sort == other.columns.sort
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'consistency_fail/index'
|
2
|
+
|
3
|
+
module ConsistencyFail
|
4
|
+
module Introspectors
|
5
|
+
class HasOne
|
6
|
+
def instances(model)
|
7
|
+
model.reflect_on_all_associations.select do |a|
|
8
|
+
a.macro == :has_one
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
# TODO: handle has_one :through cases (multicolumn index on the join table?)
|
13
|
+
def desired_indexes(model)
|
14
|
+
instances(model).map do |a|
|
15
|
+
ConsistencyFail::Index.new(a.table_name.to_s, [a.primary_key_name]) rescue nil # TODO: why?
|
16
|
+
end.compact
|
17
|
+
end
|
18
|
+
private :desired_indexes
|
19
|
+
|
20
|
+
def missing_indexes(model)
|
21
|
+
existing_indexes = TableData.new.unique_indexes(model)
|
22
|
+
|
23
|
+
desired_indexes(model).reject do |index|
|
24
|
+
existing_indexes.include?(index)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
require 'consistency_fail/index'
|
2
|
+
|
3
|
+
module ConsistencyFail
|
4
|
+
module Introspectors
|
5
|
+
class TableData
|
6
|
+
def unique_indexes(model)
|
7
|
+
return [] if !model.table_exists?
|
8
|
+
|
9
|
+
ar_indexes = model.connection.indexes(model.table_name).select(&:unique)
|
10
|
+
ar_indexes.map do |index|
|
11
|
+
ConsistencyFail::Index.new(model.table_name, index.columns)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
require 'consistency_fail/index'
|
2
|
+
|
3
|
+
module ConsistencyFail
|
4
|
+
module Introspectors
|
5
|
+
class ValidatesUniquenessOf
|
6
|
+
def instances(model)
|
7
|
+
model.reflect_on_all_validations.select do |v|
|
8
|
+
v.macro == :validates_uniqueness_of
|
9
|
+
end
|
10
|
+
end
|
11
|
+
|
12
|
+
def desired_indexes(model)
|
13
|
+
instances(model).map do |v|
|
14
|
+
scoped_columns = v.options[:scope] || []
|
15
|
+
ConsistencyFail::Index.new(model.table_name, [v.name, *scoped_columns])
|
16
|
+
end
|
17
|
+
end
|
18
|
+
private :desired_indexes
|
19
|
+
|
20
|
+
def missing_indexes(model)
|
21
|
+
existing_indexes = TableData.new.unique_indexes(model)
|
22
|
+
|
23
|
+
desired_indexes(model).reject do |index|
|
24
|
+
existing_indexes.include?(index)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'active_record'
|
2
|
+
require 'consistency_fail/index'
|
3
|
+
|
4
|
+
module ConsistencyFail
|
5
|
+
class Models
|
6
|
+
MODEL_DIRECTORY_REGEXP = /models/
|
7
|
+
|
8
|
+
attr_reader :load_path
|
9
|
+
|
10
|
+
def initialize(load_path)
|
11
|
+
@load_path = load_path
|
12
|
+
end
|
13
|
+
|
14
|
+
def dirs
|
15
|
+
load_path.select { |lp| MODEL_DIRECTORY_REGEXP =~ lp }
|
16
|
+
end
|
17
|
+
|
18
|
+
def preload_all
|
19
|
+
self.dirs.each do |d|
|
20
|
+
Dir.glob(File.join(d, "**", "*.rb")).each do |model_filename|
|
21
|
+
Kernel.require_dependency model_filename
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def all
|
27
|
+
models = []
|
28
|
+
ObjectSpace.each_object do |o|
|
29
|
+
models << o if o.class == Class &&
|
30
|
+
o.ancestors.include?(ActiveRecord::Base) &&
|
31
|
+
o != ActiveRecord::Base
|
32
|
+
end
|
33
|
+
models.sort_by(&:name)
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'consistency_fail/reporters/validates_uniqueness_of'
|
2
|
+
require 'consistency_fail/reporters/has_one'
|
3
|
+
|
4
|
+
module ConsistencyFail
|
5
|
+
class Reporter
|
6
|
+
def report_validates_uniqueness_problems(indexes_by_model)
|
7
|
+
ConsistencyFail::Reporters::ValidatesUniquenessOf.new.report(indexes_by_model)
|
8
|
+
end
|
9
|
+
|
10
|
+
def report_has_one_problems(indexes_by_model)
|
11
|
+
ConsistencyFail::Reporters::HasOne.new.report(indexes_by_model)
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,81 @@
|
|
1
|
+
module ConsistencyFail
|
2
|
+
module Reporters
|
3
|
+
class Base
|
4
|
+
TERMINAL_WIDTH = 80
|
5
|
+
|
6
|
+
RED = 31
|
7
|
+
GREEN = 32
|
8
|
+
|
9
|
+
def use_color(code)
|
10
|
+
print "\e[#{code}m"
|
11
|
+
end
|
12
|
+
|
13
|
+
def use_default_color
|
14
|
+
use_color(0)
|
15
|
+
end
|
16
|
+
|
17
|
+
def report_success(macro)
|
18
|
+
use_color(GREEN)
|
19
|
+
puts "Hooray! All calls to #{macro} are correctly backed by a unique index."
|
20
|
+
use_default_color
|
21
|
+
end
|
22
|
+
|
23
|
+
def divider(pad_to = TERMINAL_WIDTH)
|
24
|
+
puts "-" * [pad_to, TERMINAL_WIDTH].max
|
25
|
+
end
|
26
|
+
|
27
|
+
def report_failure_header(macro, longest_model_length)
|
28
|
+
puts
|
29
|
+
use_color(RED)
|
30
|
+
puts "There are calls to #{macro} that aren't backed by unique indexes."
|
31
|
+
use_default_color
|
32
|
+
divider(longest_model_length * 2)
|
33
|
+
|
34
|
+
column_1_header, column_2_header = column_headers
|
35
|
+
print column_1_header.ljust(longest_model_length + 2)
|
36
|
+
puts column_2_header
|
37
|
+
|
38
|
+
divider(longest_model_length * 2)
|
39
|
+
end
|
40
|
+
|
41
|
+
def report_index(model, index, column_1_length)
|
42
|
+
print model.name.ljust(column_1_length + 2)
|
43
|
+
puts "#{index.table_name} (#{index.columns.join(", ")})"
|
44
|
+
end
|
45
|
+
|
46
|
+
def column_1(model)
|
47
|
+
model.name
|
48
|
+
end
|
49
|
+
|
50
|
+
def column_headers
|
51
|
+
["Model", "Table Columns"]
|
52
|
+
end
|
53
|
+
|
54
|
+
def report(indexes_by_model)
|
55
|
+
if indexes_by_model.empty?
|
56
|
+
report_success(macro)
|
57
|
+
else
|
58
|
+
indexes_by_table_name = indexes_by_model.map do |model, indexes|
|
59
|
+
[column_1(model), model, indexes]
|
60
|
+
end.sort_by(&:first)
|
61
|
+
longest_model_length = indexes_by_table_name.map(&:first).
|
62
|
+
sort_by(&:length).
|
63
|
+
last.
|
64
|
+
length
|
65
|
+
column_1_header_length = column_headers.first.length
|
66
|
+
longest_model_length = [longest_model_length, column_1_header_length].max
|
67
|
+
|
68
|
+
report_failure_header(macro, longest_model_length)
|
69
|
+
|
70
|
+
indexes_by_table_name.each do |table_name, model, indexes|
|
71
|
+
indexes.each do |index|
|
72
|
+
report_index(model, index, longest_model_length)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
divider(longest_model_length * 2)
|
76
|
+
end
|
77
|
+
puts
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
File without changes
|
data/spec/index_spec.rb
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/index'
|
3
|
+
|
4
|
+
describe ConsistencyFail::Index do
|
5
|
+
|
6
|
+
describe "value objectiness" do
|
7
|
+
it "holds onto table name and columns" do
|
8
|
+
index = ConsistencyFail::Index.new("addresses", ["city", "state"])
|
9
|
+
index.table_name.should == "addresses"
|
10
|
+
index.columns.should == ["city", "state"]
|
11
|
+
end
|
12
|
+
|
13
|
+
it "leaves columns in the initial order (since we only care about presence, not performance)" do
|
14
|
+
index = ConsistencyFail::Index.new("addresses", ["state", "city"])
|
15
|
+
index.table_name.should == "addresses"
|
16
|
+
index.columns.should == ["state", "city"]
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
describe "equality test" do
|
21
|
+
it "passes when everything matches" do
|
22
|
+
ConsistencyFail::Index.new("addresses", ["city", "state"]).should ==
|
23
|
+
ConsistencyFail::Index.new("addresses", ["city", "state"])
|
24
|
+
end
|
25
|
+
|
26
|
+
it "fails when tables are different" do
|
27
|
+
ConsistencyFail::Index.new("locations", ["city", "state"]).should_not ==
|
28
|
+
ConsistencyFail::Index.new("addresses", ["city", "state"])
|
29
|
+
end
|
30
|
+
|
31
|
+
it "fails when columns are different" do
|
32
|
+
ConsistencyFail::Index.new("addresses", ["city", "state"]).should_not ==
|
33
|
+
ConsistencyFail::Index.new("addresses", ["state", "zip"])
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/introspectors/table_data'
|
3
|
+
require 'consistency_fail/introspectors/has_one'
|
4
|
+
|
5
|
+
describe ConsistencyFail::Introspectors::HasOne do
|
6
|
+
def introspector(model)
|
7
|
+
ConsistencyFail::Introspectors::HasOne.new(model)
|
8
|
+
end
|
9
|
+
|
10
|
+
describe "instances of has_one" do
|
11
|
+
it "finds none" do
|
12
|
+
model = fake_ar_model("User")
|
13
|
+
model.stub(:reflect_on_all_associations).and_return([])
|
14
|
+
|
15
|
+
subject.instances(model).should == []
|
16
|
+
end
|
17
|
+
|
18
|
+
it "finds one" do
|
19
|
+
model = fake_ar_model("User")
|
20
|
+
association = double("association", :macro => :has_one)
|
21
|
+
model.stub!(:reflect_on_all_associations).and_return([association])
|
22
|
+
|
23
|
+
subject.instances(model).should == [association]
|
24
|
+
end
|
25
|
+
|
26
|
+
it "finds other associations, but not has_one" do
|
27
|
+
model = fake_ar_model("User")
|
28
|
+
validation = double("validation", :macro => :has_many)
|
29
|
+
model.stub!(:reflect_on_all_associations).and_return([validation])
|
30
|
+
|
31
|
+
subject.instances(model).should == []
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
describe "finding missing indexes" do
|
36
|
+
before do
|
37
|
+
@association = double("association", :macro => :has_one)
|
38
|
+
@model = fake_ar_model("User", :table_exists? => true,
|
39
|
+
:table_name => "users",
|
40
|
+
:reflect_on_all_associations => [@association])
|
41
|
+
end
|
42
|
+
|
43
|
+
it "finds one" do
|
44
|
+
@association.stub!(:table_name => :addresses, :primary_key_name => "user_id")
|
45
|
+
@model.stub_chain(:connection, :indexes).with("users").and_return([])
|
46
|
+
|
47
|
+
indexes = subject.missing_indexes(@model)
|
48
|
+
indexes.should == [ConsistencyFail::Index.new("addresses", ["user_id"])]
|
49
|
+
end
|
50
|
+
|
51
|
+
it "finds none when they're already in place" do
|
52
|
+
@association.stub!(:table_name => :addresses, :primary_key_name => "user_id")
|
53
|
+
index = ConsistencyFail::Index.new("addresses", ["user_id"])
|
54
|
+
ConsistencyFail::Introspectors::TableData.stub_chain(:new, :unique_indexes).
|
55
|
+
and_return([index])
|
56
|
+
|
57
|
+
subject.missing_indexes(@model).should == []
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
|
@@ -0,0 +1,52 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/introspectors/table_data'
|
3
|
+
|
4
|
+
describe ConsistencyFail::Introspectors::TableData do
|
5
|
+
describe "finding unique indexes" do
|
6
|
+
it "finds none when the table does not exist" do
|
7
|
+
model = fake_ar_model("User", :table_exists? => false)
|
8
|
+
|
9
|
+
subject.unique_indexes(model).should == []
|
10
|
+
end
|
11
|
+
|
12
|
+
it "gets one" do
|
13
|
+
model = fake_ar_model("User", :table_exists? => true,
|
14
|
+
:table_name => "users")
|
15
|
+
|
16
|
+
model.stub_chain(:connection, :indexes).
|
17
|
+
with("users").
|
18
|
+
and_return([fake_index_on(["a"], :unique => true)])
|
19
|
+
|
20
|
+
indexes = subject.unique_indexes(model)
|
21
|
+
indexes.should == [ConsistencyFail::Index.new("users", ["a"])]
|
22
|
+
end
|
23
|
+
|
24
|
+
it "doesn't get non-unique indexes" do
|
25
|
+
model = fake_ar_model("User", :table_exists? => true,
|
26
|
+
:table_name => "users")
|
27
|
+
|
28
|
+
model.stub_chain(:connection, :indexes).
|
29
|
+
with("users").
|
30
|
+
and_return([fake_index_on(["a"], :unique => false)])
|
31
|
+
|
32
|
+
subject.unique_indexes(model).should == []
|
33
|
+
end
|
34
|
+
|
35
|
+
it "gets multiple unique indexes" do
|
36
|
+
model = fake_ar_model("User", :table_exists? => true,
|
37
|
+
:table_name => "users")
|
38
|
+
|
39
|
+
model.stub_chain(:connection, :indexes).
|
40
|
+
with("users").
|
41
|
+
and_return([fake_index_on(["a"], :unique => true),
|
42
|
+
fake_index_on(["b", "c"], :unique => true)])
|
43
|
+
|
44
|
+
indexes = subject.unique_indexes(model)
|
45
|
+
indexes.size.should == 2
|
46
|
+
indexes.should == [ConsistencyFail::Index.new("users", ["a"]),
|
47
|
+
ConsistencyFail::Index.new("users", ["b", "c"])]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
end
|
52
|
+
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/introspectors/validates_uniqueness_of'
|
3
|
+
|
4
|
+
describe ConsistencyFail::Introspectors::ValidatesUniquenessOf do
|
5
|
+
def introspector(model)
|
6
|
+
ConsistencyFail::Introspectors::ValidatesUniquenessOf.new(model)
|
7
|
+
end
|
8
|
+
|
9
|
+
describe "instances of validates_uniqueness_of" do
|
10
|
+
it "finds none" do
|
11
|
+
model = fake_ar_model("User")
|
12
|
+
model.stub!(:reflect_on_all_validations).and_return([])
|
13
|
+
|
14
|
+
subject.instances(model).should == []
|
15
|
+
end
|
16
|
+
|
17
|
+
it "finds one" do
|
18
|
+
model = fake_ar_model("User")
|
19
|
+
validation = double("validation", :macro => :validates_uniqueness_of)
|
20
|
+
model.stub!(:reflect_on_all_validations).and_return([validation])
|
21
|
+
|
22
|
+
subject.instances(model).should == [validation]
|
23
|
+
end
|
24
|
+
|
25
|
+
it "finds other validations, but not uniqueness" do
|
26
|
+
model = fake_ar_model("User")
|
27
|
+
validation = double("validation", :macro => :validates_format_of)
|
28
|
+
model.stub!(:reflect_on_all_validations).and_return([validation])
|
29
|
+
|
30
|
+
subject.instances(model).should == []
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
describe "finding missing indexes" do
|
35
|
+
before do
|
36
|
+
@validation = double("validation", :macro => :validates_uniqueness_of)
|
37
|
+
@model = fake_ar_model("User", :table_exists? => true,
|
38
|
+
:table_name => "users",
|
39
|
+
:reflect_on_all_validations => [@validation])
|
40
|
+
end
|
41
|
+
|
42
|
+
it "finds one" do
|
43
|
+
@validation.stub!(:name => :email, :options => {})
|
44
|
+
@model.stub_chain(:connection, :indexes).with("users").and_return([])
|
45
|
+
|
46
|
+
indexes = subject.missing_indexes(@model)
|
47
|
+
indexes.should == [ConsistencyFail::Index.new("users", ["email"])]
|
48
|
+
end
|
49
|
+
|
50
|
+
it "finds one where the validation has scoped columns" do
|
51
|
+
@validation.stub!(:name => :city, :options => {:scope => [:email, :state]})
|
52
|
+
@model.stub_chain(:connection, :indexes).with("users").and_return([])
|
53
|
+
|
54
|
+
indexes = subject.missing_indexes(@model)
|
55
|
+
indexes.should == [ConsistencyFail::Index.new("users", ["city", "email", "state"])]
|
56
|
+
end
|
57
|
+
|
58
|
+
it "sorts the scoped columns" do
|
59
|
+
@validation.stub!(:name => :email, :options => {:scope => [:city, :state]})
|
60
|
+
@model.stub_chain(:connection, :indexes).with("users").and_return([])
|
61
|
+
|
62
|
+
indexes = subject.missing_indexes(@model)
|
63
|
+
indexes.should == [ConsistencyFail::Index.new("users", ["email", "city", "state"])]
|
64
|
+
end
|
65
|
+
|
66
|
+
it "finds none when they're already in place" do
|
67
|
+
@validation.stub!(:name => :email, :options => {})
|
68
|
+
index = fake_index_on(["email"], :unique => true)
|
69
|
+
@model.stub_chain(:connection, :indexes).with("users").
|
70
|
+
and_return([index])
|
71
|
+
|
72
|
+
subject.missing_indexes(@model).should == []
|
73
|
+
end
|
74
|
+
|
75
|
+
it "finds none when indexes are there but in a different order" do
|
76
|
+
@validation.stub!(:name => :email, :options => {:scope => [:city, :state]})
|
77
|
+
index = fake_index_on(["state", "email", "city"], :unique => true)
|
78
|
+
@model.stub_chain(:connection, :indexes).with("users").
|
79
|
+
and_return([index])
|
80
|
+
|
81
|
+
subject.missing_indexes(@model).should == []
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|
data/spec/models_spec.rb
ADDED
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/models'
|
3
|
+
|
4
|
+
describe ConsistencyFail::Models do
|
5
|
+
def models(load_path)
|
6
|
+
ConsistencyFail::Models.new(load_path)
|
7
|
+
end
|
8
|
+
|
9
|
+
it "gets the load path" do
|
10
|
+
models([:a, :b, :c]).load_path.should == [:a, :b, :c]
|
11
|
+
end
|
12
|
+
|
13
|
+
it "gets the directories matching /models/" do
|
14
|
+
models = models(["foo/bar/baz", "app/models", "some/other/models"])
|
15
|
+
models.dirs.should == ["app/models", "some/other/models"]
|
16
|
+
end
|
17
|
+
|
18
|
+
it "preloads models by calling require_dependency" do
|
19
|
+
models = models(["foo/bar/baz", "app/models", "some/other/models"])
|
20
|
+
Dir.stub(:glob).
|
21
|
+
with(File.join("app/models", "**", "*.rb")).
|
22
|
+
and_return(["app/models/user.rb", "app/models/address.rb"])
|
23
|
+
Dir.stub(:glob).
|
24
|
+
with(File.join("some/other/models", "**", "*.rb")).
|
25
|
+
and_return(["some/other/models/foo.rb"])
|
26
|
+
|
27
|
+
Kernel.should_receive(:require_dependency).with("app/models/user.rb")
|
28
|
+
Kernel.should_receive(:require_dependency).with("app/models/address.rb")
|
29
|
+
Kernel.should_receive(:require_dependency).with("some/other/models/foo.rb")
|
30
|
+
|
31
|
+
models.preload_all
|
32
|
+
end
|
33
|
+
|
34
|
+
it "gets all models" do
|
35
|
+
model_a = double("model_a",
|
36
|
+
:ancestors => [ActiveRecord::Base],
|
37
|
+
:class => Class,
|
38
|
+
:name => "ModelA")
|
39
|
+
model_b = double("model_b",
|
40
|
+
:ancestors => [ActiveRecord::Base],
|
41
|
+
:class => Class,
|
42
|
+
:name => "ModelB")
|
43
|
+
model_c = double("model_c",
|
44
|
+
:ancestors => [ActiveRecord::Base],
|
45
|
+
:class => ActiveRecord::Base,
|
46
|
+
:name => "ModelC")
|
47
|
+
|
48
|
+
ObjectSpace.stub(:each_object).
|
49
|
+
and_yield(model_c).
|
50
|
+
and_yield(model_b).
|
51
|
+
and_yield(model_a)
|
52
|
+
|
53
|
+
models([]).all.should == [model_a, model_b]
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'consistency_fail/reporter'
|
3
|
+
require 'consistency_fail/index'
|
4
|
+
|
5
|
+
describe ConsistencyFail::Reporter do
|
6
|
+
before(:each) do
|
7
|
+
@real_out = $stdout
|
8
|
+
@fake_out = StringIO.new
|
9
|
+
$stdout = @fake_out
|
10
|
+
end
|
11
|
+
after(:each) do
|
12
|
+
$stdout = @real_out
|
13
|
+
end
|
14
|
+
|
15
|
+
context "validates_uniqueness_of" do
|
16
|
+
it "says everything's good" do
|
17
|
+
subject.report_validates_uniqueness_problems([])
|
18
|
+
|
19
|
+
@fake_out.string.should =~ /Hooray!/
|
20
|
+
end
|
21
|
+
|
22
|
+
it "shows a missing single-column index on a single model" do
|
23
|
+
missing_indexes = [ConsistencyFail::Index.new("users", ["email"])]
|
24
|
+
|
25
|
+
subject.report_validates_uniqueness_problems(fake_ar_model("User", :table_name => "users") => missing_indexes)
|
26
|
+
|
27
|
+
@fake_out.string.should =~ /users\s+\(email\)/
|
28
|
+
end
|
29
|
+
|
30
|
+
it "shows a missing multiple-column index on a single model" do
|
31
|
+
missing_indexes = [ConsistencyFail::Index.new("addresses", ["number", "street", "zip"])]
|
32
|
+
|
33
|
+
subject.report_validates_uniqueness_problems(fake_ar_model("Address", :table_name => "addresses") => missing_indexes)
|
34
|
+
|
35
|
+
@fake_out.string.should =~ /addresses\s+\(number, street, zip\)/
|
36
|
+
end
|
37
|
+
|
38
|
+
context "with problems on multiple models" do
|
39
|
+
before(:each) do
|
40
|
+
subject.report_validates_uniqueness_problems(
|
41
|
+
fake_ar_model("User", :table_name => "users") =>
|
42
|
+
[ConsistencyFail::Index.new("users", ["email"])],
|
43
|
+
fake_ar_model("Citizen", :table_name => "citizens") =>
|
44
|
+
[ConsistencyFail::Index.new("citizens", ["ssn"])]
|
45
|
+
)
|
46
|
+
end
|
47
|
+
|
48
|
+
it "shows all problems" do
|
49
|
+
@fake_out.string.should =~ /users\s+\(email\)/m
|
50
|
+
@fake_out.string.should =~ /citizens\s+\(ssn\)/m
|
51
|
+
end
|
52
|
+
|
53
|
+
it "orders the models alphabetically" do
|
54
|
+
@fake_out.string.should =~ /citizens\s+\(ssn\).*users\s+\(email\)/m
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
context "has_one" do
|
60
|
+
it "says everything's good" do
|
61
|
+
subject.report_has_one_problems([])
|
62
|
+
|
63
|
+
@fake_out.string.should =~ /Hooray!/
|
64
|
+
end
|
65
|
+
|
66
|
+
it "shows a missing single-column index on a single model" do
|
67
|
+
missing_indexes = [ConsistencyFail::Index.new("users", ["email"])]
|
68
|
+
|
69
|
+
subject.report_has_one_problems(fake_ar_model("Friend", :table_name => "users") => missing_indexes)
|
70
|
+
|
71
|
+
@fake_out.string.should =~ /Friend\s+users\s+\(email\)/m
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
data/spec/spec_helper.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
$:<< File.expand_path('..', __FILE__)
|
2
|
+
$:<< File.expand_path('../../lib', __FILE__)
|
3
|
+
|
4
|
+
require 'rubygems'
|
5
|
+
|
6
|
+
def fake_ar_model(name, options = {})
|
7
|
+
double("AR model: #{name}", options.merge(:name => name))
|
8
|
+
end
|
9
|
+
|
10
|
+
def fake_index_on(columns, options = {})
|
11
|
+
double("index on #{columns.inspect}", options.merge(:columns => columns))
|
12
|
+
end
|
13
|
+
|
metadata
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: consistency_fail
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
hash: 27
|
5
|
+
prerelease:
|
6
|
+
segments:
|
7
|
+
- 0
|
8
|
+
- 1
|
9
|
+
- 0
|
10
|
+
version: 0.1.0
|
11
|
+
platform: ruby
|
12
|
+
authors:
|
13
|
+
- Colin Jones
|
14
|
+
autorequire:
|
15
|
+
bindir: bin
|
16
|
+
cert_chain: []
|
17
|
+
|
18
|
+
date: 2011-06-10 00:00:00 -05:00
|
19
|
+
default_executable:
|
20
|
+
dependencies:
|
21
|
+
- !ruby/object:Gem::Dependency
|
22
|
+
name: validation_reflection
|
23
|
+
prerelease: false
|
24
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
25
|
+
none: false
|
26
|
+
requirements:
|
27
|
+
- - ~>
|
28
|
+
- !ruby/object:Gem::Version
|
29
|
+
hash: 3
|
30
|
+
segments:
|
31
|
+
- 0
|
32
|
+
- 3
|
33
|
+
- 8
|
34
|
+
version: 0.3.8
|
35
|
+
type: :runtime
|
36
|
+
version_requirements: *id001
|
37
|
+
- !ruby/object:Gem::Dependency
|
38
|
+
name: activerecord
|
39
|
+
prerelease: false
|
40
|
+
requirement: &id002 !ruby/object:Gem::Requirement
|
41
|
+
none: false
|
42
|
+
requirements:
|
43
|
+
- - ~>
|
44
|
+
- !ruby/object:Gem::Version
|
45
|
+
hash: 5
|
46
|
+
segments:
|
47
|
+
- 2
|
48
|
+
- 3
|
49
|
+
version: "2.3"
|
50
|
+
type: :development
|
51
|
+
version_requirements: *id002
|
52
|
+
- !ruby/object:Gem::Dependency
|
53
|
+
name: rspec
|
54
|
+
prerelease: false
|
55
|
+
requirement: &id003 !ruby/object:Gem::Requirement
|
56
|
+
none: false
|
57
|
+
requirements:
|
58
|
+
- - ">="
|
59
|
+
- !ruby/object:Gem::Version
|
60
|
+
hash: 3
|
61
|
+
segments:
|
62
|
+
- 0
|
63
|
+
version: "0"
|
64
|
+
type: :development
|
65
|
+
version_requirements: *id003
|
66
|
+
description: |
|
67
|
+
With more than one application server, validates_uniqueness_of becomes a lie.
|
68
|
+
Two app servers -> two requests -> two near-simultaneous uniqueness checks ->
|
69
|
+
two processes that commit to the database independently, violating this faux
|
70
|
+
constraint. You'll need a database-level constraint for cases like these.
|
71
|
+
|
72
|
+
consistency_fail will find your missing unique indexes, so you can add them and
|
73
|
+
stop ignoring the C in ACID.
|
74
|
+
|
75
|
+
email:
|
76
|
+
- colin@8thlight.com
|
77
|
+
executables:
|
78
|
+
- consistency_fail
|
79
|
+
extensions: []
|
80
|
+
|
81
|
+
extra_rdoc_files: []
|
82
|
+
|
83
|
+
files:
|
84
|
+
- .gitignore
|
85
|
+
- Gemfile
|
86
|
+
- LICENSE
|
87
|
+
- README.md
|
88
|
+
- Rakefile
|
89
|
+
- bin/consistency_fail
|
90
|
+
- consistency_fail.gemspec
|
91
|
+
- lib/consistency_fail.rb
|
92
|
+
- lib/consistency_fail/index.rb
|
93
|
+
- lib/consistency_fail/introspectors/has_one.rb
|
94
|
+
- lib/consistency_fail/introspectors/table_data.rb
|
95
|
+
- lib/consistency_fail/introspectors/validates_uniqueness_of.rb
|
96
|
+
- lib/consistency_fail/models.rb
|
97
|
+
- lib/consistency_fail/reporter.rb
|
98
|
+
- lib/consistency_fail/reporters/base.rb
|
99
|
+
- lib/consistency_fail/reporters/has_one.rb
|
100
|
+
- lib/consistency_fail/reporters/validates_uniqueness_of.rb
|
101
|
+
- lib/consistency_fail/version.rb
|
102
|
+
- spec/consistency_fail_spec.rb
|
103
|
+
- spec/index_spec.rb
|
104
|
+
- spec/introspectors/has_one_spec.rb
|
105
|
+
- spec/introspectors/table_data_spec.rb
|
106
|
+
- spec/introspectors/validates_uniqueness_of_spec.rb
|
107
|
+
- spec/models_spec.rb
|
108
|
+
- spec/reporter_spec.rb
|
109
|
+
- spec/spec_helper.rb
|
110
|
+
has_rdoc: true
|
111
|
+
homepage: http://github.com/trptcolin/consistency_fail
|
112
|
+
licenses: []
|
113
|
+
|
114
|
+
post_install_message:
|
115
|
+
rdoc_options: []
|
116
|
+
|
117
|
+
require_paths:
|
118
|
+
- lib
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
120
|
+
none: false
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
hash: 3
|
125
|
+
segments:
|
126
|
+
- 0
|
127
|
+
version: "0"
|
128
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
129
|
+
none: false
|
130
|
+
requirements:
|
131
|
+
- - ">="
|
132
|
+
- !ruby/object:Gem::Version
|
133
|
+
hash: 3
|
134
|
+
segments:
|
135
|
+
- 0
|
136
|
+
version: "0"
|
137
|
+
requirements: []
|
138
|
+
|
139
|
+
rubyforge_project:
|
140
|
+
rubygems_version: 1.5.2
|
141
|
+
signing_key:
|
142
|
+
specification_version: 3
|
143
|
+
summary: A tool to detect missing unique indexes
|
144
|
+
test_files: []
|
145
|
+
|