From ef68d3e35cb58f9f491993eeec6e7de99442dd06 Mon Sep 17 00:00:00 2001 From: lulalala Date: Thu, 15 Mar 2018 16:13:18 +0800 Subject: Add ActiveModel::Error and NestedError Add initialize_dup to deep dup. Move proc eval and flexible message position out to Errors, because proc eval is needed for Errors#added? and Errors#delete --- activemodel/lib/active_model/error.rb | 60 +++++++++ activemodel/lib/active_model/errors.rb | 16 --- activemodel/lib/active_model/nested_error.rb | 33 +++++ activemodel/test/cases/error_test.rb | 174 +++++++++++++++++++++++++++ activemodel/test/cases/nested_error_test.rb | 54 +++++++++ 5 files changed, 321 insertions(+), 16 deletions(-) create mode 100644 activemodel/lib/active_model/error.rb create mode 100644 activemodel/lib/active_model/nested_error.rb create mode 100644 activemodel/test/cases/error_test.rb create mode 100644 activemodel/test/cases/nested_error_test.rb (limited to 'activemodel') diff --git a/activemodel/lib/active_model/error.rb b/activemodel/lib/active_model/error.rb new file mode 100644 index 0000000000..214a0b356d --- /dev/null +++ b/activemodel/lib/active_model/error.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module ActiveModel + # == Active \Model \Error + # + # Represents one single error + class Error + CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] + MESSAGE_OPTIONS = [:message] + + def initialize(base, attribute, type, **options) + @base = base + @attribute = attribute + @raw_type = type + @type = type || :invalid + @options = options + end + + def initialize_dup(other) + @attribute = @attribute.dup + @raw_type = @raw_type.dup + @type = @type.dup + @options = @options.deep_dup + end + + attr_reader :base, :attribute, :type, :raw_type, :options + + def message + case raw_type + when Symbol + base.errors.generate_message(attribute, raw_type, options.except(*CALLBACKS_OPTIONS)) + else + raw_type + end + end + + def detail + { error: raw_type }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)) + end + + def full_message + base.errors.full_message(attribute, message) + end + + # See if error matches provided +attribute+, +type+ and +options+. + def match?(attribute, type = nil, **options) + if @attribute != attribute || (type && @type != type) + return false + end + + options.each do |key, value| + if @options[key] != value + return false + end + end + + true + end + end +end diff --git a/activemodel/lib/active_model/errors.rb b/activemodel/lib/active_model/errors.rb index 3a692a3e64..7eff374ce3 100644 --- a/activemodel/lib/active_model/errors.rb +++ b/activemodel/lib/active_model/errors.rb @@ -59,9 +59,6 @@ module ActiveModel class Errors include Enumerable - CALLBACKS_OPTIONS = [:if, :unless, :on, :allow_nil, :allow_blank, :strict] - MESSAGE_OPTIONS = [:message] - class << self attr_accessor :i18n_customize_full_message # :nodoc: end @@ -532,19 +529,6 @@ module ActiveModel end private - def normalize_message(attribute, message, options) - case message - when Symbol - generate_message(attribute, message, options.except(*CALLBACKS_OPTIONS)) - else - message - end - end - - def normalize_detail(message, options) - { error: message }.merge(options.except(*CALLBACKS_OPTIONS + MESSAGE_OPTIONS)) - end - def without_default_proc(hash) hash.dup.tap do |new_h| new_h.default_proc = nil diff --git a/activemodel/lib/active_model/nested_error.rb b/activemodel/lib/active_model/nested_error.rb new file mode 100644 index 0000000000..b01447ac75 --- /dev/null +++ b/activemodel/lib/active_model/nested_error.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "active_model/error" +require "forwardable" + +module ActiveModel + # Represents one single error + # @!attribute [r] base + # @return [ActiveModel::Base] the object which the error belongs to + # @!attribute [r] attribute + # @return [Symbol] attribute of the object which the error belongs to + # @!attribute [r] type + # @return [Symbol] error's type + # @!attribute [r] options + # @return [Hash] additional options + # @!attribute [r] inner_error + # @return [Error] inner error + class NestedError < Error + def initialize(base, inner_error, override_options = {}) + @base = base + @inner_error = inner_error + @attribute = override_options.fetch(:attribute) { inner_error.attribute } + @type = override_options.fetch(:type) { inner_error.type } + @raw_type = inner_error.raw_type + @options = inner_error.options + end + + attr_reader :inner_error + + extend Forwardable + def_delegators :@inner_error, :full_message, :message + end +end diff --git a/activemodel/test/cases/error_test.rb b/activemodel/test/cases/error_test.rb new file mode 100644 index 0000000000..c87ab8b858 --- /dev/null +++ b/activemodel/test/cases/error_test.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_model/error" + +class ErrorTest < ActiveModel::TestCase + class Person + extend ActiveModel::Naming + def initialize + @errors = ActiveModel::Errors.new(self) + end + + attr_accessor :name, :age + attr_reader :errors + + def read_attribute_for_validation(attr) + send(attr) + end + + def self.human_attribute_name(attr, options = {}) + attr + end + + def self.lookup_ancestors + [self] + end + end + + def test_initialize + base = Person.new + error = ActiveModel::Error.new(base, :name, :too_long, foo: :bar) + assert_equal base, error.base + assert_equal :name, error.attribute + assert_equal :too_long, error.type + assert_equal({ foo: :bar }, error.options) + end + + test "initialize without type" do + error = ActiveModel::Error.new(Person.new, :name) + assert_equal :invalid, error.type + assert_equal({}, error.options) + end + + test "initialize without type but with options" do + options = { message: "bar" } + error = ActiveModel::Error.new(Person.new, :name, options) + assert_equal :invalid, error.type + assert_equal(options, error.options) + end + + # match? + + test "match? handles mixed condition" do + subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2) + assert_not subject.match?(:mineral, :too_coarse) + assert subject.match?(:mineral, :not_enough) + assert subject.match?(:mineral, :not_enough, count: 2) + assert_not subject.match?(:mineral, :not_enough, count: 1) + end + + test "match? handles attribute match" do + subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2) + assert_not subject.match?(:foo) + assert subject.match?(:mineral) + end + + test "match? handles error type match" do + subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2) + assert_not subject.match?(:mineral, :too_coarse) + assert subject.match?(:mineral, :not_enough) + end + + test "match? handles extra options match" do + subject = ActiveModel::Error.new(Person.new, :mineral, :not_enough, count: 2) + assert_not subject.match?(:mineral, :not_enough, count: 1) + assert subject.match?(:mineral, :not_enough, count: 2) + end + + # message + + test "message with type as a symbol" do + error = ActiveModel::Error.new(Person.new, :name, :blank) + assert_equal "can't be blank", error.message + end + + test "message with custom interpolation" do + subject = ActiveModel::Error.new(Person.new, :name, :inclusion, message: "custom message %{value}", value: "name") + assert_equal "custom message name", subject.message + end + + test "message returns plural interpolation" do + subject = ActiveModel::Error.new(Person.new, :name, :too_long, count: 10) + assert_equal "is too long (maximum is 10 characters)", subject.message + end + + test "message returns singular interpolation" do + subject = ActiveModel::Error.new(Person.new, :name, :too_long, count: 1) + assert_equal "is too long (maximum is 1 character)", subject.message + end + + test "message returns count interpolation" do + subject = ActiveModel::Error.new(Person.new, :name, :too_long, message: "custom message %{count}", count: 10) + assert_equal "custom message 10", subject.message + end + + test "message handles lambda in messages and option values, and i18n interpolation" do + subject = ActiveModel::Error.new(Person.new, :name, :invalid, + foo: "foo", + bar: "bar", + baz: Proc.new { "baz" }, + message: Proc.new { |model, options| + "%{attribute} %{foo} #{options[:bar]} %{baz}" + } + ) + assert_equal "name foo bar baz", subject.message + end + + test "generate_message works without i18n_scope" do + person = Person.new + error = ActiveModel::Error.new(person, :name, :blank) + assert_not_respond_to Person, :i18n_scope + assert_nothing_raised { + error.message + } + end + + test "message with type as custom message" do + error = ActiveModel::Error.new(Person.new, :name, message: "cannot be blank") + assert_equal "cannot be blank", error.message + end + + test "message with options[:message] as custom message" do + error = ActiveModel::Error.new(Person.new, :name, :blank, message: "cannot be blank") + assert_equal "cannot be blank", error.message + end + + test "message renders lazily using current locale" do + error = nil + + I18n.backend.store_translations(:pl, errors: { messages: { invalid: "jest nieprawidłowe" } }) + + I18n.with_locale(:en) { error = ActiveModel::Error.new(Person.new, :name, :invalid) } + I18n.with_locale(:pl) { + assert_equal "jest nieprawidłowe", error.message + } + end + + test "message uses current locale" do + I18n.backend.store_translations(:en, errors: { messages: { inadequate: "Inadequate %{attribute} found!" } }) + error = ActiveModel::Error.new(Person.new, :name, :inadequate) + assert_equal "Inadequate name found!", error.message + end + + # full_message + + test "full_message returns the given message when attribute is :base" do + error = ActiveModel::Error.new(Person.new, :base, message: "press the button") + assert_equal "press the button", error.full_message + end + + test "full_message returns the given message with the attribute name included" do + error = ActiveModel::Error.new(Person.new, :name, :blank) + assert_equal "name can't be blank", error.full_message + end + + test "full_message uses default format" do + error = ActiveModel::Error.new(Person.new, :name, message: "can't be blank") + + # Use a locale without errors.format + I18n.with_locale(:unknown) { + assert_equal "name can't be blank", error.full_message + } + end +end diff --git a/activemodel/test/cases/nested_error_test.rb b/activemodel/test/cases/nested_error_test.rb new file mode 100644 index 0000000000..5bad100da5 --- /dev/null +++ b/activemodel/test/cases/nested_error_test.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "cases/helper" +require "active_model/nested_error" +require "models/topic" +require "models/reply" + +class ErrorTest < ActiveModel::TestCase + def test_initialize + topic = Topic.new + inner_error = ActiveModel::Error.new(topic, :title, :not_enough, count: 2) + reply = Reply.new + error = ActiveModel::NestedError.new(reply, inner_error) + + assert_equal reply, error.base + assert_equal inner_error.attribute, error.attribute + assert_equal inner_error.type, error.type + assert_equal(inner_error.options, error.options) + end + + test "initialize with overriding attribute and type" do + topic = Topic.new + inner_error = ActiveModel::Error.new(topic, :title, :not_enough, count: 2) + reply = Reply.new + error = ActiveModel::NestedError.new(reply, inner_error, attribute: :parent, type: :foo) + + assert_equal reply, error.base + assert_equal :parent, error.attribute + assert_equal :foo, error.type + assert_equal(inner_error.options, error.options) + end + + def test_message + topic = Topic.new(author_name: "Bruce") + inner_error = ActiveModel::Error.new(topic, :title, :not_enough, message: Proc.new { |model, options| + "not good enough for #{model.author_name}" + }) + reply = Reply.new(author_name: "Mark") + error = ActiveModel::NestedError.new(reply, inner_error) + + assert_equal "not good enough for Bruce", error.message + end + + def test_full_message + topic = Topic.new(author_name: "Bruce") + inner_error = ActiveModel::Error.new(topic, :title, :not_enough, message: Proc.new { |model, options| + "not good enough for #{model.author_name}" + }) + reply = Reply.new(author_name: "Mark") + error = ActiveModel::NestedError.new(reply, inner_error) + + assert_equal "Title not good enough for Bruce", error.full_message + end +end -- cgit v1.2.3