├── .gitignore ├── .yardopts ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── adequate_errors.gemspec ├── bin ├── console └── setup ├── lib ├── adequate_errors.rb └── adequate_errors │ ├── error.rb │ ├── errors.rb │ ├── interceptor.rb │ ├── locale │ └── en.yml │ ├── nested_error.rb │ └── version.rb └── test ├── cases ├── adequate_errors │ ├── error_test.rb │ ├── errors_test.rb │ ├── interceptor_test.rb │ └── nested_error_test.rb ├── helper.rb └── validations_test.rb └── models ├── custom_reader.rb ├── reply.rb └── topic.rb /.gitignore: -------------------------------------------------------------------------------- 1 | /.ruby-version 2 | /.bundle/ 3 | /.yardoc 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --no-private 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in adequate_errors.gemspec 6 | gemspec 7 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | adequate_errors (0.1.2) 5 | activemodel 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (5.1.4) 11 | activesupport (= 5.1.4) 12 | activesupport (5.1.4) 13 | concurrent-ruby (~> 1.0, >= 1.0.2) 14 | i18n (~> 0.7) 15 | minitest (~> 5.1) 16 | tzinfo (~> 1.1) 17 | builder (3.2.3) 18 | concurrent-ruby (1.0.5) 19 | diff-lcs (1.3) 20 | i18n (0.9.1) 21 | concurrent-ruby (~> 1.0) 22 | minitest (5.11.1) 23 | rake (10.5.0) 24 | rspec (3.7.0) 25 | rspec-core (~> 3.7.0) 26 | rspec-expectations (~> 3.7.0) 27 | rspec-mocks (~> 3.7.0) 28 | rspec-core (3.7.0) 29 | rspec-support (~> 3.7.0) 30 | rspec-expectations (3.7.0) 31 | diff-lcs (>= 1.2.0, < 2.0) 32 | rspec-support (~> 3.7.0) 33 | rspec-mocks (3.7.0) 34 | diff-lcs (>= 1.2.0, < 2.0) 35 | rspec-support (~> 3.7.0) 36 | rspec-support (3.7.0) 37 | thread_safe (0.3.6) 38 | tzinfo (1.2.4) 39 | thread_safe (~> 0.1) 40 | 41 | PLATFORMS 42 | ruby 43 | 44 | DEPENDENCIES 45 | adequate_errors! 46 | builder (~> 3.2.3) 47 | bundler (~> 1.16.a) 48 | rake (~> 10.0) 49 | rspec (~> 3.7) 50 | 51 | BUNDLED WITH 52 | 1.16.0 53 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 LULALALA 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AdequateErrors 2 | 3 | Overcoming limitation of Rails model errors API: 4 | 5 | * fine-grained `where` query 6 | * object-oriented Error object 7 | * turn off message's attribute prefix. 8 | * lazy evaluation of messages 9 | 10 | ## We want some of this as part of Rails 11 | 12 | I have opened a [pull request](https://github.com/rails/rails/pull/32313) to migrate some ideas to Rails. It's a great chance for you to shape the future of the API. Please join the discussion there :) 13 | 14 | ## Introduction 15 | 16 | Rails errors API is simple to use, but can be inadequate when coping with more complex requirements. 17 | 18 | Examples of how existing Rails API is limiting are listed here: http://lulalala.logdown.com/posts/2909828-adequate-errors 19 | 20 | The existing API was originally a collection of message strings without much meta data, making it very restrictive. Though `details` hash was added later for storing meta information, many fundamental issues can not be fixed without altering the API and the architecture. 21 | 22 | This gem redesigned the API, placing it in its own object, co-existing with existing Rails API. Thus nothing will break, allowing you to migrate the code one at a time. 23 | 24 | 25 | ## Quick start 26 | 27 | To access the AdequateErrors, call: 28 | 29 | model.errors.adequate 30 | 31 | From this `Errors` object, many convenience methods are provided: 32 | 33 | Return an array of AdequateErrors::Error objects, matching a condition: 34 | 35 | model.errors.adequate.where(attribute:'title', :type => :too_short, length: 5) 36 | 37 | Prints out each error's full message one by one: 38 | 39 | model.errors.adequate.each {|error| puts error.message } 40 | 41 | Return an array of all message strings: 42 | 43 | model.errors.adequate.messages 44 | 45 | ## `Error` object 46 | 47 | An `Error` object provides the following: 48 | 49 | * `attribute` is the model attribute the error belongs to. 50 | * `type` is the error type. 51 | * `options` is a hash containing additional information such as `:count` or `:length`. 52 | * `message` is the error message. It is full message by design. 53 | 54 | ## `where` query 55 | 56 | Use `where` method to find errors matching different conditions. An array of Error objects are returned. 57 | 58 | To find all errors of `title` attribute, pass it with `:attribute` key: 59 | 60 | model.errors.adequate.where(:attribute => :title) 61 | 62 | You can also filter by error type using the `:type` key: 63 | 64 | model.errors.adequate.where(:type => :too_short) 65 | 66 | Custom attributes passed can also be used to filter errors: 67 | 68 | model.errors.adequate.where(:attribute => :title, :type => :too_short, length: 5) 69 | 70 | 71 | ## `include?` 72 | 73 | Same as Rails, provide the attribute name to see if that attribute has errors. 74 | 75 | ## `add`, `delete` 76 | 77 | Same as built-in counterparts. 78 | 79 | ## `import` 80 | 81 | For copying an error from inner model to outer model, such as form object. This ensures lazy message generation can still reference all information that the inner error has. 82 | 83 | ```ruby 84 | inner_model.errors.adequate.each do |error| 85 | errors.adequate.import(error) 86 | end 87 | ``` 88 | 89 | Attribute and type can be overriden in case when attribute does not exist in the outer model: 90 | 91 | ```ruby 92 | errors.adequate.import(error, attribute: :foo, type: :bar) 93 | ``` 94 | 95 | ## Message and I18n 96 | 97 | Error message strings reside under `adequate_errors` namespace. Unlike Rails, there is no global prefixing of attributes. Instead, `%{attribute}` is added into each error message when needed. 98 | 99 | ```yaml 100 | en: 101 | adequate_errors: 102 | messages: 103 | invalid: "%{attribute} is invalid" 104 | inclusion: "%{attribute} is not included in the list" 105 | exclusion: "%{attribute} is reserved" 106 | ``` 107 | 108 | This allows omission of attribute prefix. You no longer need to attach errors to `:base` for that purpose. 109 | 110 | Built-in Rails error types already have been prefixed out of the box, but error types from other gems have to be handled manually by copying entries to the `adequate_errors` namespace and prefixing with attributes. 111 | 112 | Error messages are evaluated lazily, which means it can be rendered in a different locale at view rendering time. 113 | 114 | 115 | ## `messages` 116 | 117 | Returns an array of all messages. 118 | 119 | model.errors.adequate.messages 120 | 121 | ## `messages_for` 122 | 123 | Returns an array of messages, filtered by conditions. Method argument is the same as `where`. 124 | 125 | model.errors.adequate.messages_for(:attribute => :title, :type => :too_short, length: 5) 126 | 127 | ## Full documentation 128 | 129 | http://www.rubydoc.info/github/lulalala/adequate_errors 130 | 131 | ## Note 132 | 133 | Calls to Rails' API are synced to AdequateErrors object, but not in reverse. Deprecated methods such as `[]=`, `get` and `set` are not sync'ed however. 134 | 135 | The gem is developed from ActiveModel 5.1, but it should work with earlier versions. 136 | 137 | ## We want to hear your issues too 138 | 139 | If you also have issues with exsting API, share it by filing that issue here. 140 | 141 | We collect use cases in issues and analyze the problem in wiki (publicly editable): 142 | 143 | [So come to our wiki, see what's going on, and join us!](https://github.com/lulalala/adequate_errors/wiki) 144 | 145 | --- 146 | 147 | This repo was called Rails Error API Redesign Initiative. 148 | This is a fan project and is not affiliated to Rails Core team, 149 | but my wish is that one day this can be adapted into Rails too. 150 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rake/testtask" 3 | 4 | dir = File.dirname(__FILE__) 5 | 6 | task default: :test 7 | 8 | task :package 9 | 10 | Rake::TestTask.new do |t| 11 | t.libs << "test" 12 | t.test_files = Dir.glob("#{dir}/test/cases/**/*_test.rb") 13 | t.warning = true 14 | t.verbose = true 15 | t.ruby_opts = ["--dev"] if defined?(JRUBY_VERSION) 16 | end 17 | 18 | namespace :test do 19 | task :isolated do 20 | Dir.glob("#{dir}/test/**/*_test.rb").all? do |file| 21 | sh(Gem.ruby, "-w", "-I#{dir}/lib", "-I#{dir}/test", file) 22 | end || raise("Failures") 23 | end 24 | end 25 | 26 | -------------------------------------------------------------------------------- /adequate_errors.gemspec: -------------------------------------------------------------------------------- 1 | 2 | lib = File.expand_path("../lib", __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require "adequate_errors/version" 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "adequate_errors" 8 | spec.version = AdequateErrors::VERSION 9 | spec.license = 'MIT' 10 | spec.authors = ["lulalala"] 11 | spec.email = ["mark@goodlife.tw"] 12 | 13 | spec.summary = %q{Overcoming limitation of Rails model errors API} 14 | spec.homepage = "https://github.com/lulalala/adequate_errors/" 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = "exe" 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ["lib"] 22 | 23 | spec.required_ruby_version = '>= 2.0.0' 24 | 25 | spec.add_dependency "activemodel" 26 | 27 | spec.add_development_dependency "bundler", "~> 1.16.a" 28 | spec.add_development_dependency "rake", "~> 10.0" 29 | spec.add_development_dependency "builder", "~> 3.2.3" 30 | spec.add_development_dependency "rspec", "~> 3.7" 31 | end 32 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "adequate_errors" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /lib/adequate_errors.rb: -------------------------------------------------------------------------------- 1 | require "adequate_errors/version" 2 | require "adequate_errors/interceptor" 3 | 4 | ActiveSupport.on_load(:i18n) do 5 | I18n.load_path << File.dirname(__FILE__) + "/adequate_errors/locale/en.yml" 6 | end 7 | -------------------------------------------------------------------------------- /lib/adequate_errors/error.rb: -------------------------------------------------------------------------------- 1 | module AdequateErrors 2 | # Represents one single error 3 | # @!attribute [r] base 4 | # @return [ActiveModel::Base] the object which the error belongs to 5 | # @!attribute [r] attribute 6 | # @return [Symbol] attribute of the object which the error belongs to 7 | # @!attribute [r] type 8 | # @return [Symbol] error's type 9 | # @!attribute [r] options 10 | # @return [Hash] additional options 11 | class Error 12 | def initialize(base, attribute, type, options = {}) 13 | @base = base 14 | @attribute = attribute 15 | @type = type 16 | @options = options 17 | end 18 | 19 | attr_reader :base, :attribute, :type, :options 20 | 21 | # Full message of the error. 22 | # 23 | # === Key differences to Rails vanilla errors 24 | # 25 | # ==== 1. Flexible positioning of attribute name interpolation 26 | # 27 | # In Rails, errors' full messages are always prefixed with attribute name, 28 | # and if prefix is not wanted, developer often adds error to the `base' attribute instead. 29 | # This can be unreasonable in different languages or special business requirements. 30 | # 31 | # AdequateErrors leaves the attribute placement to the developer. 32 | # For each error message in the locale file, the %{attribute} indicates placement of the attribute. 33 | # The same error can have prefix in English, and be prefix-less in Russian. 34 | # If no prefix is needed, one should update the locale file accordingly, 35 | # 36 | # ==== 2. Message evaluated lazily 37 | # 38 | # In Rails, error message is evaluated during the `add` call. 39 | # AdequateErrors evaluates message lazily at `message` call instead, 40 | # so one can change message locale after model has been validated. 41 | # 42 | # === Order of I18n lookup: 43 | # 44 | # Error messages are first looked up in activemodel.adequate_errors.models.MODEL.attributes.ATTRIBUTE.MESSAGE, 45 | # if it's not there, it's looked up in activemodel.adequate_errors.models.MODEL.MESSAGE and if 46 | # that is not there also, it returns the translation of the default message 47 | # (e.g. activemodel.errors.messages.MESSAGE). The translated model 48 | # name, translated attribute name and the value are available for 49 | # interpolation. 50 | # 51 | # When using inheritance in your models, it will check all the inherited 52 | # models too, but only if the model itself hasn't been found. Say you have 53 | # class Admin < User; end and you wanted the translation for 54 | # the :blank error message for the title attribute, 55 | # it looks for these translations: 56 | # 57 | # * activemodel.adequate_errors.models.admin.attributes.title.blank 58 | # * activemodel.adequate_errors.models.admin.blank 59 | # * activemodel.adequate_errors.models.user.attributes.title.blank 60 | # * activemodel.adequate_errors.models.user.blank 61 | # * any default you provided through the +options+ hash (in the activemodel.adequate_errors scope) 62 | # * activemodel.adequate_errors.messages.blank 63 | # * adequate_errors.attributes.title.blank 64 | # * adequate_errors.messages.blank 65 | def message 66 | if @options[:message].is_a?(Symbol) 67 | type = @options.delete(:message) 68 | else 69 | type = @type 70 | end 71 | 72 | if @base.class.respond_to?(:i18n_scope) 73 | i18n_scope = @base.class.i18n_scope.to_s 74 | defaults = @base.class.lookup_ancestors.flat_map do |klass| 75 | [ :"#{i18n_scope}.adequate_errors.models.#{klass.model_name.i18n_key}.attributes.#{attribute}.#{type}", 76 | :"#{i18n_scope}.adequate_errors.models.#{klass.model_name.i18n_key}.#{type}" ] 77 | end 78 | defaults << :"#{i18n_scope}.adequate_errors.messages.#{type}" 79 | else 80 | defaults = [] 81 | end 82 | 83 | defaults << :"adequate_errors.attributes.#{attribute}.#{type}" 84 | defaults << :"adequate_errors.messages.#{type}" 85 | 86 | key = defaults.shift 87 | defaults = @options.delete(:message) if @options[:message] 88 | value = (attribute != :base ? @base.send(:read_attribute_for_validation, attribute) : nil) 89 | 90 | i18n_options = { 91 | default: defaults, 92 | model: @base.model_name.human, 93 | attribute: humanized_attribute, 94 | value: value, 95 | object: @base, 96 | exception_handler: ->(exception, locale, key, option) { 97 | rails_errors = @base.errors 98 | rails_errors.full_message(@attribute, rails_errors.generate_message(@attribute, @type, @options)) 99 | } 100 | }.merge!(@options) 101 | 102 | I18n.translate(key, i18n_options) 103 | end 104 | 105 | # @param (see Errors#where) 106 | # @return [Boolean] whether error matches the params 107 | def match?(params) 108 | if params.key?(:attribute) && @attribute != params[:attribute] 109 | return false 110 | end 111 | 112 | if params.key?(:type) && @type != params[:type] 113 | return false 114 | end 115 | 116 | (params.keys - [:attribute, :type]).each do |key| 117 | if @options[key] != params[key] 118 | return false 119 | end 120 | end 121 | 122 | true 123 | end 124 | 125 | private 126 | 127 | def humanized_attribute 128 | default = @attribute.to_s.tr(".", "_").humanize 129 | @base.class.human_attribute_name(@attribute, default: default) 130 | end 131 | 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /lib/adequate_errors/errors.rb: -------------------------------------------------------------------------------- 1 | require 'active_model/errors' 2 | require 'forwardable' 3 | require 'adequate_errors/error' 4 | require 'adequate_errors/nested_error' 5 | 6 | module AdequateErrors 7 | # Collection of {Error} objects. 8 | # Provides convenience methods to access these errors. 9 | # It is accessed via +model.errors.adequate+ 10 | class Errors 11 | include Enumerable 12 | 13 | extend Forwardable 14 | def_delegators :@errors, :each, :size, :clear, :blank?, :empty?, *Enumerable.instance_methods(false) 15 | 16 | # @param base [ActiveModel::Base] 17 | def initialize(base) 18 | @base = base 19 | @errors = [] 20 | end 21 | 22 | # Delete errors of attribute 23 | def delete(attribute) 24 | @errors.delete_if do |error| 25 | error.attribute == attribute 26 | end 27 | end 28 | 29 | # Adds error. 30 | # More than one error can be added to the same `attribute`. 31 | # If no `type` is supplied, `:invalid` is assumed. 32 | # 33 | # @param attribute [Symbol] attribute that the error belongs to 34 | # @param type [Symbol] error's type, defaults to `:invalid`. 35 | # As convenience, if type is String/Proc/Lambda, 36 | # it will be moved to `options[:message]`, 37 | # and type itself will be changed to the default `:invalid`. 38 | # @param options [Hash] extra conditions such as interpolated value 39 | def add(attribute, type = :invalid, options = {}) 40 | 41 | if !type.is_a? Symbol 42 | options[:message] = type 43 | type = :invalid 44 | end 45 | 46 | @errors.append(::AdequateErrors::Error.new(@base, attribute, type, options)) 47 | end 48 | 49 | # Imports error 50 | # For copying nested model's errors back to base model. 51 | # The provided error will be wrapped, and its attribute/type will be copied across. 52 | # If attribute or type needs to be overriden, use `override_options`. 53 | # 54 | # @param override_options [Hash] 55 | # @option override_options [Symbol] :attribute Override the attribute the error belongs to 56 | # @option override_options [Symbol] :type Override type of the error. 57 | def import(error, override_options = {}) 58 | @errors.append(::AdequateErrors::NestedError.new(@base, error, override_options)) 59 | end 60 | 61 | # @return [Array(String)] all error messages 62 | def messages 63 | @errors.map(&:message) 64 | end 65 | 66 | # Convenience method to fetch error messages filtered by where condition. 67 | # @param params [Hash] filter condition, see {#where} for details. 68 | # @return [Array(String)] error messages 69 | def messages_for(params) 70 | where(params).map(&:message) 71 | end 72 | 73 | # @param params [Hash] 74 | # filter condition 75 | # The most common keys are +:attribute+ and +:type+, 76 | # but other custom keys given during {Errors#add} can also be used. 77 | # If params is empty, all errors are returned. 78 | # @option params [Symbol] :attribute Filtering on attribute the error belongs to 79 | # @option params [Symbol] :type Filter on type of error 80 | # 81 | # @return [Array(AdequateErrors::Error)] matching {Error}. 82 | def where(params) 83 | return @errors.dup if params.blank? 84 | 85 | @errors.select {|error| 86 | error.match?(params) 87 | } 88 | end 89 | 90 | # @return [Boolean] whether the given attribute contains error. 91 | def include?(attribute) 92 | @errors.any?{|error| error.attribute == attribute } 93 | end 94 | 95 | # @return [Hash] attributes with their error messages 96 | def to_hash 97 | hash = {} 98 | @errors.each do |error| 99 | if hash.has_key?(error.attribute) 100 | hash[error.attribute] << error.message 101 | else 102 | hash[error.attribute] = [error.message] 103 | end 104 | end 105 | hash 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /lib/adequate_errors/interceptor.rb: -------------------------------------------------------------------------------- 1 | require "active_model/errors" 2 | require "adequate_errors/errors" 3 | 4 | module AdequateErrors 5 | # Wraps around Rails' errors object, intercepting its state changes 6 | # in order to update AdequateErrors' errors object. 7 | module Interceptor 8 | def initialize(base) 9 | rails_errors = super 10 | @adequate_errors = ::AdequateErrors::Errors.new(base) 11 | rails_errors 12 | end 13 | 14 | def clear 15 | super 16 | @adequate_errors.clear 17 | end 18 | 19 | def delete(key) 20 | super 21 | @adequate_errors.delete(key) 22 | end 23 | 24 | def add(attribute, message = :invalid, options = {}) 25 | adequate_options = options.dup 26 | 27 | messages = super 28 | 29 | if options.has_key?(:message) && !options[:message].is_a?(Symbol) 30 | adequate_options[:message] = full_message(attribute, messages.last) 31 | end 32 | 33 | adequate_message = if !message.is_a?(Symbol) 34 | full_message(attribute, messages.last) 35 | else 36 | message 37 | end 38 | 39 | @adequate_errors.add(attribute, adequate_message, adequate_options) 40 | 41 | messages 42 | end 43 | 44 | # Accessor 45 | def adequate 46 | @adequate_errors 47 | end 48 | end 49 | end 50 | 51 | # @private 52 | module ActiveModel 53 | class Errors 54 | prepend AdequateErrors::Interceptor 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/adequate_errors/locale/en.yml: -------------------------------------------------------------------------------- 1 | en: 2 | adequate_errors: 3 | # The values :model, :attribute and :value are always available for interpolation 4 | # The value :count is available when applicable. Can be used for pluralization. 5 | messages: 6 | model_invalid: "Validation failed: %{errors}" 7 | inclusion: "%{attribute} is not included in the list" 8 | exclusion: "%{attribute} is reserved" 9 | invalid: "%{attribute} is invalid" 10 | confirmation: "%{attribute} doesn't match %{attribute}" 11 | accepted: "%{attribute} must be accepted" 12 | empty: "%{attribute} can't be empty" 13 | blank: "%{attribute} can't be blank" 14 | present: "%{attribute} must be blank" 15 | too_long: 16 | one: "%{attribute} is too long (maximum is 1 character)" 17 | other: "%{attribute} is too long (maximum is %{count} characters)" 18 | too_short: 19 | one: "%{attribute} is too short (minimum is 1 character)" 20 | other: "%{attribute} is too short (minimum is %{count} characters)" 21 | wrong_length: 22 | one: "%{attribute} is the wrong length (should be 1 character)" 23 | other: "%{attribute} is the wrong length (should be %{count} characters)" 24 | not_a_number: "%{attribute} is not a number" 25 | not_an_integer: "%{attribute} must be an integer" 26 | greater_than: "%{attribute} must be greater than %{count}" 27 | greater_than_or_equal_to: "%{attribute} must be greater than or equal to %{count}" 28 | equal_to: "%{attribute} must be equal to %{count}" 29 | less_than: "%{attribute} must be less than %{count}" 30 | less_than_or_equal_to: "%{attribute} must be less than or equal to %{count}" 31 | other_than: "%{attribute} must be other than %{count}" 32 | odd: "%{attribute} must be odd" 33 | even: "%{attribute} must be even" -------------------------------------------------------------------------------- /lib/adequate_errors/nested_error.rb: -------------------------------------------------------------------------------- 1 | module AdequateErrors 2 | # Represents one single error 3 | # @!attribute [r] base 4 | # @return [ActiveModel::Base] the object which the error belongs to 5 | # @!attribute [r] attribute 6 | # @return [Symbol] attribute of the object which the error belongs to 7 | # @!attribute [r] type 8 | # @return [Symbol] error's type 9 | # @!attribute [r] options 10 | # @return [Hash] additional options 11 | # @!attribute [r] inner_error 12 | # @return [Error] inner error 13 | class NestedError < Error 14 | def initialize(base, inner_error, override_options = {}) 15 | @base = base 16 | @inner_error = inner_error 17 | @attribute = override_options.fetch(:attribute) { inner_error.attribute } 18 | @type = override_options.fetch(:type) { inner_error.type } 19 | @options = inner_error.options 20 | end 21 | 22 | attr_reader :inner_error 23 | 24 | # Full message of the error. 25 | # Sourced from inner error. 26 | def message 27 | @inner_error.message 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/adequate_errors/version.rb: -------------------------------------------------------------------------------- 1 | module AdequateErrors 2 | VERSION = "0.1.2" 3 | end 4 | -------------------------------------------------------------------------------- /test/cases/adequate_errors/error_test.rb: -------------------------------------------------------------------------------- 1 | require "cases/helper" 2 | require "minitest/autorun" 3 | require 'adequate_errors' 4 | require "models/topic" 5 | 6 | describe AdequateErrors::Error do 7 | subject { AdequateErrors::Error.new(self, :mineral, :not_enough, count: 2) } 8 | 9 | describe '#initialize' do 10 | it 'assigns attributes' do 11 | assert_equal self, subject.base 12 | assert_equal :mineral, subject.attribute 13 | assert_equal :not_enough, subject.type 14 | assert_equal({count: 2}, subject.options) 15 | end 16 | end 17 | 18 | describe '#match?' do 19 | it 'handles mixed condition' do 20 | assert_equal false,subject.match?(:attribute => :mineral, :type => :too_coarse) 21 | assert_equal true,subject.match?(:attribute => :mineral, :type => :not_enough) 22 | assert_equal true,subject.match?(:attribute => :mineral, :type => :not_enough, count: 2) 23 | assert_equal false,subject.match?(:attribute => :mineral, :type => :not_enough, count: 1) 24 | end 25 | 26 | it 'handles attribute match' do 27 | assert_equal false,subject.match?(:attribute => :foo) 28 | assert_equal true,subject.match?(:attribute => :mineral) 29 | end 30 | 31 | it 'handles error type match' do 32 | assert_equal false,subject.match?(:type => :too_coarse) 33 | assert_equal true,subject.match?(:type => :not_enough) 34 | end 35 | 36 | it 'handles extra options match' do 37 | assert_equal false,subject.match?(:count => 1) 38 | assert_equal true,subject.match?(:count => 2) 39 | end 40 | end 41 | 42 | describe '#message' do 43 | let(:model) { Topic.new } 44 | 45 | it 'returns message' do 46 | subject = AdequateErrors::Error.new(model, :title, :inclusion,value: 'title') 47 | assert_equal "Title is not included in the list", subject.message 48 | end 49 | 50 | it 'returns custom message with interpolation' do 51 | subject = AdequateErrors::Error.new(model, :title, :inclusion,message: "custom message %{value}", value: "title") 52 | assert_equal "custom message title", subject.message 53 | end 54 | 55 | it 'returns plural interpolation' do 56 | subject = AdequateErrors::Error.new(model, :title, :too_long, count: 10) 57 | assert_equal "Title is too long (maximum is 10 characters)", subject.message 58 | end 59 | 60 | it 'returns singular interpolation' do 61 | subject = AdequateErrors::Error.new(model, :title, :too_long, count: 1) 62 | assert_equal "Title is too long (maximum is 1 character)", subject.message 63 | end 64 | 65 | it 'returns count interpolation' do 66 | subject = AdequateErrors::Error.new(model, :title, :too_long, message: "custom message %{count}", count: 10) 67 | assert_equal "custom message 10", subject.message 68 | end 69 | 70 | it 'renders lazily using current locale' do 71 | I18n.backend.store_translations(:pl,{adequate_errors: {messages: {invalid: "%{attribute} jest nieprawidłowe"}}}) 72 | 73 | I18n.with_locale(:en) { model.errors.adequate.add(:title, :invalid) } 74 | I18n.with_locale(:pl) { 75 | assert_equal 'Title jest nieprawidłowe', model.errors.adequate.first.message 76 | } 77 | end 78 | 79 | it 'uses current locale' do 80 | I18n.backend.store_translations(:en,{adequate_errors: {messages: {inadequate: "Inadequate %{attribute} found!"}}}) 81 | model.errors.adequate.add(:title, :inadequate) 82 | assert_equal 'Inadequate Title found!', model.errors.adequate.first.message 83 | end 84 | 85 | it 'handles lambda in messages and option values, and i18n interpolation' do 86 | subject = AdequateErrors::Error.new(model, :title, :invalid, 87 | foo: 'foo', 88 | bar: 'bar', 89 | baz: Proc.new {'baz'}, 90 | message: Proc.new { |model, options| 91 | "%{attribute} %{foo} #{options[:bar]} %{baz}" 92 | } 93 | ) 94 | assert_equal "Title foo bar baz", subject.message 95 | end 96 | 97 | it 'falls back to Rails message if translation can not be found' do 98 | I18n.backend.store_translations(:en,{errors: {messages: {too_much_water: "has too much water"}}}) 99 | 100 | subject = AdequateErrors::Error.new(model, :title, :too_much_water) 101 | assert_equal "Title has too much water", subject.message 102 | end 103 | end 104 | end -------------------------------------------------------------------------------- /test/cases/adequate_errors/errors_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "active_model" 3 | require "models/topic" 4 | require 'adequate_errors' 5 | 6 | describe AdequateErrors::Errors do 7 | let(:model) { Topic.new } 8 | let(:rails_errors) { ActiveModel::Errors.new(model) } 9 | subject { AdequateErrors::Errors.new(model) } 10 | 11 | describe '#add' do 12 | it 'assigns attributes' do 13 | assert_equal 0, subject.size 14 | 15 | subject.add(:title, :not_attractive) 16 | 17 | assert_equal 1, subject.size 18 | assert_equal :title, subject.first.attribute 19 | assert_equal :not_attractive, subject.first.type 20 | end 21 | 22 | describe 'Proc provided' do 23 | it 'is moved to options' do 24 | proc = Proc.new { 'foo %{bar}' } 25 | subject.add(:title, proc, bar: 'bar') 26 | error = subject.first 27 | assert_equal :title, error.attribute 28 | assert_equal :invalid, error.type 29 | assert_equal proc, error.options[:message] 30 | assert_equal 'foo bar', error.message 31 | end 32 | end 33 | 34 | describe 'String provided' do 35 | it 'moves it to options' do 36 | text = 'too outdated' 37 | subject.add(:title, text) 38 | error = subject.first 39 | assert_equal :title, error.attribute 40 | assert_equal :invalid, error.type 41 | assert_equal text, error.options[:message] 42 | end 43 | end 44 | end 45 | 46 | describe '#import' do 47 | let(:inner_error) { AdequateErrors::Error.new(model, :foo, :not_attractive) } 48 | 49 | it 'creates a NestedError' do 50 | assert_equal 0, subject.size 51 | 52 | subject.import(inner_error) 53 | 54 | assert_equal 1, subject.size 55 | error = subject.first 56 | assert_equal :foo, error.attribute 57 | assert_equal :not_attractive, error.type 58 | assert_equal AdequateErrors::NestedError, error.class 59 | assert_equal inner_error, error.inner_error 60 | end 61 | end 62 | 63 | describe '#delete' do 64 | it 'assigns attributes' do 65 | subject.add(:title, :not_attractive) 66 | subject.add(:title, :not_provocative) 67 | subject.add(:content, :too_vague) 68 | 69 | subject.delete(:title) 70 | 71 | assert_equal 1, subject.size 72 | end 73 | end 74 | 75 | describe '#clear' do 76 | it 'assigns attributes' do 77 | subject.add(:title, :not_attractive) 78 | subject.add(:content, :too_vague) 79 | 80 | subject.clear 81 | 82 | assert_equal 0, subject.size 83 | end 84 | end 85 | 86 | describe '#blank?' do 87 | it 'returns true when empty' do 88 | assert_equal true, subject.blank? 89 | end 90 | 91 | it 'returns false when error is present' do 92 | subject.add(:title, :not_attractive) 93 | assert_equal false, subject.blank? 94 | end 95 | end 96 | 97 | describe '#empty?' do 98 | it 'returns true when empty' do 99 | assert_equal true, subject.empty? 100 | end 101 | 102 | it 'returns false when error is present' do 103 | subject.add(:title, :not_attractive) 104 | assert_equal false, subject.empty? 105 | end 106 | end 107 | 108 | describe '#where' do 109 | describe 'attribute' do 110 | it '' do 111 | subject.add(:title, :not_attractive) 112 | subject.add(:content, :too_vague) 113 | 114 | assert_equal 0,subject.where(:attribute => :foo).size 115 | assert_equal 1,subject.where(:attribute => :title).size 116 | assert_equal 1,subject.where(:attribute => :title, :type => :not_attractive).size 117 | assert_equal 0,subject.where(:attribute => :title, :type => :too_vague).size 118 | assert_equal 1,subject.where(:type => :too_vague).size 119 | end 120 | end 121 | end 122 | 123 | describe '#messages' do 124 | it 'returns an array of messages' do 125 | subject.add(:title, :invalid) 126 | subject.add(:content, :too_short, count: 5) 127 | 128 | assert_equal ["Title is invalid", "Content is too short (minimum is 5 characters)"], subject.messages 129 | end 130 | 131 | it 'returns empty array if no error exists' do 132 | assert_equal [], subject.messages 133 | end 134 | end 135 | 136 | describe '#messages_for' do 137 | it 'returns message of the match' do 138 | subject.add(:content, :too_short, count: 5) 139 | 140 | assert_equal 0, subject.messages_for(:attribute => :title).size 141 | assert_equal 1, subject.messages_for(:attribute => :content).size 142 | assert_equal 1, subject.messages_for(:attribute => :content, count: 5).size 143 | assert_equal 0, subject.messages_for(:attribute => :content, count: 0).size 144 | end 145 | end 146 | 147 | describe '#include?' do 148 | it 'returns true if attribute has error' do 149 | subject.add(:title, :invalid) 150 | assert subject.include?(:title) 151 | end 152 | 153 | it 'returns false if attribute has no error' do 154 | subject.add(:title, :invalid) 155 | assert !subject.include?(:content) 156 | end 157 | end 158 | 159 | describe '#to_hash' do 160 | it 'returns hash containing messages' do 161 | subject.add(:title, :invalid) 162 | subject.add(:content, :too_short, count: 5) 163 | 164 | assert_equal({title: ['Title is invalid'], content: ["Content is too short (minimum is 5 characters)"]}, subject.to_hash) 165 | end 166 | 167 | it 'returns empty hash' do 168 | assert_equal({}, subject.to_hash) 169 | end 170 | end 171 | end -------------------------------------------------------------------------------- /test/cases/adequate_errors/interceptor_test.rb: -------------------------------------------------------------------------------- 1 | require "minitest/autorun" 2 | require "active_model" 3 | require "models/topic" 4 | require 'adequate_errors' 5 | 6 | describe AdequateErrors::Interceptor do 7 | let(:model) { Topic.new } 8 | let(:adequate_errors) { model.errors.adequate } 9 | 10 | describe '#initialize' do 11 | it 'initialize adequate_errors object' do 12 | assert_equal ActiveModel::Errors, model.errors.class 13 | end 14 | end 15 | 16 | describe '#adequate' do 17 | it 'returns adequate_errors object' do 18 | assert_equal AdequateErrors::Errors, model.errors.adequate.class 19 | end 20 | end 21 | 22 | describe '#add' do 23 | it 'assigns attributes' do 24 | assert_equal 0,model.errors.count 25 | assert_equal 0, model.errors.details.count 26 | 27 | return_value = model.errors.add(:title, :not_attractive) 28 | 29 | assert_equal 1, model.errors.size 30 | assert_equal 1, model.errors.details.count 31 | assert_equal [{:error=>:not_attractive}], model.errors.details[:title] 32 | 33 | assert_equal 1, adequate_errors.size 34 | assert_equal :title, adequate_errors.first.attribute 35 | assert_equal :not_attractive, adequate_errors.first.type 36 | assert_equal model, adequate_errors.first.base 37 | 38 | assert(return_value.all? { |v| v.is_a? String }) 39 | end 40 | 41 | it 'creates full message for AdequateErrors when message is a symbol' do 42 | model.errors.add(:title, :invalid) 43 | 44 | assert_equal 'Title is invalid', adequate_errors.first.message 45 | end 46 | 47 | it 'creates full message for AdequateErrors when message is a String' do 48 | model.errors.add(:title, "not informative") 49 | 50 | assert_equal 'Title not informative', adequate_errors.first.message 51 | end 52 | 53 | it 'creates full message for AdequateErrors when options message is provided' do 54 | model.errors.add(:title, :not_attractive, message:"not attractive yo") 55 | 56 | assert_equal 'Title not attractive yo', adequate_errors.first.message 57 | assert_equal :not_attractive, adequate_errors.first.type 58 | end 59 | end 60 | 61 | describe '#delete' do 62 | it 'assigns attributes' do 63 | model.errors.add(:title, :not_attractive) 64 | model.errors.add(:title, :not_provocative) 65 | model.errors.add(:content, :too_vague) 66 | 67 | model.errors.delete(:title) 68 | 69 | assert_equal 1, model.errors.size 70 | assert_equal 1, model.errors.details.count 71 | assert_equal [], model.errors.details[:title] 72 | 73 | assert_equal 1, adequate_errors.size 74 | assert_equal :content, adequate_errors.first.attribute 75 | end 76 | end 77 | 78 | describe '#clear' do 79 | it 'assigns attributes' do 80 | model.errors.add(:title, :not_attractive) 81 | model.errors.add(:content, :too_vague) 82 | 83 | model.errors.clear 84 | 85 | assert_equal 0, model.errors.size 86 | assert_equal 0, model.errors.details.count 87 | assert_equal [], model.errors.details[:title] 88 | 89 | assert_equal 0, adequate_errors.size 90 | end 91 | end 92 | end -------------------------------------------------------------------------------- /test/cases/adequate_errors/nested_error_test.rb: -------------------------------------------------------------------------------- 1 | require "cases/helper" 2 | require "minitest/autorun" 3 | require 'rspec/mocks/minitest_integration' 4 | require 'adequate_errors' 5 | require "models/topic" 6 | require "models/reply" 7 | 8 | describe AdequateErrors::NestedError do 9 | let(:topic) { Topic.new } 10 | let(:inner_error) { AdequateErrors::Error.new(topic, :mineral, :not_enough, count: 2) } 11 | 12 | let(:reply) { Reply.new } 13 | subject { AdequateErrors::NestedError.new(reply, inner_error) } 14 | 15 | describe '#initialize' do 16 | it 'assigns attributes' do 17 | assert_equal reply, subject.base 18 | assert_equal inner_error.attribute, subject.attribute 19 | assert_equal inner_error.type, subject.type 20 | assert_equal(inner_error.options, subject.options) 21 | end 22 | 23 | describe 'overriding attribute and type' do 24 | subject { AdequateErrors::NestedError.new(reply, inner_error, attribute: :parent, type: :foo) } 25 | 26 | it 'assigns attributes' do 27 | assert_equal reply, subject.base 28 | assert_equal :parent, subject.attribute 29 | assert_equal :foo, subject.type 30 | assert_equal(inner_error.options, subject.options) 31 | end 32 | end 33 | end 34 | 35 | describe '#message' do 36 | it "return inner error's message" do 37 | expect(inner_error).to receive(:message) 38 | subject.message 39 | end 40 | end 41 | end -------------------------------------------------------------------------------- /test/cases/helper.rb: -------------------------------------------------------------------------------- 1 | require "active_model" 2 | 3 | # Show backtraces for deprecated behavior for quicker cleanup. 4 | ActiveSupport::Deprecation.debug = true 5 | 6 | # Disable available locale checks to avoid warnings running the test suite. 7 | I18n.enforce_available_locales = false 8 | 9 | require "active_support/testing/autorun" 10 | require "active_support/testing/method_call_assertions" 11 | 12 | class ActiveModel::TestCase < ActiveSupport::TestCase 13 | include ActiveSupport::Testing::MethodCallAssertions 14 | 15 | # Skips the current run on Rubinius using Minitest::Assertions#skip 16 | private def rubinius_skip(message = "") 17 | skip message if RUBY_ENGINE == "rbx" 18 | end 19 | # Skips the current run on JRuby using Minitest::Assertions#skip 20 | private def jruby_skip(message = "") 21 | skip message if defined?(JRUBY_VERSION) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /test/cases/validations_test.rb: -------------------------------------------------------------------------------- 1 | require "cases/helper" 2 | 3 | require "adequate_errors" 4 | require "models/topic" 5 | require "models/reply" 6 | require "models/custom_reader" 7 | 8 | require "active_support/json" 9 | require "active_support/xml_mini" 10 | 11 | class ValidationsTest < ActiveModel::TestCase 12 | class CustomStrictValidationException < StandardError; end 13 | 14 | def teardown 15 | Topic.clear_validators! 16 | end 17 | 18 | def test_single_field_validation 19 | r = Reply.new 20 | r.title = "There's no content!" 21 | assert r.invalid?, "A reply without content should be invalid" 22 | assert r.after_validation_performed, "after_validation callback should be called" 23 | 24 | r.content = "Messa content!" 25 | assert r.valid?, "A reply with content should be valid" 26 | assert r.after_validation_performed, "after_validation callback should be called" 27 | end 28 | 29 | def test_single_attr_validation_and_error_msg 30 | r = Reply.new 31 | r.title = "There's no content!" 32 | assert r.invalid? 33 | assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid" 34 | assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error" 35 | assert_equal 1, r.errors.count 36 | end 37 | 38 | def test_double_attr_validation_and_error_msg 39 | r = Reply.new 40 | assert r.invalid? 41 | 42 | assert r.errors[:title].any?, "A reply without title should mark that attribute as invalid" 43 | assert_equal ["is Empty"], r.errors["title"], "A reply without title should contain an error" 44 | 45 | assert r.errors[:content].any?, "A reply without content should mark that attribute as invalid" 46 | assert_equal ["is Empty"], r.errors["content"], "A reply without content should contain an error" 47 | 48 | assert_equal 2, r.errors.count 49 | end 50 | 51 | def test_single_error_per_attr_iteration 52 | r = Reply.new 53 | r.valid? 54 | 55 | errors = r.errors.collect { |attr, messages| [attr.to_s, messages] } 56 | 57 | assert_includes errors, ["title", "is Empty"] 58 | assert_includes errors, ["content", "is Empty"] 59 | end 60 | 61 | def test_multiple_errors_per_attr_iteration_with_full_error_composition 62 | r = Reply.new 63 | r.title = "" 64 | r.content = "" 65 | r.valid? 66 | 67 | errors = r.errors.to_a 68 | 69 | assert_equal "Content is Empty", errors[0] 70 | assert_equal "Title is Empty", errors[1] 71 | assert_equal 2, r.errors.count 72 | end 73 | 74 | def test_errors_on_nested_attributes_expands_name 75 | t = Topic.new 76 | t.errors["replies.name"] << "can't be blank" 77 | assert_equal ["Replies name can't be blank"], t.errors.full_messages 78 | end 79 | 80 | def test_errors_on_base 81 | r = Reply.new 82 | r.content = "Mismatch" 83 | r.valid? 84 | r.errors.add(:base, "Reply is not dignifying") 85 | 86 | errors = r.errors.to_a.inject([]) { |result, error| result + [error] } 87 | 88 | assert_equal ["Reply is not dignifying"], r.errors[:base] 89 | 90 | assert_includes errors, "Title is Empty" 91 | assert_includes errors, "Reply is not dignifying" 92 | assert_equal 2, r.errors.count 93 | end 94 | 95 | def test_errors_on_base_with_symbol_message 96 | r = Reply.new 97 | r.content = "Mismatch" 98 | r.valid? 99 | r.errors.add(:base, :invalid) 100 | 101 | errors = r.errors.to_a.inject([]) { |result, error| result + [error] } 102 | 103 | assert_equal ["is invalid"], r.errors[:base] 104 | 105 | assert_includes errors, "Title is Empty" 106 | assert_includes errors, "is invalid" 107 | 108 | assert_equal 2, r.errors.count 109 | end 110 | 111 | def test_errors_empty_after_errors_on_check 112 | t = Topic.new 113 | assert t.errors[:id].empty? 114 | assert t.errors.empty? 115 | end 116 | 117 | def test_validates_each 118 | hits = 0 119 | Topic.validates_each(:title, :content, [:title, :content]) do |record, attr| 120 | record.errors.add attr, "gotcha" 121 | hits += 1 122 | end 123 | t = Topic.new("title" => "valid", "content" => "whatever") 124 | assert t.invalid? 125 | assert_equal 4, hits 126 | assert_equal %w(gotcha gotcha), t.errors[:title] 127 | assert_equal %w(gotcha gotcha), t.errors[:content] 128 | end 129 | 130 | def test_validates_each_custom_reader 131 | hits = 0 132 | CustomReader.validates_each(:title, :content, [:title, :content]) do |record, attr| 133 | record.errors.add attr, "gotcha" 134 | hits += 1 135 | end 136 | t = CustomReader.new("title" => "valid", "content" => "whatever") 137 | assert t.invalid? 138 | assert_equal 4, hits 139 | assert_equal %w(gotcha gotcha), t.errors[:title] 140 | assert_equal %w(gotcha gotcha), t.errors[:content] 141 | ensure 142 | CustomReader.clear_validators! 143 | end 144 | 145 | def test_validate_block 146 | Topic.validate { errors.add("title", "will never be valid") } 147 | t = Topic.new("title" => "Title", "content" => "whatever") 148 | assert t.invalid? 149 | assert t.errors[:title].any? 150 | assert_equal ["will never be valid"], t.errors["title"] 151 | end 152 | 153 | def test_validate_block_with_params 154 | Topic.validate { |topic| topic.errors.add("title", "will never be valid") } 155 | t = Topic.new("title" => "Title", "content" => "whatever") 156 | assert t.invalid? 157 | assert t.errors[:title].any? 158 | assert_equal ["will never be valid"], t.errors["title"] 159 | end 160 | 161 | def test_invalid_validator 162 | Topic.validate :i_dont_exist 163 | assert_raises(NoMethodError) do 164 | t = Topic.new 165 | t.valid? 166 | end 167 | end 168 | 169 | def test_invalid_options_to_validate 170 | error = assert_raises(ArgumentError) do 171 | # A common mistake -- we meant to call 'validates' 172 | Topic.validate :title, presence: true 173 | end 174 | message = "Unknown key: :presence. Valid keys are: :on, :if, :unless, :prepend. Perhaps you meant to call `validates` instead of `validate`?" 175 | assert_equal message, error.message 176 | end 177 | 178 | def test_callback_options_to_validate 179 | klass = Class.new(Topic) do 180 | attr_reader :call_sequence 181 | 182 | def initialize(*) 183 | super 184 | @call_sequence = [] 185 | end 186 | 187 | private 188 | def validator_a 189 | @call_sequence << :a 190 | end 191 | 192 | def validator_b 193 | @call_sequence << :b 194 | end 195 | 196 | def validator_c 197 | @call_sequence << :c 198 | end 199 | end 200 | 201 | assert_nothing_raised do 202 | klass.validate :validator_a, if: -> { true } 203 | klass.validate :validator_b, prepend: true 204 | klass.validate :validator_c, unless: -> { true } 205 | end 206 | 207 | t = klass.new 208 | 209 | assert_predicate t, :valid? 210 | assert_equal [:b, :a], t.call_sequence 211 | end 212 | 213 | def test_errors_conversions 214 | Topic.validates_presence_of %w(title content) 215 | t = Topic.new 216 | assert t.invalid? 217 | 218 | xml = t.errors.to_xml 219 | assert_match %r{}, xml 220 | assert_match %r{Title can't be blank}, xml 221 | assert_match %r{Content can't be blank}, xml 222 | 223 | hash = {} 224 | hash[:title] = ["can't be blank"] 225 | hash[:content] = ["can't be blank"] 226 | assert_equal t.errors.to_json, hash.to_json 227 | end 228 | 229 | def test_validation_order 230 | Topic.validates_presence_of :title 231 | Topic.validates_length_of :title, minimum: 2 232 | 233 | t = Topic.new("title" => "") 234 | assert t.invalid? 235 | assert_equal "can't be blank", t.errors["title"].first 236 | Topic.validates_presence_of :title, :author_name 237 | Topic.validate { errors.add("author_email_address", "will never be valid") } 238 | Topic.validates_length_of :title, :content, minimum: 2 239 | 240 | t = Topic.new title: "" 241 | assert t.invalid? 242 | 243 | assert_equal :title, key = t.errors.keys[0] 244 | assert_equal "can't be blank", t.errors[key][0] 245 | assert_equal "is too short (minimum is 2 characters)", t.errors[key][1] 246 | assert_equal :author_name, key = t.errors.keys[1] 247 | assert_equal "can't be blank", t.errors[key][0] 248 | assert_equal :author_email_address, key = t.errors.keys[2] 249 | assert_equal "will never be valid", t.errors[key][0] 250 | assert_equal :content, key = t.errors.keys[3] 251 | assert_equal "is too short (minimum is 2 characters)", t.errors[key][0] 252 | end 253 | 254 | def test_validation_with_if_and_on 255 | Topic.validates_presence_of :title, if: Proc.new { |x| x.author_name = "bad"; true }, on: :update 256 | 257 | t = Topic.new(title: "") 258 | 259 | # If block should not fire 260 | assert t.valid? 261 | assert t.author_name.nil? 262 | 263 | # If block should fire 264 | assert t.invalid?(:update) 265 | assert t.author_name == "bad" 266 | end 267 | 268 | def test_invalid_should_be_the_opposite_of_valid 269 | Topic.validates_presence_of :title 270 | 271 | t = Topic.new 272 | assert t.invalid? 273 | assert t.errors[:title].any? 274 | 275 | t.title = "Things are going to change" 276 | assert !t.invalid? 277 | end 278 | 279 | def test_validation_with_message_as_proc 280 | Topic.validates_presence_of(:title, message: proc { "no blanks here".upcase }) 281 | 282 | t = Topic.new 283 | assert t.invalid? 284 | assert_equal ["NO BLANKS HERE"], t.errors[:title] 285 | end 286 | 287 | def test_list_of_validators_for_model 288 | Topic.validates_presence_of :title 289 | Topic.validates_length_of :title, minimum: 2 290 | 291 | assert_equal 2, Topic.validators.count 292 | assert_equal [:presence, :length], Topic.validators.map(&:kind) 293 | end 294 | 295 | def test_list_of_validators_on_an_attribute 296 | Topic.validates_presence_of :title, :content 297 | Topic.validates_length_of :title, minimum: 2 298 | 299 | assert_equal 2, Topic.validators_on(:title).count 300 | assert_equal [:presence, :length], Topic.validators_on(:title).map(&:kind) 301 | assert_equal 1, Topic.validators_on(:content).count 302 | assert_equal [:presence], Topic.validators_on(:content).map(&:kind) 303 | end 304 | 305 | def test_accessing_instance_of_validator_on_an_attribute 306 | Topic.validates_length_of :title, minimum: 10 307 | assert_equal 10, Topic.validators_on(:title).first.options[:minimum] 308 | end 309 | 310 | def test_list_of_validators_on_multiple_attributes 311 | Topic.validates :title, length: { minimum: 10 } 312 | Topic.validates :author_name, presence: true, format: /a/ 313 | 314 | validators = Topic.validators_on(:title, :author_name) 315 | 316 | assert_equal [ 317 | ActiveModel::Validations::FormatValidator, 318 | ActiveModel::Validations::LengthValidator, 319 | ActiveModel::Validations::PresenceValidator 320 | ], validators.map(&:class).sort_by(&:to_s) 321 | end 322 | 323 | def test_list_of_validators_will_be_empty_when_empty 324 | Topic.validates :title, length: { minimum: 10 } 325 | assert_equal [], Topic.validators_on(:author_name) 326 | end 327 | 328 | def test_validations_on_the_instance_level 329 | Topic.validates :title, :author_name, presence: true 330 | Topic.validates :content, length: { minimum: 10 } 331 | 332 | topic = Topic.new 333 | assert topic.invalid? 334 | assert_equal 3, topic.errors.size 335 | 336 | topic.title = "Some Title" 337 | topic.author_name = "Some Author" 338 | topic.content = "Some Content Whose Length is more than 10." 339 | assert topic.valid? 340 | end 341 | 342 | def test_validate 343 | Topic.validate do 344 | validates_presence_of :title, :author_name 345 | validates_length_of :content, minimum: 10 346 | end 347 | 348 | topic = Topic.new 349 | assert_empty topic.errors 350 | 351 | topic.validate 352 | assert_not_empty topic.errors 353 | end 354 | 355 | def test_validate_with_bang 356 | Topic.validates :title, presence: true 357 | 358 | assert_raise(ActiveModel::ValidationError) do 359 | Topic.new.validate! 360 | end 361 | end 362 | 363 | def test_validate_with_bang_and_context 364 | Topic.validates :title, presence: true, on: :context 365 | 366 | assert_raise(ActiveModel::ValidationError) do 367 | Topic.new.validate!(:context) 368 | end 369 | 370 | t = Topic.new(title: "Valid title") 371 | assert t.validate!(:context) 372 | end 373 | 374 | def test_strict_validation_in_validates 375 | Topic.validates :title, strict: true, presence: true 376 | assert_raises ActiveModel::StrictValidationFailed do 377 | Topic.new.valid? 378 | end 379 | end 380 | 381 | def test_strict_validation_not_fails 382 | Topic.validates :title, strict: true, presence: true 383 | assert Topic.new(title: "hello").valid? 384 | end 385 | 386 | def test_strict_validation_particular_validator 387 | Topic.validates :title, presence: { strict: true } 388 | assert_raises ActiveModel::StrictValidationFailed do 389 | Topic.new.valid? 390 | end 391 | end 392 | 393 | def test_strict_validation_in_custom_validator_helper 394 | Topic.validates_presence_of :title, strict: true 395 | assert_raises ActiveModel::StrictValidationFailed do 396 | Topic.new.valid? 397 | end 398 | end 399 | 400 | def test_strict_validation_custom_exception 401 | Topic.validates_presence_of :title, strict: CustomStrictValidationException 402 | assert_raises CustomStrictValidationException do 403 | Topic.new.valid? 404 | end 405 | end 406 | 407 | def test_validates_with_bang 408 | Topic.validates! :title, presence: true 409 | assert_raises ActiveModel::StrictValidationFailed do 410 | Topic.new.valid? 411 | end 412 | end 413 | 414 | def test_validates_with_false_hash_value 415 | Topic.validates :title, presence: false 416 | assert Topic.new.valid? 417 | end 418 | 419 | def test_strict_validation_error_message 420 | Topic.validates :title, strict: true, presence: true 421 | 422 | exception = assert_raises(ActiveModel::StrictValidationFailed) do 423 | Topic.new.valid? 424 | end 425 | assert_equal "Title can't be blank", exception.message 426 | end 427 | 428 | def test_does_not_modify_options_argument 429 | options = { presence: true } 430 | Topic.validates :title, options 431 | assert_equal({ presence: true }, options) 432 | end 433 | 434 | def test_dup_validity_is_independent 435 | Topic.validates_presence_of :title 436 | topic = Topic.new("title" => "Literature") 437 | topic.valid? 438 | 439 | duped = topic.dup 440 | duped.title = nil 441 | assert duped.invalid? 442 | 443 | topic.title = nil 444 | duped.title = "Mathematics" 445 | assert topic.invalid? 446 | assert duped.valid? 447 | end 448 | 449 | def test_validation_with_message_as_proc_that_takes_a_record_as_a_parameter 450 | Topic.validates_presence_of(:title, message: proc { |record| "You have failed me for the last time, #{record.author_name}." }) 451 | 452 | t = Topic.new(author_name: "Admiral") 453 | assert t.invalid? 454 | assert_equal ["You have failed me for the last time, Admiral."], t.errors[:title] 455 | end 456 | 457 | def test_validation_with_message_as_proc_that_takes_record_and_data_as_a_parameters 458 | Topic.validates_presence_of(:title, message: proc { |record, data| "#{data[:attribute]} is missing. You have failed me for the last time, #{record.author_name}." }) 459 | 460 | t = Topic.new(author_name: "Admiral") 461 | assert t.invalid? 462 | assert_equal ["Title is missing. You have failed me for the last time, Admiral."], t.errors[:title] 463 | end 464 | end 465 | -------------------------------------------------------------------------------- /test/models/custom_reader.rb: -------------------------------------------------------------------------------- 1 | class CustomReader 2 | include ActiveModel::Validations 3 | 4 | def initialize(data = {}) 5 | @data = data 6 | end 7 | 8 | def []=(key, value) 9 | @data[key] = value 10 | end 11 | 12 | def read_attribute_for_validation(key) 13 | @data[key] 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /test/models/reply.rb: -------------------------------------------------------------------------------- 1 | require "models/topic" 2 | 3 | class Reply < Topic 4 | validate :errors_on_empty_content 5 | validate :title_is_wrong_create, on: :create 6 | 7 | validate :check_empty_title 8 | validate :check_content_mismatch, on: :create 9 | validate :check_wrong_update, on: :update 10 | 11 | def check_empty_title 12 | errors[:title] << "is Empty" unless title && title.size > 0 13 | end 14 | 15 | def errors_on_empty_content 16 | errors[:content] << "is Empty" unless content && content.size > 0 17 | end 18 | 19 | def check_content_mismatch 20 | if title && content && content == "Mismatch" 21 | errors[:title] << "is Content Mismatch" 22 | end 23 | end 24 | 25 | def title_is_wrong_create 26 | errors[:title] << "is Wrong Create" if title && title == "Wrong Create" 27 | end 28 | 29 | def check_wrong_update 30 | errors[:title] << "is Wrong Update" if title && title == "Wrong Update" 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /test/models/topic.rb: -------------------------------------------------------------------------------- 1 | class Topic 2 | include ActiveModel::Validations 3 | include ActiveModel::Validations::Callbacks 4 | 5 | def self._validates_default_keys 6 | super | [ :message ] 7 | end 8 | 9 | attr_accessor :title, :author_name, :content, :approved, :created_at 10 | attr_accessor :after_validation_performed 11 | 12 | after_validation :perform_after_validation 13 | 14 | def initialize(attributes = {}) 15 | attributes.each do |key, value| 16 | send "#{key}=", value 17 | end 18 | end 19 | 20 | def condition_is_true 21 | true 22 | end 23 | 24 | def condition_is_true_but_its_not 25 | false 26 | end 27 | 28 | def perform_after_validation 29 | self.after_validation_performed = true 30 | end 31 | 32 | def my_validation 33 | errors.add :title, "is missing" unless title 34 | end 35 | 36 | def my_validation_with_arg(attr) 37 | errors.add attr, "is missing" unless send(attr) 38 | end 39 | end 40 | --------------------------------------------------------------------------------