diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d87d4be --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +*.gem +*.rbc +.bundle +.config +.yardoc +Gemfile.lock +InstalledFiles +_yardoc +coverage +doc/ +lib/bundler/man +pkg +rdoc +spec/reports +test/tmp +test/version_tmp +tmp diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..2af562d --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' + +# Specify your gem's dependencies in broken_record.gemspec +gemspec diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..91bedf8 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,22 @@ +Copyright (c) 2014 Nicholas Gervasi + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cc6dfc1 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# BrokenRecord + +Provides a rake task for scanning your ActiveRecord models and detecting validation errors. + +## Installation + +Add this line to your application's Gemfile: + + gem 'broken_record' + +And then execute: + + $ bundle + +Or install it yourself as: + + $ gem install broken_record + +## Usage + +To scan all records of all models in your project: + + rake broken_record:scan + +If you want to scan all records of a specific model (e.g. the User model) + + rake broken_record:scan[User] + +## Configuration + +BrokenRecord provides a configure method with two options. Here's an example: + + BrokenRecord.configure do |config| + # Skip the Foo and Bar models when scanning. + config.classes_to_skip = [Foo, Bar] + + # BrokenRecord will call the block provided in before_scan before scanning + # your records. This is useful for skipping validations you want ignore. + config.before_scan do + User.skip_callback :validate, :before, :user_must_be_active + end + end + +## Contributing + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Add some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2995527 --- /dev/null +++ b/Rakefile @@ -0,0 +1 @@ +require "bundler/gem_tasks" diff --git a/broken_record.gemspec b/broken_record.gemspec new file mode 100644 index 0000000..6b0ea61 --- /dev/null +++ b/broken_record.gemspec @@ -0,0 +1,26 @@ +# coding: utf-8 +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'broken_record/version' + +Gem::Specification.new do |spec| + spec.name = "broken_record" + spec.version = BrokenRecord::VERSION + spec.authors = ["Nicholas Gervasi"] + spec.email = ["nick@zenpayroll.com"] + spec.description = %q{Detects ActiveRecord models that are not valid.} + spec.summary = %q{Provides a rake task for scanning your ActiveRecord models and detecting validation errors.} + spec.homepage = "" + spec.license = "MIT" + + spec.files = `git ls-files`.split($/) + spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } + spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) + spec.require_paths = ["lib"] + + spec.add_development_dependency "bundler", "~> 1.3" + + spec.add_runtime_dependency "rake" + spec.add_runtime_dependency "parallel" + spec.add_runtime_dependency "colorize" +end diff --git a/lib/broken_record.rb b/lib/broken_record.rb new file mode 100644 index 0000000..23729d0 --- /dev/null +++ b/lib/broken_record.rb @@ -0,0 +1,16 @@ +require "broken_record/version" +require "broken_record/config" +require "broken_record/scanner" +require "broken_record/railtie" if defined? Rails::Railtie + +module BrokenRecord + extend self + + def configure + yield BrokenRecord::Config + end +end + +BrokenRecord.configure do |config| + config.classes_to_skip = [] +end \ No newline at end of file diff --git a/lib/broken_record/config.rb b/lib/broken_record/config.rb new file mode 100644 index 0000000..723e8a7 --- /dev/null +++ b/lib/broken_record/config.rb @@ -0,0 +1,11 @@ +module BrokenRecord + module Config + extend self + attr_accessor :classes_to_skip, :before_scan_callbacks + + def before_scan(&block) + self.before_scan_callbacks ||= [] + self.before_scan_callbacks << block + end + end +end \ No newline at end of file diff --git a/lib/broken_record/logger.rb b/lib/broken_record/logger.rb new file mode 100644 index 0000000..d8ae776 --- /dev/null +++ b/lib/broken_record/logger.rb @@ -0,0 +1,58 @@ +require 'tempfile' + +module BrokenRecord + class Logger + + # Static Methods + + def self.report_output(result, lock = nil) + lock.flock File::LOCK_EX if lock + $stdout.print result[:stdout] + $stdout.flush + ensure + lock.flock File::LOCK_UN if lock + end + + def self.report_results(test_results) + total_errors = 0 + test_results.each { |result| total_errors += result[:errors] } + if total_errors == 0 + puts "\nAll models validated successfully.".green + else + puts "\n#{total_errors} errors were found while running validations.".red + exit 1 + end + end + + def self.parallel + Tempfile.open 'broken_record_lock' do |lock| + yield lock + end + end + + # Instance Methods + + def initialize + @errors = 0 + @stdout = "" + end + + def log_error(message) + @stdout << "[FAIL]\n".red if @errors == 0 + @stdout << "#{message.red}\n" + @errors += 1 + end + + def log_message(message) + @stdout << "#{message}" + end + + def log_result + @stdout << "[PASS]\n".green if @errors == 0 + end + + def result + { stdout: @stdout, errors: @errors} + end + end +end \ No newline at end of file diff --git a/lib/broken_record/railtie.rb b/lib/broken_record/railtie.rb new file mode 100644 index 0000000..0e5b4ef --- /dev/null +++ b/lib/broken_record/railtie.rb @@ -0,0 +1,8 @@ +# rake tasks for Rails 3+ +module BrokenRecord + class Railtie < ::Rails::Railtie + rake_tasks do + require "broken_record/tasks" + end + end +end diff --git a/lib/broken_record/scanner.rb b/lib/broken_record/scanner.rb new file mode 100644 index 0000000..68a6eef --- /dev/null +++ b/lib/broken_record/scanner.rb @@ -0,0 +1,71 @@ +require "broken_record/logger" + +module BrokenRecord + class Scanner + def run(model_name = nil) + models = models_to_validate(model_name) + + BrokenRecord::Config.before_scan_callbacks.each { |callback| callback.call } + + results = BrokenRecord::Logger.parallel do |lock| + Parallel.map(models) do |model| + result = validate_model(model) + BrokenRecord::Logger.report_output(result, lock) + result + end + end + + BrokenRecord::Logger.report_results(results) + end + + private + + def models_to_validate(model_name) + if model_name + [ model_name.constantize ] + else + load_all_active_record_classes + end + end + + def load_all_active_record_classes + Dir.glob(Rails.root.to_s + '/app/models/**/*.rb').each { |file| require file } + objects = Set.new + ObjectSpace.each_object(Class) do |klass| + if ActiveRecord::Base > klass + # Use base_class so we don't try to validate abstract classes and so we don't validate + # STI classes multiple times. See active_record/inheritance.rb for more details. + objects.add klass.base_class unless BrokenRecord::Config.classes_to_skip.include?(klass) + end + end + + objects.sort_by(&:name) + end + + def validate_model(model) + ActiveRecord::Base.connection.reconnect! + + logger = BrokenRecord::Logger.new + logger.log_message "Validating model #{model}... ".ljust(70) + + begin + model.unscoped.all.each do |r| + begin + if !r.valid? + message = " Invalid record in #{model} id=#{r.id}." + r.errors.each { |attr,msg| message << "\n #{attr} - #{msg}" } + logger.log_error message + end + rescue Exception => msg + logger.log_error " Exception for record in #{model} id=#{r.id} - #{msg}." + end + end + rescue Exception => msg + logger.log_error " Error querying model #{model} - #{msg}." + end + + logger.log_result + logger.result + end + end +end \ No newline at end of file diff --git a/lib/broken_record/tasks.rb b/lib/broken_record/tasks.rb new file mode 100644 index 0000000..fbac645 --- /dev/null +++ b/lib/broken_record/tasks.rb @@ -0,0 +1,9 @@ +require 'rake' + +namespace :broken_record do + desc 'Scans all models for validation errors' + task :scan, [:model_name] => :environment do |t, args| + scanner = BrokenRecord::Scanner.new + scanner.run(args[:model_name]) + end +end \ No newline at end of file diff --git a/lib/broken_record/version.rb b/lib/broken_record/version.rb new file mode 100644 index 0000000..ad1a055 --- /dev/null +++ b/lib/broken_record/version.rb @@ -0,0 +1,3 @@ +module BrokenRecord + VERSION = "0.0.1" +end