├── .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 |
--------------------------------------------------------------------------------