From f672fdf9122e8422a854296b8176c9fa42dc6189 Mon Sep 17 00:00:00 2001 From: Chris Baker Date: Thu, 24 Feb 2011 16:26:24 -0800 Subject: [PATCH] range validator --- activemodel/lib/active_model/locale/en.yml | 3 + activemodel/lib/active_model/validations/range.rb | 73 ++++++++++++++++++++ .../cases/validations/range_validation_test.rb | 71 +++++++++++++++++++ activemodel/test/models/event.rb | 10 +++ 4 files changed, 157 insertions(+), 0 deletions(-) create mode 100644 activemodel/lib/active_model/validations/range.rb create mode 100644 activemodel/test/cases/validations/range_validation_test.rb create mode 100644 activemodel/test/models/event.rb diff --git a/activemodel/lib/active_model/locale/en.yml b/activemodel/lib/active_model/locale/en.yml index 44425b4..b80eb3c 100644 --- a/activemodel/lib/active_model/locale/en.yml +++ b/activemodel/lib/active_model/locale/en.yml @@ -25,3 +25,6 @@ en: less_than_or_equal_to: "must be less than or equal to %{count}" odd: "must be odd" even: "must be even" + not_a_range: "is not a range" + overlap: "overlaps" + no_overlap: "does not overlap" diff --git a/activemodel/lib/active_model/validations/range.rb b/activemodel/lib/active_model/validations/range.rb new file mode 100644 index 0000000..fe9a432 --- /dev/null +++ b/activemodel/lib/active_model/validations/range.rb @@ -0,0 +1,73 @@ +module ActiveModel + + # == Active Model Range Validator + module Validations + class RangeValidator < ActiveModel::EachValidator + OPTIONS = [:overlapping, :not_overlapping].freeze + + def check_validity! + options.each do |option, option_value| + next if option_value.is_a?(Symbol) || option_value.is_a?(Proc) + raise ArgumentError, ":#{option} must be a symbol or a proc" + end + end + + def validate_each(record, attribute, value) + unless value.is_a? Range + record.errors.add(attribute, :not_a_range) + return + end + + options.slice(*OPTIONS).each do |option, option_value| + other_records = retrieve_other_records(record, option_value) + + if option == :overlapping && other_records.blank? + record.errors.add(attribute, :no_overlap) + end + + other_records.each do |other_record| + overlap = value.overlaps? other_record.send(attribute) + + if option == :overlapping && !overlap + record.errors.add(attribute, :no_overlap) + elsif option == :not_overlapping && overlap + record.errors.add(attribute, :overlap) + end + end + end + end + + protected + + def retrieve_other_records(record, lookup) + if lookup.is_a?(Symbol) + other_records = record.send(lookup) + elsif lookup.is_a?(Proc) + other_records = lookup.call(record) + end + + responds_to_key = record.respond_to?(:to_key) && !record.to_key.blank? + + (other_records || []).reject do |other_record| + other_record.equal?(record) || (responds_to_key && other_record.to_key == record.to_key) + end + end + end + + module HelperMethods + # Validates that the specified attributes are valid ranges and, optionally, that they do + # or do not overlap with ranges in other models. Examples: + # + # validates :field, :range => true + # validates :field, :range => { :overlapping => Proc.new{ |record| record.other_records } } + # validates :field, :range => { :not_overlapping => :other_records } + # + # When passing a symbol to :overlapping or :not_overlapping, the object must respond_to that + # message with a (possibly empty) list of objects that have the same fields. + # + def validates_range_of(*attr_names) + validates_with RangeValidator, _merge_attributes(attr_names) + end + end + end +end diff --git a/activemodel/test/cases/validations/range_validation_test.rb b/activemodel/test/cases/validations/range_validation_test.rb new file mode 100644 index 0000000..90bb990 --- /dev/null +++ b/activemodel/test/cases/validations/range_validation_test.rb @@ -0,0 +1,71 @@ +require 'models/event' + +class RangeValidationTest < ActiveModel::TestCase + + def setup + @day1 = Date.civil(2011,1,1)...Date.civil(2011,1,2) + @day2 = Date.civil(2011,1,2)...Date.civil(2011,1,3) + end + + def teardown + Event.reset_callbacks(:validate) + end + + def test_default_validates_range_of + Event.validates_range_of :duration + e = Event.new + assert e.invalid? + assert_equal ["is not a range"], e.errors[:duration] + + e.duration = 0..1 + assert e.valid? + + e.duration = @day1 + assert e.valid? + end + + def test_validates_range_of_with_overlap + Event.validates_range_of :duration, :overlapping => :other_events + e = Event.new(:duration => @day1, :other_events => []) + assert e.invalid? + assert_equal ["does not overlap"], e.errors[:duration] + + e.other_events = [Event.new(:duration => @day2)] + assert e.invalid? + assert_equal ["does not overlap"], e.errors[:duration] + + e.other_events = [Event.new(:duration => @day1)] + assert e.valid? + end + + def test_validates_range_of_with_no_overlap + Event.validates_range_of :duration, :not_overlapping => :other_events + e = Event.new(:duration => @day1) + + e.other_events = nil + assert e.valid? + + e.other_events = [] + assert e.valid? + + e.other_events = [Event.new(:duration => @day2)] + assert e.valid? + + e.other_events = [Event.new(:duration => @day1)] + assert e.invalid? + assert_equal ["overlaps"], e.errors[:duration] + end + + def test_validates_range_of_with_no_overlap_ignoring_self + Event.validates_range_of :duration, :not_overlapping => :other_events + e = Event.new(:duration => @day1) + + e.other_events = [e] + assert e.valid? + + e.to_key = :key + e.other_events = [Event.new(:duration => @day1, :to_key => :key)] + assert e.valid? + end + +end \ No newline at end of file diff --git a/activemodel/test/models/event.rb b/activemodel/test/models/event.rb new file mode 100644 index 0000000..5154886 --- /dev/null +++ b/activemodel/test/models/event.rb @@ -0,0 +1,10 @@ +class Event + include ActiveModel::Validations + attr_accessor :duration, :other_events, :to_key + + def initialize(attributes = {}) + attributes.each do |key, value| + send "#{key}=", value + end + end +end -- 1.7.3.4