├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Gemfile ├── README.md ├── Rakefile ├── context_validations.gemspec ├── lib ├── context_validations.rb └── context_validations │ ├── controller.rb │ ├── minitest.rb │ ├── model.rb │ └── version.rb └── test ├── controller_test.rb ├── model_test.rb └── test_helper.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | bin/* 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | rvm: 2 | - 1.9.3 3 | - 2.0.0 4 | 5 | notifications: 6 | email: 7 | - brian@dockyard.com 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines # 2 | 3 | ## Submitting a new issue ## 4 | 5 | If you want to ensure that your issue gets fixed *fast* you should 6 | attempt to reproduce the issue in an isolated example application that 7 | you can share. 8 | 9 | ## Making a pull request ## 10 | 11 | If you'd like to submit a pull request please adhere to the following: 12 | 13 | 1. Your code *must* be tested. Please TDD your code! 14 | 2. No single-character variables 15 | 3. Two-spaces instead of tabs 16 | 4. Single-quotes instead of double-quotes unless you are using string 17 | interpolation or escapes. 18 | 5. General Rails/Ruby naming conventions for files and classes 19 | 6. *Do not* use Ruby 1.9 stabby proc syntax 20 | 21 | Please note that you must adhere to each of the above mentioned rules. 22 | Failure to do so will result in an immediate closing of the pull 23 | request. If you update and rebase the pull request to follow the 24 | guidelines your pull request will be re-opened and considered for 25 | inclusion. 26 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in context_validations.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ContextValidations # 2 | 3 | [![Build Status](https://secure.travis-ci.org/dockyard/context_validations.png?branch=master)](http://travis-ci.org/dockyard/context_validations) 4 | [![Dependency Status](https://gemnasium.com/dockyard/context_validations.png?travis)](https://gemnasium.com/dockyard/context_validations) 5 | [![Code Climate](https://codeclimate.com/github/dockyard/context_validations.png)](https://codeclimate.com/github/dockyard/context_validations) 6 | 7 | Context based validations for model instances. 8 | 9 | ## Looking for help? ## 10 | 11 | If it is a bug [please open an issue on GitHub](https://github.com/dockyard/context_validations/issues). 12 | 13 | ## Installation ## 14 | 15 | In your `Gemfile` 16 | 17 | ```ruby 18 | gem 'context_validations' 19 | ``` 20 | 21 | You can either mixin the modules on a case-by-case basis or make the 22 | changes global: 23 | 24 | ### Case-by-case ### 25 | 26 | ```ruby 27 | # model 28 | class User < ActiveModel::Base 29 | include ContextValidations::Model 30 | end 31 | 32 | # controller 33 | class UsersController < ApplicationController 34 | include ContextValidations::Controller 35 | end 36 | ``` 37 | 38 | ### Global ### 39 | Create an initializer: `config/initializers/context_validations.rb` 40 | 41 | ```ruby 42 | class ActiveRecord::Base 43 | include ContextValidations::Model 44 | end 45 | 46 | class ActionController::Base 47 | include ContextValidations::Controller 48 | end 49 | ``` 50 | 51 | ## Usage ## 52 | 53 | ```ruby 54 | class UserController < ApplicationController 55 | include ContextValidations::Controller 56 | 57 | def create 58 | @user = User.new(user_params) 59 | @user.validations = validations(:create) 60 | 61 | if @user.save 62 | # happy path 63 | else 64 | # sad path 65 | end 66 | end 67 | 68 | private 69 | 70 | def create_validations 71 | validates :password, :presence => true 72 | end 73 | 74 | def base_validations 75 | validates :first_name, :last_name, :presence => true 76 | validates :password, :confirmation => true 77 | validates :email, :uniqueness => true, :format => EmailFormat 78 | end 79 | end 80 | 81 | class User < ActiveRecord::Base 82 | include ContextValidations::Model 83 | end 84 | ``` 85 | 86 | ### Controllers ### 87 | In the above example we just call `validations` and pass the context. We 88 | set the result to `#validations=` on the model. 89 | 90 | While this does introduce some complexity into our controllers it frees 91 | us from the mental gymnastics of conditional validators and state flags. 92 | 93 | The corresponding `#{context}_validations` method, in this case 94 | `create_validations` defines the validations that will be used. Call the 95 | `#validates` method just like you would in model validations, the API is 96 | identical. 97 | 98 | A `#base_validations` method is always called prior to 99 | `#{context}_validations` that will allow you to group together common 100 | validations. The result of these methods appends onto a `@validations` 101 | array. 102 | 103 | If you are using `Ruby 2.0+` you can use implicit contexts: 104 | 105 | ```ruby 106 | def create 107 | @user = User.new(user_params) 108 | # Notice we are only calling the #validations method and not passing 109 | # context. In this example the context is derived from the calling 110 | # method `create` 111 | @user.validations = validations 112 | end 113 | 114 | private 115 | 116 | def create_validations 117 | ... 118 | end 119 | ``` 120 | 121 | ### Models ### 122 | When the `ContextValidations::Model` module is mixed into the model all 123 | of the validation callbacks are removed from that model. 124 | 125 | Because we are setting the validations on the instance you should not 126 | use `ActiveRecord::Base.create` as we do not want to allow the 127 | validations to be set via mass-assignment. This does introduce an extra 128 | step in some places but it shouldn't be that big of a deal. 129 | 130 | ## Testing ## 131 | 132 | Currently only `MiniTest` is supported. We are open to pull requests for supporting additional test frameworks but we only work with `MiniTest` 133 | so we probably won't go out of the way to support something we're not using. 134 | 135 | We highly recommend using [ValidAttribute](https://github.com/bcardarella/valid_attribute) to test your validations. The following example is done using 136 | `ValidAttribute`. 137 | 138 | ### MiniTest ### 139 | 140 | In `/test/test_helper.rb`: 141 | 142 | ```ruby 143 | require 'context_validations/minitest' 144 | ``` 145 | 146 | You are given access to a `#validations_for(:action_name)` method. You should pass the action in your 147 | controller that is the context of the validations and use the `#validations=` setter on the model. 148 | 149 | This is a common example of how to test: 150 | 151 | ```ruby 152 | require 'test_helper' 153 | 154 | describe UserController do 155 | context 'create' do 156 | subject { User.new(:password => 'password', :validations => validations_for(:create)) } 157 | it { must have_valid(:name).when('Brian Cardarella') } 158 | it { wont have_valid(:name).when(nil, '') } 159 | end 160 | end 161 | ``` 162 | 163 | ## ClientSideValidations Support ## 164 | 165 | The [ClientSideValidations](https://github.com/bcardarella/client_side_validations) gem is fully supported. 166 | 167 | ## Authors ## 168 | 169 | * [Brian Cardarella](http://twitter.com/bcardarella) 170 | 171 | [We are very thankful for the many contributors](https://github.com/dockyard/context_validations/graphs/contributors) 172 | 173 | ## Versioning ## 174 | 175 | This gem follows [Semantic Versioning](http://semver.org) 176 | 177 | ## Want to help? ## 178 | 179 | Please do! We are always looking to improve this gem. Please see our 180 | [Contribution Guidelines](https://github.com/dockyard/context_validations/blob/master/CONTRIBUTING.md) 181 | on how to properly submit issues and pull requests. 182 | 183 | ## Legal ## 184 | 185 | [DockYard](http://dockyard.com), LLC © 2013 186 | 187 | [@dockyard](http://twitter.com/dockyard) 188 | 189 | [Licensed under the MIT license](http://www.opensource.org/licenses/mit-license.php) 190 | 191 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rake/testtask' 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << 'lib' 7 | t.libs << 'test' 8 | t.pattern = 'test/**/*_test.rb' 9 | t.verbose = false 10 | end 11 | 12 | task :default => :test 13 | -------------------------------------------------------------------------------- /context_validations.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'context_validations/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = 'context_validations' 8 | spec.version = ContextValidations::VERSION 9 | spec.authors = ['Brian Cardarella'] 10 | spec.email = ['bcardarella@gmail.com'] 11 | spec.description = %q{Context based validations for ActiveRecord models} 12 | spec.summary = %q{Context based validations for ActiveRecord models} 13 | spec.license = 'MIT' 14 | 15 | spec.files = `git ls-files`.split($/) 16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 17 | spec.require_paths = ['lib'] 18 | 19 | spec.add_dependency 'activesupport', '~> 4.1' 20 | 21 | spec.add_development_dependency 'bundler', '~> 1.3' 22 | spec.add_development_dependency 'rake' 23 | spec.add_development_dependency 'minitest' 24 | spec.add_development_dependency 'm' 25 | spec.add_development_dependency 'activerecord', '~> 4.1' 26 | spec.add_development_dependency 'sqlite3' 27 | end 28 | -------------------------------------------------------------------------------- /lib/context_validations.rb: -------------------------------------------------------------------------------- 1 | require 'context_validations/version' 2 | require 'active_support' 3 | if defined?(MiniTest::Unit::TestCase) 4 | require 'context_validations/minitest' 5 | end 6 | 7 | module ContextValidations 8 | extend ActiveSupport::Autoload 9 | 10 | autoload :Controller 11 | autoload :Model 12 | end 13 | -------------------------------------------------------------------------------- /lib/context_validations/controller.rb: -------------------------------------------------------------------------------- 1 | module ContextValidations::Controller 2 | # Will build the validations used to assign to a model instance 3 | # 4 | # Passing a context will call the `#{context}_validations` method if available 5 | # `#base_validations` will always be called prior to `#{context}_validations` 6 | # 7 | # If you are using Ruby 2.0+ not passing a context will force an implicit context call 8 | # based upon the calling method name. 9 | # 10 | # examples: 11 | # # Implicit method call will call `#base_validations` then `#create_validations` 12 | # def create 13 | # @user.validations = validations 14 | # end 15 | # 16 | # # Will call `#base_validations` then `#create_validations` 17 | # def other_create 18 | # @user.validations = validations(:create) 19 | # end 20 | # 21 | # # Will onliy call `#base_validations` because `#update_validations` does not exist 22 | # def update 23 | # @user.validations = validations 24 | # end 25 | # 26 | # def create_validations 27 | # ... 28 | # end 29 | # 30 | # @param [String, Symbol] 31 | def validations(context = nil) 32 | if RUBY_VERSION > '2' 33 | context ||= caller_locations(1, 1).first.label 34 | end 35 | @validations = [] 36 | base_validations 37 | if respond_to?("#{context}_validations") || private_methods.include?("#{context}_validations".to_sym) || 38 | protected_methods.include?("#{context}_validations".to_sym) 39 | send("#{context}_validations") 40 | end 41 | @validations 42 | end 43 | 44 | # Instance level implementation of `ActiveModel::Validations.validates` 45 | # Will accept all of the same options as the class-level model versions of the method 46 | def validates(*attributes) 47 | defaults = attributes.extract_options! 48 | validations = defaults.slice!(*_validates_default_keys) 49 | 50 | attributes.inject(@validations) do |validators, attribute| 51 | defaults[:attributes] = [attribute] 52 | validations.each do |key, options| 53 | key = "#{key.to_s.camelize}Validator" 54 | namespace = defined?(ActiveRecord) ? ActiveRecord::Base : ActiveModel::Validations 55 | klass = key.include?('::') ? key.constantize : namespace.const_get(key) 56 | validator = { class: klass, options: defaults.merge(_parse_validates_options(options)) } 57 | validators << validator 58 | end 59 | validators 60 | end.flatten.uniq 61 | end 62 | 63 | private 64 | 65 | def _validates_default_keys 66 | [:if, :unless, :on, :allow_blank, :allow_nil , :strict, :message] 67 | end 68 | 69 | def _parse_validates_options(options) 70 | case options 71 | when TrueClass 72 | {} 73 | when Hash 74 | options 75 | when Range, Array 76 | { :in => options } 77 | else 78 | { :with => options } 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/context_validations/minitest.rb: -------------------------------------------------------------------------------- 1 | module ContextValidations 2 | module ValidationsFor 3 | module MiniTest 4 | def validations_for(action) 5 | determine_constant_from_test_name.new.validations(action) 6 | end 7 | 8 | def determine_constant_from_test_name 9 | names = self.class.name.split('::') 10 | 11 | while names.size > 0 do 12 | names.last.sub!(/Test$/, '') 13 | begin 14 | constant = names.join('::').constantize 15 | break(constant) if constant 16 | rescue NameError 17 | # Constant wasn't found, move on 18 | ensure 19 | names.pop 20 | end 21 | end 22 | end 23 | end 24 | end 25 | end 26 | 27 | MiniTest::Unit::TestCase.send(:include, ContextValidations::ValidationsFor::MiniTest) 28 | -------------------------------------------------------------------------------- /lib/context_validations/model.rb: -------------------------------------------------------------------------------- 1 | module ContextValidations::Model 2 | def self.included(base) 3 | base.class_eval do 4 | reset_callbacks(:validate) 5 | end 6 | 7 | base._validators.keys.each do |key| 8 | base._validators.delete(key) 9 | end 10 | end 11 | 12 | # The collection of validations assigned to this model instance 13 | # 14 | # @return [Array] 15 | def validations 16 | @validations ||= [] 17 | end 18 | 19 | # Use to set the validations collection assigned to this model instance 20 | # 21 | # Pass an array of validator instances 22 | # 23 | # @param [[ActiveMode::Validations::Validator]] 24 | def validations=(validations) 25 | @validations = validations.flatten.map do |validator| 26 | validator[:options][:class] = self.class 27 | validator[:class].new(validator[:options]) 28 | end 29 | end 30 | 31 | protected 32 | 33 | def run_validations! 34 | Array.wrap(validations).each do |validator| 35 | if validator.respond_to?(:setup) 36 | validator.setup(self.class) 37 | end 38 | if validator.options[:if] 39 | if validator.options[:if].respond_to?(:call) 40 | if validator.options[:if].call(self) 41 | validator.validate(self) 42 | end 43 | elsif self.send(validator.options[:if]) 44 | validator.validate(self) 45 | end 46 | elsif validator.options[:unless] 47 | if validator.options[:unless].respond_to?(:call) 48 | if !validator.options[:unless].call(self) 49 | validator.validate(self) 50 | end 51 | elsif !self.send(validator.options[:unless]) 52 | validator.validate(self) 53 | end 54 | else 55 | validator.validate(self) 56 | end 57 | end 58 | errors.empty? 59 | end 60 | 61 | private 62 | 63 | def _validators 64 | validations.inject({}) do |hash, validator| 65 | attribute = validator.attributes.first 66 | if hash.key?(attribute) 67 | hash[attribute] << validator 68 | else 69 | hash[attribute] = [validator] 70 | end 71 | hash 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/context_validations/version.rb: -------------------------------------------------------------------------------- 1 | module ContextValidations 2 | VERSION = '0.3.0' 3 | end 4 | -------------------------------------------------------------------------------- /test/controller_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class UsersController 4 | include ContextValidations::Controller 5 | 6 | def create 7 | validations 8 | end 9 | 10 | def other_create 11 | validations(:create) 12 | end 13 | 14 | def update 15 | validations 16 | end 17 | 18 | def base_validations 19 | validates :first_name, :presence => true 20 | end 21 | 22 | def create_validations 23 | validates :password, :presence => true 24 | end 25 | end 26 | 27 | class ProtectedController 28 | include ContextValidations::Controller 29 | 30 | def create 31 | validations(:create) 32 | end 33 | 34 | protected 35 | 36 | def base_validations 37 | validates :first_name, :presence => true 38 | end 39 | 40 | def create_validations 41 | validates :password, :presence => true 42 | end 43 | end 44 | 45 | class PrivateController 46 | include ContextValidations::Controller 47 | 48 | def create 49 | validations(:create) 50 | end 51 | 52 | private 53 | 54 | def base_validations 55 | validates :first_name, :presence => true 56 | end 57 | 58 | def create_validations 59 | validates :password, :presence => true 60 | end 61 | end 62 | 63 | class CustomValidator < ActiveModel::EachValidator 64 | def validate_each(record, attribute, value) 65 | if value != 'awesome' 66 | record.errors.add(attribute, 'Failed') 67 | end 68 | end 69 | end 70 | 71 | class CustomValidatorsController 72 | include ContextValidations::Controller 73 | 74 | def create 75 | validations(:create) 76 | end 77 | 78 | private 79 | 80 | def base_validations 81 | validates :first_name, :custom => true 82 | end 83 | end 84 | 85 | describe 'Controller' do 86 | context 'Public validations' do 87 | before do 88 | @controller = UsersController.new 89 | end 90 | 91 | if RUBY_VERSION >= '2' 92 | it 'combines base and create validations for create action, context is implied' do 93 | @controller.create.length.must_equal 2 94 | end 95 | end 96 | 97 | it 'combines base and create validations for other create action, context is forced' do 98 | @controller.other_create.length.must_equal 2 99 | end 100 | 101 | it 'uses base validations when context validations are not set for update action' do 102 | @controller.update.length.must_equal 1 103 | end 104 | end 105 | 106 | context 'Protected validations' do 107 | before do 108 | @controller = ProtectedController.new 109 | end 110 | 111 | it 'combines base and create validations for other create action, context is forced' do 112 | @controller.create.length.must_equal 2 113 | end 114 | end 115 | 116 | context 'Private validations' do 117 | before do 118 | @controller = PrivateController.new 119 | end 120 | 121 | it 'combines base and create validations for other create action, context is forced' do 122 | @controller.create.length.must_equal 2 123 | end 124 | end 125 | 126 | context 'Custom validations' do 127 | before do 128 | @controller = CustomValidatorsController.new 129 | end 130 | 131 | it 'combines base and create validations for other create action, context is forced' do 132 | @controller.create.length.must_equal 1 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/model_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | EmailFormat = /\A([^@\s]+)@((?:[-a-z0-9]+\.)+[a-z]{2,})\Z/ 4 | 5 | users_table = %{CREATE TABLE users (id INTEGER PRIMARY KEY, first_name TEXT, email TEXT);} 6 | ActiveRecord::Base.connection.execute(users_table) 7 | class User < ActiveRecord::Base 8 | include ContextValidations::Model 9 | 10 | validates :first_name, :presence => true 11 | validates :email, :format => EmailFormat 12 | end 13 | 14 | describe 'Model' do 15 | before do 16 | @user = User.new 17 | end 18 | 19 | it 'ignores existing validations' do 20 | @user.valid?.must_equal true 21 | end 22 | 23 | it 'accepts validations set onto the instance' do 24 | validations = [ 25 | {class: ActiveModel::Validations::PresenceValidator, options: { :attributes => [:first_name] }}, 26 | {class: ActiveModel::Validations::FormatValidator, options: { :attributes => [:email], :with => EmailFormat }}, 27 | {class: ActiveRecord::Validations::UniquenessValidator, options: { :attributes => [:email] }} 28 | ] 29 | @user.validations = validations 30 | @user.valid?.must_equal false 31 | @user.errors.count.must_equal 2 32 | end 33 | 34 | it 'respect conditional validations set onto the instance' do 35 | validations = [ 36 | {class: ActiveModel::Validations::PresenceValidator, options: { :attributes => [:first_name], :if => :can_validate? }}, 37 | {class: ActiveModel::Validations::PresenceValidator, options: { :attributes => [:first_name], :if => Proc.new { |model| model.can_validate? }}}, 38 | {class: ActiveModel::Validations::PresenceValidator, options: { :attributes => [:first_name], :unless => :cannot_validate? }}, 39 | {class: ActiveModel::Validations::PresenceValidator, options: { :attributes => [:first_name], :unless => Proc.new { |model| model.cannot_validate? }}} 40 | ] 41 | def @user.can_validate? 42 | false 43 | end 44 | def @user.cannot_validate? 45 | true 46 | end 47 | @user.validations = validations 48 | @user.valid?.must_equal true 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | if defined?(M) 4 | require 'minitest/spec' 5 | else 6 | require 'minitest/autorun' 7 | end 8 | 9 | require 'active_record' 10 | require 'context_validations' 11 | 12 | class MiniTest::Spec 13 | class << self 14 | alias :context :describe 15 | end 16 | end 17 | 18 | ActiveRecord::Base.establish_connection( 19 | :adapter => defined?(JRUBY_VERSION) ? 'jdbcsqlite3' : 'sqlite3', 20 | :database => ':memory:' 21 | ) 22 | --------------------------------------------------------------------------------