├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── consistency_fail ├── consistency_fail.gemspec ├── lib ├── consistency_fail.rb └── consistency_fail │ ├── enforcer.rb │ ├── index.rb │ ├── introspectors │ ├── has_one.rb │ ├── polymorphic.rb │ ├── table_data.rb │ └── validates_uniqueness_of.rb │ ├── models.rb │ ├── reporter.rb │ ├── reporters │ ├── base.rb │ ├── has_one.rb │ ├── polymorphic.rb │ └── validates_uniqueness_of.rb │ └── version.rb └── spec ├── index_spec.rb ├── introspectors ├── has_one_spec.rb ├── polymorphic_spec.rb ├── table_data_spec.rb └── validates_uniqueness_of_spec.rb ├── models_spec.rb ├── reporter_spec.rb ├── spec_helper.rb └── support ├── active_record.rb ├── models ├── blob.rb ├── blob │ └── edible.rb ├── correct_account.rb ├── correct_address.rb ├── correct_attachment.rb ├── correct_person.rb ├── correct_post.rb ├── correct_user.rb ├── correct_user │ ├── credential.rb │ └── phone.rb ├── new_correct_person.rb ├── nonexistent.rb ├── wrong_account.rb ├── wrong_address.rb ├── wrong_attachment.rb ├── wrong_business.rb ├── wrong_person.rb ├── wrong_post.rb └── wrong_user.rb └── schema.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # Specify your gem's dependencies in consistency_fail.gemspec 4 | gemspec 5 | 6 | gem 'pry' 7 | 8 | group :test do 9 | gem 'activesupport', '~> 5.0' 10 | end 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 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 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 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 | For more detail, see [my blog post on the 18 | subject](http://blog.8thlight.com/articles/2011/6/11/winning-at-consistency). 19 | 20 | 21 | ## Installation 22 | 23 | You can install the gem directly: 24 | 25 | gem install consistency_fail 26 | 27 | Or if you're using Bundler (which you probably are), add it to your Gemfile. 28 | 29 | gem 'consistency_fail' 30 | 31 | 32 | ## Limitations 33 | 34 | The master branch should work for the following ActiveRecord versions: 35 | 36 | - 5.x 37 | - 4.x (*has a known issue around views*) 38 | - 3.x 39 | - 2.3 (on the `rails-2.3` branch) 40 | 41 | The known issue with views in ActiveRecord 4.x is that in this version, the 42 | connection adapter's `tables` method includes both tables and views. This means 43 | that without additional monkeypatches to the various connection adapters, we 44 | cannot reliably detect whether a given model is backed by a table or a view. I 45 | wouldn't mind monkeypatching a bounded set of adapters, but I don't want to be 46 | on the hook for arbitrary connection adapters that may require licenses to test 47 | (e.g. SQL Server, Oracle). 48 | 49 | consistency\_fail depends on being able to find all your `ActiveRecord::Base` 50 | subclasses with some `$LOAD_PATH` trickery. If any models are in a path either 51 | not on your project's load path or in a path that doesn't include the word 52 | "models", consistency\_fail won't be able to find or analyze them. I'm open to 53 | making the text "models" configurable if people want that. Please open an issue 54 | or pull request if so! 55 | 56 | ## Usage 57 | 58 | The normal run mode is to generate a report of the problematic spots in your 59 | application. From your Rails project directory, run: 60 | 61 | consistency_fail 62 | 63 | from your terminal / shell. This will spit a report to standard output, which 64 | you can view directly, redirect to a file as evidence to embarrass a teammate, 65 | or simply beam in happiness at your application's perfect record for 66 | `validates_uniqueness_of` and `has_one` usage. 67 | 68 | The somewhat more sinister and awesome run mode is to include an initializer 69 | that does this: 70 | 71 | require 'consistency_fail/enforcer' 72 | ConsistencyFail::Enforcer.enforce! 73 | 74 | This will make it so that you can't save or load any ActiveRecord models until 75 | you go back and add your unique indexes. Of course, you'll need to make it so 76 | Rails can find `consistency_fail/enforcer` by having `consistency_fail` in your 77 | Gemfile, or by some other mechanism. 78 | 79 | This mega-fail mode is nice to have if you have a large team and want to ensure 80 | that new models or validations/associations follow the rules. 81 | 82 | If you're using the `Enforcer`, depending on your project, you may need to 83 | delay the initializer until later, so that model files can be loaded only once 84 | gem dependencies have been satisfied. One possible way is to move the code above 85 | to the end of `environment.rb` or to the more specific `config/environment/*` files. 86 | 87 | ## Using with Guard 88 | 89 | There is a guard integration plugin available. See [guard-consistency_fail](https://github.com/ptyagi16/guard-consistency_fail). 90 | 91 | ## License 92 | 93 | Released under the MIT License. See the LICENSE file for further details. 94 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler' 2 | Bundler::GemHelper.install_tasks 3 | -------------------------------------------------------------------------------- /bin/consistency_fail: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | base_dir = File.join(Dir.pwd, ARGV.first.to_s) 4 | puts "\nWarning! You are going out of current directory, ruby version may be wrong and some gems may be missing.\n" unless File.realpath(base_dir).start_with?(Dir.pwd) 5 | 6 | begin 7 | 8 | require File.join(base_dir, 'config', 'boot') 9 | rescue LoadError 10 | puts "\nUh-oh! You must be in the root directory of a Rails project.\n" 11 | raise 12 | end 13 | 14 | require 'active_record' 15 | require File.join(base_dir, 'config', 'environment') 16 | 17 | $:<< File.join(File.dirname(__FILE__), '..', 'lib') 18 | require 'consistency_fail' 19 | 20 | if ConsistencyFail.run 21 | exit 0 22 | else 23 | exit 1 24 | end 25 | -------------------------------------------------------------------------------- /consistency_fail.gemspec: -------------------------------------------------------------------------------- 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 | s.license = "MIT" 23 | 24 | s.add_development_dependency "activerecord", "~>5.0" 25 | s.add_development_dependency "sqlite3", "~>1.3" 26 | s.add_development_dependency "rspec", "~>3.2" 27 | 28 | s.files = `git ls-files`.split("\n") 29 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 30 | s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) } 31 | s.require_paths = ["lib"] 32 | end 33 | -------------------------------------------------------------------------------- /lib/consistency_fail.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/models' 2 | require 'consistency_fail/introspectors/table_data' 3 | require 'consistency_fail/introspectors/validates_uniqueness_of' 4 | require 'consistency_fail/introspectors/has_one' 5 | require 'consistency_fail/introspectors/polymorphic' 6 | require 'consistency_fail/reporter' 7 | 8 | module ConsistencyFail 9 | def self.run 10 | models = ConsistencyFail::Models.new($LOAD_PATH) 11 | models.preload_all 12 | 13 | reporter = ConsistencyFail::Reporter.new 14 | 15 | success = true 16 | 17 | introspector = ConsistencyFail::Introspectors::ValidatesUniquenessOf.new 18 | problems = problems(models.all, introspector) 19 | reporter.report_validates_uniqueness_problems(problems) 20 | success &&= problems.empty? 21 | 22 | introspector = ConsistencyFail::Introspectors::HasOne.new 23 | problems = problems(models.all, introspector) 24 | reporter.report_has_one_problems(problems) 25 | success &&= problems.empty? 26 | 27 | introspector = ConsistencyFail::Introspectors::Polymorphic.new 28 | problems = problems(models.all, introspector) 29 | reporter.report_polymorphic_problems(problems) 30 | success &&= problems.empty? 31 | 32 | success 33 | end 34 | 35 | private 36 | 37 | def self.problems(models, introspector) 38 | models.map do |m| 39 | [m, introspector.missing_indexes(m)] 40 | end.reject do |m, indexes| 41 | indexes.empty? 42 | end 43 | end 44 | end -------------------------------------------------------------------------------- /lib/consistency_fail/enforcer.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail' 2 | 3 | module ConsistencyFail 4 | class Enforcer 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 | def self.enforce! 14 | models = ConsistencyFail::Models.new($LOAD_PATH) 15 | models.preload_all 16 | 17 | introspectors = [ConsistencyFail::Introspectors::ValidatesUniquenessOf.new, 18 | ConsistencyFail::Introspectors::HasOne.new, 19 | ConsistencyFail::Introspectors::Polymorphic.new] 20 | 21 | problem_models_exist = models.all.detect do |model| 22 | introspectors.any? {|i| !i.missing_indexes(model).empty?} 23 | end 24 | 25 | if problem_models_exist 26 | mega_fail! 27 | end 28 | end 29 | 30 | def self.mega_fail! 31 | ActiveRecord::Base.class_eval do 32 | class << self 33 | def panic 34 | raise "You've got missing indexes! Run `consistency_fail` to find and fix them." 35 | end 36 | 37 | def find(*arguments) 38 | panic 39 | end 40 | 41 | alias :first :find 42 | alias :last :find 43 | alias :count :find 44 | end 45 | 46 | def save(*arguments) 47 | self.class.panic 48 | end 49 | 50 | def save!(*arguments) 51 | self.class.panic 52 | end 53 | end 54 | end 55 | 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/consistency_fail/index.rb: -------------------------------------------------------------------------------- 1 | module ConsistencyFail 2 | class Index 3 | attr_reader :model, :table_name, :columns 4 | def initialize(model, table_name, columns) 5 | @model = model 6 | @table_name = table_name 7 | @columns = columns.map(&:to_s) 8 | handle_associations 9 | end 10 | 11 | def ==(other) 12 | self.table_name == other.table_name && 13 | self.columns.sort == other.columns.sort 14 | end 15 | 16 | private 17 | 18 | def handle_associations 19 | references = @model.reflect_on_all_associations(:belongs_to) 20 | names = references.map(&:name).map(&:to_s) 21 | @columns.map! do |column| 22 | next column unless names.include?(column) 23 | reflection = @model.reflect_on_association(column.to_sym) 24 | if reflection.options[:polymorphic] 25 | [reflection.foreign_key, reflection.foreign_type] 26 | else 27 | reflection.foreign_key 28 | end 29 | end 30 | @columns.flatten! 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/consistency_fail/introspectors/has_one.rb: -------------------------------------------------------------------------------- 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 && a.options[:as].to_s.length == 0 && a.options[:through].to_s.length == 0 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 | if a.respond_to?(:foreign_key) 16 | foreign_key = a.foreign_key 17 | else 18 | foreign_key = a.primary_key_name 19 | end 20 | ConsistencyFail::Index.new(a.klass, 21 | a.table_name.to_s, 22 | [foreign_key]) 23 | end.compact 24 | end 25 | private :desired_indexes 26 | 27 | def missing_indexes(model) 28 | desired = desired_indexes(model) 29 | 30 | existing_indexes = desired.inject([]) do |acc, d| 31 | acc += TableData.new.unique_indexes_by_table(d.model, 32 | d.model.connection, 33 | d.table_name) 34 | end 35 | 36 | desired.reject do |index| 37 | existing_indexes.include?(index) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/consistency_fail/introspectors/polymorphic.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/index' 2 | 3 | module ConsistencyFail 4 | module Introspectors 5 | class Polymorphic 6 | def instances(model) 7 | model.reflect_on_all_associations.select do |a| 8 | a.macro == :has_one && a.options[:as].to_s.length > 0 9 | end 10 | end 11 | 12 | def desired_indexes(model) 13 | instances(model).map do |a| 14 | as = a.options[:as] 15 | as_type = "#{as}_type" 16 | as_id = "#{as}_id" 17 | 18 | ConsistencyFail::Index.new( 19 | a.klass, 20 | a.table_name.to_s, 21 | [as_type, as_id] 22 | ) 23 | end.compact 24 | end 25 | private :desired_indexes 26 | 27 | def missing_indexes(model) 28 | desired = desired_indexes(model) 29 | 30 | existing_indexes = desired.inject([]) do |acc, d| 31 | acc += TableData.new.unique_indexes_by_table(d.model, 32 | d.model.connection, 33 | d.table_name) 34 | end 35 | 36 | desired.reject do |index| 37 | existing_indexes.include?(index) 38 | end 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/consistency_fail/introspectors/table_data.rb: -------------------------------------------------------------------------------- 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 | unique_indexes_by_table(model, model.connection, model.table_name) 10 | end 11 | 12 | def unique_indexes_by_table(model, connection, table_name) 13 | ar_indexes = connection.indexes(table_name).select(&:unique) 14 | result = ar_indexes.map do |index| 15 | ConsistencyFail::Index.new(model, 16 | table_name, 17 | index.columns) 18 | end 19 | result 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/consistency_fail/introspectors/validates_uniqueness_of.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/index' 2 | 3 | module ConsistencyFail 4 | module Introspectors 5 | class ValidatesUniquenessOf 6 | def instances(model) 7 | model.validators.select do |v| 8 | v.class == ActiveRecord::Validations::UniquenessValidator 9 | end 10 | end 11 | 12 | def desired_indexes(model) 13 | instances(model).map do |v| 14 | v.attributes.map do |attribute| 15 | scoped_columns = v.options[:scope] || [] 16 | ConsistencyFail::Index.new(model, 17 | model.table_name, 18 | [attribute, *scoped_columns]) 19 | end 20 | end.flatten 21 | end 22 | private :desired_indexes 23 | 24 | def missing_indexes(model) 25 | return [] unless model.connection.tables.include? model.table_name 26 | existing_indexes = TableData.new.unique_indexes(model) 27 | 28 | desired_indexes(model).reject do |index| 29 | existing_indexes.include?(index) 30 | end 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/consistency_fail/models.rb: -------------------------------------------------------------------------------- 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.to_s } 16 | end 17 | 18 | def preload_all 19 | self.dirs.each do |d| 20 | ruby_files = Dir.glob(File.join(d, "**", "*.rb")).sort 21 | ruby_files.each do |model_filename| 22 | Kernel.require_dependency model_filename 23 | end 24 | end 25 | end 26 | 27 | def all 28 | ActiveRecord::Base.send(:descendants).sort_by(&:name) 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/consistency_fail/reporter.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/reporters/validates_uniqueness_of' 2 | require 'consistency_fail/reporters/has_one' 3 | require 'consistency_fail/reporters/polymorphic' 4 | 5 | module ConsistencyFail 6 | class Reporter 7 | def report_validates_uniqueness_problems(indexes_by_model) 8 | ConsistencyFail::Reporters::ValidatesUniquenessOf.new.report(indexes_by_model) 9 | end 10 | 11 | def report_has_one_problems(indexes_by_model) 12 | ConsistencyFail::Reporters::HasOne.new.report(indexes_by_model) 13 | end 14 | 15 | def report_polymorphic_problems(indexes_by_model) 16 | ConsistencyFail::Reporters::Polymorphic.new.report(indexes_by_model) 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/consistency_fail/reporters/base.rb: -------------------------------------------------------------------------------- 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 82 | -------------------------------------------------------------------------------- /lib/consistency_fail/reporters/has_one.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/reporters/base' 2 | 3 | module ConsistencyFail 4 | module Reporters 5 | class HasOne < Base 6 | attr_reader :macro 7 | 8 | def initialize 9 | @macro = :has_one 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/consistency_fail/reporters/polymorphic.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/reporters/base' 2 | 3 | module ConsistencyFail 4 | module Reporters 5 | class Polymorphic < Base 6 | attr_reader :macro 7 | 8 | def initialize 9 | @macro = :has_one_with_polymorphic 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/consistency_fail/reporters/validates_uniqueness_of.rb: -------------------------------------------------------------------------------- 1 | require 'consistency_fail/reporters/base' 2 | 3 | module ConsistencyFail 4 | module Reporters 5 | class ValidatesUniquenessOf < Base 6 | attr_reader :macro 7 | 8 | def initialize 9 | @macro = :validates_uniqueness_of 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/consistency_fail/version.rb: -------------------------------------------------------------------------------- 1 | module ConsistencyFail 2 | VERSION = "0.3.7" 3 | end 4 | -------------------------------------------------------------------------------- /spec/index_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'support/models/correct_address' 2 | require_relative 'support/models/wrong_address' 3 | 4 | describe ConsistencyFail::Index do 5 | 6 | let(:index) do 7 | ConsistencyFail::Index.new( 8 | CorrectAddress, 9 | CorrectAddress.table_name, 10 | ["city", "state"] 11 | ) 12 | end 13 | 14 | describe "value objectiness" do 15 | it "holds onto model, table name, and columns" do 16 | expect(index.model).to eq(CorrectAddress) 17 | expect(index.table_name).to eq("correct_addresses") 18 | expect(index.columns).to eq( 19 | ["city", "state"] 20 | ) 21 | end 22 | 23 | it "leaves columns in the initial order (since we only care about presence, not performance)" do 24 | expect(index.columns).to eq( 25 | ["city", "state"] 26 | ) 27 | end 28 | end 29 | 30 | describe "equality test" do 31 | it "passes when everything matches" do 32 | expect(index).to eq( 33 | ConsistencyFail::Index.new( 34 | "CorrectAddress".constantize, 35 | "correct_addresses", 36 | ["city", "state"] 37 | ) 38 | ) 39 | end 40 | 41 | it "fails when tables are different" do 42 | expect(index).not_to eq( 43 | ConsistencyFail::Index.new( 44 | CorrectAttachment, 45 | CorrectAttachment.table_name, 46 | ["attachable_id", "attachable_type"] 47 | ) 48 | ) 49 | end 50 | 51 | it "fails when columns are different" do 52 | expect(index).not_to eq( 53 | ConsistencyFail::Index.new( 54 | CorrectAddress, 55 | CorrectAddress.table_name, 56 | ["correct_user_id"] 57 | ) 58 | ) 59 | end 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /spec/introspectors/has_one_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Introspectors::HasOne do 2 | 3 | describe "finding missing indexes" do 4 | it "finds one" do 5 | indexes = subject.missing_indexes(WrongUser) 6 | 7 | expect(indexes).to eq([ 8 | ConsistencyFail::Index.new( 9 | WrongAddress, 10 | WrongAddress.table_name, 11 | ["wrong_user_id"] 12 | ) 13 | ]) 14 | end 15 | 16 | it "finds none when they're already in place" do 17 | expect(subject.missing_indexes(CorrectUser)).to be_empty 18 | end 19 | end 20 | 21 | end 22 | -------------------------------------------------------------------------------- /spec/introspectors/polymorphic_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Introspectors::Polymorphic do 2 | 3 | describe "finding missing indexes" do 4 | it "finds one" do 5 | indexes = subject.missing_indexes(WrongPost) 6 | 7 | expect(indexes).to eq([ 8 | ConsistencyFail::Index.new( 9 | WrongAttachment, 10 | WrongAttachment.table_name, 11 | ["attachable_type", "attachable_id"] 12 | ) 13 | ]) 14 | end 15 | 16 | it "finds none when they're already in place" do 17 | expect(subject.missing_indexes(CorrectPost)).to be_empty 18 | end 19 | 20 | it "finds none with nested modules" do 21 | expect(subject.missing_indexes(CorrectUser)).to eq([]) 22 | end 23 | 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /spec/introspectors/table_data_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Introspectors::TableData do 2 | 3 | describe "finding unique indexes" do 4 | it "finds none when the table does not exist" do 5 | expect(subject.unique_indexes(Nonexistent)).to be_empty 6 | end 7 | 8 | it "gets one" do 9 | index = ConsistencyFail::Index.new( 10 | CorrectAccount, 11 | CorrectAccount.table_name, 12 | ["email"] 13 | ) 14 | 15 | expect( 16 | ConsistencyFail::Introspectors::TableData.new.unique_indexes_by_table( 17 | CorrectAccount, 18 | ActiveRecord::Base.connection, 19 | CorrectAccount.table_name 20 | ) 21 | ).to eq [index] 22 | end 23 | 24 | it "doesn't get non-unique indexes" do 25 | expect( 26 | ConsistencyFail::Introspectors::TableData.new.unique_indexes_by_table( 27 | WrongAddress, 28 | ActiveRecord::Base.connection, 29 | WrongAddress.table_name 30 | ) 31 | ).to be_empty 32 | end 33 | 34 | it "gets multiple unique indexes" do 35 | indexes = [ 36 | ConsistencyFail::Index.new( 37 | CorrectAttachment, 38 | CorrectAttachment.table_name, 39 | ["name"] 40 | ), 41 | ConsistencyFail::Index.new( 42 | CorrectAttachment, 43 | CorrectAttachment.table_name, 44 | ["attachable_id", "attachable_type"] 45 | ), 46 | ConsistencyFail::Index.new( 47 | CorrectAttachment, 48 | CorrectAttachment.table_name, 49 | ["name", "attachable_id", "attachable_type"] 50 | ) 51 | ] 52 | 53 | expect( 54 | ConsistencyFail::Introspectors::TableData.new.unique_indexes_by_table( 55 | CorrectAttachment, 56 | ActiveRecord::Base.connection, 57 | CorrectAttachment.table_name 58 | ) 59 | ).to eq indexes 60 | end 61 | end 62 | 63 | end 64 | -------------------------------------------------------------------------------- /spec/introspectors/validates_uniqueness_of_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Introspectors::ValidatesUniquenessOf do 2 | 3 | describe "finding missing indexes" do 4 | it "finds one" do 5 | indexes = subject.missing_indexes(WrongAccount) 6 | 7 | expect(indexes).to eq([ 8 | ConsistencyFail::Index.new( 9 | WrongAccount, 10 | WrongAccount.table_name, 11 | ["email"] 12 | ) 13 | ]) 14 | end 15 | 16 | it "finds one where the validation has scoped columns" do 17 | indexes = subject.missing_indexes(WrongBusiness) 18 | 19 | expect(indexes).to eq([ 20 | ConsistencyFail::Index.new( 21 | WrongBusiness, 22 | WrongBusiness.table_name, 23 | ["name", "city", "state"] 24 | ) 25 | ]) 26 | end 27 | 28 | it "finds two where there are multiple attributes" do 29 | indexes = subject.missing_indexes(WrongPerson) 30 | 31 | expect(indexes).to eq( 32 | [ 33 | ConsistencyFail::Index.new( 34 | WrongPerson, 35 | WrongPerson.table_name, 36 | ["email", "city", "state"] 37 | ), 38 | ConsistencyFail::Index.new( 39 | WrongPerson, 40 | WrongPerson.table_name, 41 | ["name", "city", "state"] 42 | ) 43 | ] 44 | ) 45 | end 46 | 47 | it "finds none when they're already in place" do 48 | expect(subject.missing_indexes(CorrectAccount)).to be_empty 49 | end 50 | 51 | it "finds none even if scoped by association" do 52 | expect(subject.missing_indexes(CorrectAddress)).to be_empty 53 | end 54 | 55 | it "finds none even if scoped by polymorphic association" do 56 | expect(subject.missing_indexes(CorrectAttachment)).to be_empty 57 | end 58 | 59 | it "finds none when indexes are there but in a different order" do 60 | expect(subject.missing_indexes(CorrectPerson)).to be_empty 61 | end 62 | 63 | it "finds none when the model is an SQL view" do 64 | expect(subject.missing_indexes(NewCorrectPerson)).to be_empty 65 | end 66 | end 67 | 68 | end 69 | -------------------------------------------------------------------------------- /spec/models_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Models do 2 | 3 | def models(load_path) 4 | ConsistencyFail::Models.new(load_path) 5 | end 6 | 7 | it "gets the load path" do 8 | expect(models([:a, :b, :c]).load_path).to eq([:a, :b, :c]) 9 | end 10 | 11 | it "gets the directories matching /models/" do 12 | models = models(["foo/bar/baz", "app/models", "some/other/models"]) 13 | expect(models.dirs).to eq(["app/models", "some/other/models"]) 14 | end 15 | 16 | it "accepts and matches path names as well as strings" do 17 | models = models([Pathname.new("app/models")]) 18 | expect { models.dirs }.not_to raise_error 19 | expect(models.dirs).to eq([Pathname.new("app/models")]) 20 | end 21 | 22 | it "preloads models" do 23 | models = models(["foo/bar/baz", "app/models", "some/other/models"]) 24 | allow(Dir).to receive(:glob). 25 | with(File.join("app/models", "**", "*.rb")). 26 | and_return(["app/models/user.rb", "app/models/address.rb"]) 27 | allow(Dir).to receive(:glob). 28 | with(File.join("some/other/models", "**", "*.rb")). 29 | and_return(["some/other/models/foo.rb"]) 30 | 31 | expect(Kernel).to receive(:require_dependency).with("app/models/user.rb") 32 | expect(Kernel).to receive(:require_dependency).with("app/models/address.rb") 33 | expect(Kernel).to receive(:require_dependency).with("some/other/models/foo.rb") 34 | 35 | models.preload_all 36 | end 37 | 38 | it "gets all models" do 39 | model_a = double(:name => "animal") 40 | model_b = double(:name => "cat") 41 | model_c = double(:name => "beach_ball") 42 | 43 | allow(ActiveRecord::Base).to receive(:send).with(:descendants).and_return([model_a, model_b, model_c]) 44 | 45 | expect(models([]).all).to eq([model_a, model_c, model_b]) 46 | end 47 | 48 | it "preloads models successfully" do 49 | models = models([File.join(File.dirname(__FILE__), "support/models")]) 50 | expect {models.preload_all}.not_to raise_error 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/reporter_spec.rb: -------------------------------------------------------------------------------- 1 | describe ConsistencyFail::Reporter do 2 | 3 | context "validates_uniqueness_of" do 4 | it "says everything's good" do 5 | expect { 6 | subject.report_validates_uniqueness_problems([]) 7 | }.to output(/Hooray!/).to_stdout 8 | end 9 | 10 | it "shows a missing single-column index on a single model" do 11 | missing_indexes = [ 12 | ConsistencyFail::Index.new( 13 | WrongAccount, 14 | WrongAccount.table_name, 15 | ["email"] 16 | ) 17 | ] 18 | 19 | expect { 20 | subject.report_validates_uniqueness_problems( 21 | WrongAccount => missing_indexes 22 | ) 23 | }.to output(/wrong_accounts\s+\(email\)/).to_stdout 24 | end 25 | 26 | it "shows a missing multiple-column index on a single model" do 27 | missing_indexes = [ 28 | ConsistencyFail::Index.new( 29 | WrongBusiness, 30 | WrongBusiness.table_name, 31 | ["name", "city", "state"] 32 | ) 33 | ] 34 | 35 | expect { 36 | subject.report_validates_uniqueness_problems( 37 | WrongBusiness => missing_indexes 38 | ) 39 | }.to output(/wrong_businesses\s+\(name, city, state\)/).to_stdout 40 | end 41 | 42 | context "with problems on multiple models" do 43 | def report 44 | missing_indices = { 45 | WrongAccount => [ 46 | ConsistencyFail::Index.new( 47 | WrongAccount, 48 | WrongAccount.table_name, 49 | ["email"] 50 | ) 51 | ], 52 | WrongBusiness => [ 53 | ConsistencyFail::Index.new( 54 | WrongBusiness, 55 | WrongBusiness.table_name, 56 | ["name", "city", "state"] 57 | ) 58 | ] 59 | } 60 | 61 | subject.report_validates_uniqueness_problems(missing_indices) 62 | end 63 | 64 | it "shows all problems" do 65 | expect { report }.to output(/wrong_accounts\s+\(email\)/).to_stdout 66 | expect { report }.to output( 67 | /wrong_businesses\s+\(name, city, state\)/ 68 | ).to_stdout 69 | end 70 | 71 | it "orders the models alphabetically" do 72 | expect { report }.to output(/ 73 | wrong_accounts\s+\(email\) 74 | (\s|\S)* 75 | wrong_businesses\s+\(name,\scity,\sstate\) 76 | /x).to_stdout 77 | end 78 | end 79 | end 80 | 81 | context "has_one" do 82 | it "says everything's good" do 83 | expect { 84 | subject.report_has_one_problems([]) 85 | }.to output(/Hooray!/).to_stdout 86 | end 87 | 88 | it "shows a missing single-column index on a single model" do 89 | missing_indexes = [ 90 | ConsistencyFail::Index.new( 91 | WrongAddress, 92 | WrongAddress.table_name, 93 | ["wrong_user_id"] 94 | ) 95 | ] 96 | 97 | expect { 98 | subject.report_has_one_problems(WrongAddress => missing_indexes) 99 | }.to output(/wrong_addresses\s+\(wrong_user_id\)/).to_stdout 100 | end 101 | end 102 | 103 | context "polymorphic" do 104 | it "says everything's good" do 105 | expect { 106 | subject.report_polymorphic_problems([]) 107 | }.to output(/Hooray!/).to_stdout 108 | end 109 | 110 | it "shows a missing compound index on a single model" do 111 | missing_indexes = [ 112 | ConsistencyFail::Index.new( 113 | WrongAttachment, 114 | WrongAttachment.table_name, 115 | ["attachable_type", "attachable_id"] 116 | ) 117 | ] 118 | 119 | expect { 120 | subject.report_polymorphic_problems(WrongAttachment => missing_indexes) 121 | }.to( 122 | output( 123 | /wrong_attachments\s+\(attachable_type, attachable_id\)/ 124 | ).to_stdout 125 | ) 126 | end 127 | end 128 | 129 | end 130 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | Bundler.require(:default, :test) 4 | 5 | require_relative 'support/active_record' 6 | require_relative 'support/schema' 7 | require 'active_support/dependencies' 8 | ActiveSupport::Dependencies.autoload_paths << './spec/support/models' 9 | 10 | RSpec.configure do |config| 11 | config.around do |example| 12 | ActiveRecord::Base.transaction do 13 | example.run 14 | raise ActiveRecord::Rollback 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/support/active_record.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:" 2 | -------------------------------------------------------------------------------- /spec/support/models/blob.rb: -------------------------------------------------------------------------------- 1 | class Blob < ActiveRecord::Base 2 | require_dependency 'blob/edible' 3 | include Edible 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/blob/edible.rb: -------------------------------------------------------------------------------- 1 | module Blob::Edible 2 | end 3 | -------------------------------------------------------------------------------- /spec/support/models/correct_account.rb: -------------------------------------------------------------------------------- 1 | class CorrectAccount < ActiveRecord::Base 2 | validates :email, uniqueness: true 3 | end -------------------------------------------------------------------------------- /spec/support/models/correct_address.rb: -------------------------------------------------------------------------------- 1 | class WrongAddress < ActiveRecord::Base 2 | belongs_to :wrong_user 3 | end -------------------------------------------------------------------------------- /spec/support/models/correct_attachment.rb: -------------------------------------------------------------------------------- 1 | class CorrectAttachment < ActiveRecord::Base 2 | belongs_to :attachable, polymorphic: true 3 | 4 | validates :name, uniqueness: { scope: :attachable } 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/models/correct_person.rb: -------------------------------------------------------------------------------- 1 | class CorrectPerson < ActiveRecord::Base 2 | validates :email, uniqueness: { scope: [:city, :state] } 3 | end -------------------------------------------------------------------------------- /spec/support/models/correct_post.rb: -------------------------------------------------------------------------------- 1 | class CorrectPost < ActiveRecord::Base 2 | has_one :correct_attachment, as: :attachable 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/correct_user.rb: -------------------------------------------------------------------------------- 1 | class CorrectUser < ActiveRecord::Base 2 | has_one :correct_address 3 | has_one :credential 4 | has_one :phone, as: :phoneable 5 | 6 | validates :email, uniqueness: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/correct_user/credential.rb: -------------------------------------------------------------------------------- 1 | require_relative '../correct_user' 2 | class CorrectUser::Credential < ActiveRecord::Base 3 | end 4 | -------------------------------------------------------------------------------- /spec/support/models/correct_user/phone.rb: -------------------------------------------------------------------------------- 1 | require_relative "../correct_user" 2 | class CorrectUser::Phone < ActiveRecord::Base 3 | belongs_to :phoneable, polymorphic: true 4 | end 5 | -------------------------------------------------------------------------------- /spec/support/models/new_correct_person.rb: -------------------------------------------------------------------------------- 1 | class NewCorrectPerson < CorrectPerson 2 | self.table_name = 'new_correct_people' 3 | 4 | def readonly? 5 | true 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/models/nonexistent.rb: -------------------------------------------------------------------------------- 1 | class Nonexistent < ActiveRecord::Base 2 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_account.rb: -------------------------------------------------------------------------------- 1 | class WrongAccount < ActiveRecord::Base 2 | validates :email, uniqueness: true 3 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_address.rb: -------------------------------------------------------------------------------- 1 | class CorrectAddress < ActiveRecord::Base 2 | belongs_to :correct_user 3 | 4 | validates :city, uniqueness: { scope: :correct_user } 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/models/wrong_attachment.rb: -------------------------------------------------------------------------------- 1 | class WrongAttachment < ActiveRecord::Base 2 | belongs_to :attachable, polymorphic: true 3 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_business.rb: -------------------------------------------------------------------------------- 1 | class WrongBusiness < ActiveRecord::Base 2 | validates :name, uniqueness: { scope: [:city, :state] } 3 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_person.rb: -------------------------------------------------------------------------------- 1 | class WrongPerson < ActiveRecord::Base 2 | validates :email, :name, uniqueness: { scope: [:city, :state] } 3 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_post.rb: -------------------------------------------------------------------------------- 1 | class WrongPost < ActiveRecord::Base 2 | has_one :wrong_attachment, as: :attachable 3 | end -------------------------------------------------------------------------------- /spec/support/models/wrong_user.rb: -------------------------------------------------------------------------------- 1 | class WrongUser < ActiveRecord::Base 2 | has_one :wrong_address 3 | end -------------------------------------------------------------------------------- /spec/support/schema.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Schema.define(version: 0) do 2 | self.verbose = false 3 | 4 | create_table :correct_accounts do |t| 5 | t.string :email 6 | t.timestamps 7 | end 8 | 9 | add_index "correct_accounts", ["email"], name: "index_correct_accounts_on_email", unique: true, using: :btree 10 | 11 | create_table :correct_addresses do |t| 12 | t.string :city 13 | t.integer :correct_user_id 14 | t.string :state 15 | t.timestamps 16 | end 17 | 18 | add_index "correct_addresses", ["correct_user_id"], name: "index_correct_addresses_on_user_id", unique: true, using: :btree 19 | add_index "correct_addresses", ["city", "correct_user_id"], name: "index_correct_addresses_on_city_and_correct_user", unique: true, using: :btree 20 | 21 | create_table :correct_attachments do |t| 22 | t.integer :attachable_id 23 | t.string :attachable_type 24 | t.string :name 25 | t.timestamps 26 | end 27 | 28 | add_index "correct_attachments", ["name", "attachable_id", "attachable_type"], name: "index_correct_attachments_on_name_attachable_id_and_type", unique: true, using: :btree 29 | add_index "correct_attachments", ["attachable_id", "attachable_type"], name: "index_correct_attachments_on_attachable_id_and_attachable_type", unique: true, using: :btree 30 | add_index "correct_attachments", ["name"], name: "index_correct_attachments_on_name", unique: true, using: :btree 31 | 32 | create_table :correct_people do |t| 33 | t.string :city 34 | t.string :email 35 | t.string :name 36 | t.string :state 37 | t.timestamps 38 | end 39 | 40 | add_index "correct_people", ["state", "city", "email"], name: "index_correct_people_on_city_and_state_and_email", unique: true, using: :btree 41 | 42 | create_table :correct_posts do |t| 43 | t.text :content 44 | t.string :title 45 | t.timestamps 46 | end 47 | 48 | create_table :correct_users do |t| 49 | t.string :email 50 | t.string :name 51 | t.timestamps 52 | end 53 | 54 | create_table :correct_user_credentials do |t| 55 | t.string :oauth_token 56 | t.string :refresh_token 57 | t.integer :correct_user_id 58 | t.timestamps 59 | end 60 | add_index "correct_user_credentials", ["correct_user_id"], name: "index_correct_user_credentials_on_user_id", unique: true, using: :btree 61 | 62 | create_table :correct_user_phones do |t| 63 | t.string :text 64 | t.integer :phoneable_id 65 | t.string :phoneable_type 66 | t.timestamps 67 | end 68 | add_index "correct_user_phones", ["phoneable_id", "phoneable_type"], name: "index_correct_user_phones_on_id_and_type", unique: true, using: :btree 69 | 70 | create_table :wrong_accounts do |t| 71 | t.string :email 72 | t.timestamps 73 | end 74 | 75 | create_table :wrong_addresses do |t| 76 | t.string :city 77 | t.string :state 78 | t.integer :wrong_user_id 79 | t.timestamps 80 | end 81 | 82 | add_index "wrong_addresses", ["wrong_user_id"], name: "index_wrong_addresses_on_user_id", using: :btree 83 | 84 | create_table :wrong_attachments do |t| 85 | t.integer :attachable_id 86 | t.string :attachable_type 87 | t.string :name 88 | t.timestamps 89 | end 90 | 91 | create_table :wrong_businesses do |t| 92 | t.string :city 93 | t.string :name 94 | t.string :state 95 | t.timestamps 96 | end 97 | 98 | create_table :wrong_people do |t| 99 | t.string :city 100 | t.string :email 101 | t.string :name 102 | t.string :state 103 | t.timestamps 104 | end 105 | 106 | create_table :wrong_posts do |t| 107 | t.text :content 108 | t.string :title 109 | t.timestamps 110 | end 111 | 112 | create_table :wrong_users do |t| 113 | t.string :email 114 | t.string :name 115 | t.timestamps 116 | end 117 | 118 | execute 'CREATE VIEW new_correct_people AS '\ 119 | 'SELECT * FROM correct_people '\ 120 | 'WHERE created_at = updated_at' 121 | 122 | create_table :blob do |t| 123 | t.timestamps 124 | end 125 | end 126 | --------------------------------------------------------------------------------