consistency_fail 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.
- 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
|
+
|