diff --git a/lib/validates_timeliness.rb b/lib/validates_timeliness.rb index 558e46d..2bc3aa5 100644 --- a/lib/validates_timeliness.rb +++ b/lib/validates_timeliness.rb @@ -1,5 +1,6 @@ -require 'validates_timeliness/validations' require 'validates_timeliness/formats' +require 'validates_timeliness/validator' +require 'validates_timeliness/validation_methods' require 'validates_timeliness/validate_timeliness_matcher' if ENV['RAILS_ENV'] == 'test' require 'validates_timeliness/active_record/attribute_methods' @@ -10,7 +11,7 @@ require 'validates_timeliness/core_ext/time' require 'validates_timeliness/core_ext/date' require 'validates_timeliness/core_ext/date_time' -ActiveRecord::Base.send(:include, ValidatesTimeliness::Validations) +ActiveRecord::Base.send(:include, ValidatesTimeliness::ValidationMethods) ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::AttributeMethods) ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::MultiparameterAttributes) ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::ActionView::InstanceTag) diff --git a/lib/validates_timeliness/validate_timeliness_matcher.rb b/lib/validates_timeliness/validate_timeliness_matcher.rb index cee4608..8c8ed8f 100644 --- a/lib/validates_timeliness/validate_timeliness_matcher.rb +++ b/lib/validates_timeliness/validate_timeliness_matcher.rb @@ -1,7 +1,7 @@ module Spec module Rails module Matchers - class ValidateTimeliness + class ValidateTimeliness def initialize(attribute, options) @expected, @options = attribute, options @@ -63,13 +63,13 @@ module Spec end def parse_and_cast(value) - value = ActiveRecord::Base.send(:timeliness_restriction_value, value, record, options[:type]) - cast_method = ActiveRecord::Base.send(:restriction_type_cast_method, options[:type]) + value = ValidatesTimeliness::Validator.send(:restriction_value, value, record, options[:type]) + cast_method = ValidatesTimeliness::Validator.send(:restriction_type_cast_method, options[:type]) value.send(cast_method) rescue nil end def error_messages - messages = ActiveRecord::Base.send(:timeliness_default_error_messages) + messages = ValidatesTimeliness::Validator.send(:mapped_default_error_messages) messages = messages.inject({}) {|h, (k, v)| h[k] = v.sub(' %s', ''); h } @options.reverse_merge!(messages) end @@ -83,7 +83,7 @@ module Spec pass end - def no_error_matching(value, match) + def no_error_matching(value, match) pass = !error_matching(value, match) @last_failure = "no error matching #{match.inspect} when value is #{format_value(value)}" unless pass pass @@ -91,7 +91,7 @@ module Spec def format_value(value) return value if value.is_a?(String) - value.strftime(ActiveRecord::Errors.date_time_error_value_formats[options[:type]]) + value.strftime(ValidatesTimeliness::Validator.date_time_error_value_formats[options[:type]]) end end @@ -110,10 +110,11 @@ module Spec validate_timeliness_of(attribute, options) end - private + private + def validate_timeliness_of(attribute, options={}) ValidateTimeliness.new(attribute, options) - end + end end - end + end end diff --git a/lib/validates_timeliness/validation_methods.rb b/lib/validates_timeliness/validation_methods.rb new file mode 100644 index 0000000..0c20605 --- /dev/null +++ b/lib/validates_timeliness/validation_methods.rb @@ -0,0 +1,96 @@ +module ValidatesTimeliness + module ValidationMethods + + # Error messages and error value formats added to AR defaults to allow + # global override. + def self.included(base) + base.extend ClassMethods + end + + module ClassMethods + + def parse_date_time(raw_value, type, strict=true) + return nil if raw_value.blank? + return raw_value if raw_value.acts_like?(:time) || raw_value.is_a?(Date) + + time_array = ValidatesTimeliness::Formats.parse(raw_value, type, strict) + raise if time_array.nil? + + # Rails dummy time date part is defined as 2000-01-01 + time_array[0..2] = 2000, 1, 1 if type == :time + + # Date.new enforces days per month, unlike Time + date = Date.new(*time_array[0..2]) unless type == :time + + return date if type == :date + + # Create time object which checks time part, and return time object + make_time(time_array) + rescue + nil + end + + # Use this validation to force validation of values and restrictions + # as dummy time + def validates_time(*attr_names) + configuration = attr_names.extract_options! + configuration[:type] = :time + validates_timeliness_of(attr_names, configuration) + end + + # Use this validation to force validation of values and restrictions + # as Date + def validates_date(*attr_names) + configuration = attr_names.extract_options! + configuration[:type] = :date + validates_timeliness_of(attr_names, configuration) + end + + # Use this validation to force validation of values and restrictions + # as Time/DateTime + def validates_datetime(*attr_names) + configuration = attr_names.extract_options! + configuration[:type] = :datetime + validates_timeliness_of(attr_names, configuration) + end + + private + + # The main validation method which can be used directly or called through + # the other specific type validation methods + def validates_timeliness_of(*attr_names) + configuration = attr_names.extract_options! + validator = ValidatesTimeliness::Validator.new(configuration) + + # bypass handling of allow_nil and allow_blank to validate raw value + configuration.delete(:allow_nil) + configuration.delete(:allow_blank) + validates_each(attr_names, configuration) do |record, attr_name, value| + raw_value = record.send("#{attr_name}_before_type_cast") + validator.evaluate(record, attr_name, raw_value) + end + end + + # Time.zone. Rails 2.0 should be default_timezone. + def make_time(time_array) + if Time.respond_to?(:zone) && time_zone_aware_attributes + Time.zone.local(*time_array) + else + begin + Time.send(::ActiveRecord::Base.default_timezone, *time_array) + rescue ArgumentError, TypeError + zone_offset = ::ActiveRecord::Base.default_timezone == :local ? DateTime.local_offset : 0 + time_array.pop # remove microseconds + DateTime.civil(*(time_array << zone_offset)) + end + end + end + + end + + def add_error(attr, message) + + end + + end +end diff --git a/lib/validates_timeliness/validations.rb b/lib/validates_timeliness/validations.rb deleted file mode 100644 index b183c1d..0000000 --- a/lib/validates_timeliness/validations.rb +++ /dev/null @@ -1,189 +0,0 @@ -module ValidatesTimeliness - # Adds ActiveRecord validation methods for date, time and datetime validation. - # The validity of values can be restricted to be before or after certain dates - # or times. - module Validations - - # Error messages and error value formats added to AR defaults to allow - # global override. - def self.included(base) - base.extend ClassMethods - - base.class_inheritable_accessor :ignore_datetime_restriction_errors - base.ignore_datetime_restriction_errors = false - - ::ActiveRecord::Errors.class_inheritable_accessor :date_time_error_value_formats - ::ActiveRecord::Errors.date_time_error_value_formats = { - :time => '%H:%M:%S', - :date => '%Y-%m-%d', - :datetime => '%Y-%m-%d %H:%M:%S' - } - - ::ActiveRecord::Errors.default_error_messages.update( - :invalid_date => "is not a valid date", - :invalid_time => "is not a valid time", - :invalid_datetime => "is not a valid datetime", - :before => "must be before %s", - :on_or_before => "must be on or before %s", - :after => "must be after %s", - :on_or_after => "must be on or after %s" - ) - end - - module ClassMethods - - def parse_date_time(raw_value, type, strict=true) - return nil if raw_value.blank? - return raw_value if raw_value.acts_like?(:time) || raw_value.is_a?(Date) - - time_array = ValidatesTimeliness::Formats.parse(raw_value, type, strict) - raise if time_array.nil? - - # Rails dummy time date part is defined as 2000-01-01 - time_array[0..2] = 2000, 1, 1 if type == :time - - # Date.new enforces days per month, unlike Time - date = Date.new(*time_array[0..2]) unless type == :time - - return date if type == :date - - # Create time object which checks time part, and return time object - make_time(time_array) - rescue - nil - end - - # The main validation method which can be used directly or called through - # the other specific type validation methods. - def validates_timeliness_of(*attr_names) - configuration = { :on => :save, :type => :datetime, :allow_nil => false, :allow_blank => false } - configuration.update(timeliness_default_error_messages) - configuration.update(attr_names.extract_options!) - - # we need to check raw value for blank or nil - allow_nil = configuration.delete(:allow_nil) - allow_blank = configuration.delete(:allow_blank) - - validates_each(attr_names, configuration) do |record, attr_name, value| - raw_value = record.send("#{attr_name}_before_type_cast") - - next if (raw_value.nil? && allow_nil) || (raw_value.blank? && allow_blank) - - record.errors.add(attr_name, configuration[:blank_message]) and next if raw_value.blank? - - column = record.column_for_attribute(attr_name) - begin - unless time = parse_date_time(raw_value, configuration[:type]) - record.errors.add(attr_name, configuration["invalid_#{configuration[:type]}_message".to_sym]) - next - end - - validate_timeliness_restrictions(record, attr_name, time, configuration) - rescue Exception => e - record.errors.add(attr_name, configuration["invalid_#{configuration[:type]}_message".to_sym]) - end - end - end - - # Use this validation to force validation of values and restrictions - # as dummy time - def validates_time(*attr_names) - configuration = attr_names.extract_options! - configuration[:type] = :time - validates_timeliness_of(attr_names, configuration) - end - - # Use this validation to force validation of values and restrictions - # as Date - def validates_date(*attr_names) - configuration = attr_names.extract_options! - configuration[:type] = :date - validates_timeliness_of(attr_names, configuration) - end - - # Use this validation to force validation of values and restrictions - # as Time/DateTime - def validates_datetime(*attr_names) - configuration = attr_names.extract_options! - configuration[:type] = :datetime - validates_timeliness_of(attr_names, configuration) - end - - private - - def timeliness_restriction_value(restriction, record, type) - case restriction - when Time, Date, DateTime - restriction - when Symbol - timeliness_restriction_value(record.send(restriction), record, type) - when Proc - timeliness_restriction_value(restriction.call(record), record, type) - else - parse_date_time(restriction, type, false) - end - end - - def restriction_type_cast_method(type) - case type - when :time then :to_dummy_time - when :date then :to_date - when :datetime then :to_time - end - end - - # Validate value against the temporal restrictions. Restriction values - # maybe of mixed type, so they are evaluated as a common type, which may - # require conversion. The type used is defined by validation type. - def validate_timeliness_restrictions(record, attr_name, value, configuration) - restriction_methods = {:before => '<', :after => '>', :on_or_before => '<=', :on_or_after => '>='} - - type_cast_method = restriction_type_cast_method(configuration[:type]) - - display = ::ActiveRecord::Errors.date_time_error_value_formats[configuration[:type]] - - value = value.send(type_cast_method) - - restriction_methods.each do |option, method| - next unless restriction = configuration[option] - begin - compare = timeliness_restriction_value(restriction, record, configuration[:type]) - - next if compare.nil? - - compare = compare.send(type_cast_method) - record.errors.add(attr_name, configuration["#{option}_message".to_sym] % compare.strftime(display)) unless value.send(method, compare) - rescue - record.errors.add(attr_name, "restriction '#{option}' value was invalid") unless self.ignore_datetime_restriction_errors - end - end - end - - # Map error message keys to *_message to merge with validation options - def timeliness_default_error_messages - defaults = ::ActiveRecord::Errors.default_error_messages.slice( - :blank, :invalid_date, :invalid_time, :invalid_datetime, :before, :on_or_before, :after, :on_or_after) - returning({}) do |messages| - defaults.each {|k, v| messages["#{k}_message".to_sym] = v } - end - end - - # Create time in correct timezone. For Rails 2.1 that is value in - # Time.zone. Rails 2.0 should be default_timezone. - def make_time(time_array) - if Time.respond_to?(:zone) && time_zone_aware_attributes - Time.zone.local(*time_array) - else - begin - Time.send(::ActiveRecord::Base.default_timezone, *time_array) - rescue ArgumentError, TypeError - zone_offset = ::ActiveRecord::Base.default_timezone == :local ? DateTime.local_offset : 0 - time_array.pop # remove microseconds - DateTime.civil(*(time_array << zone_offset)) - end - end - end - - end - end -end diff --git a/lib/validates_timeliness/validator.rb b/lib/validates_timeliness/validator.rb new file mode 100644 index 0000000..566aa42 --- /dev/null +++ b/lib/validates_timeliness/validator.rb @@ -0,0 +1,116 @@ +module ValidatesTimeliness + + # Adds ActiveRecord validation methods for date, time and datetime validation. + # The validity of values can be restricted to be before or after certain dates + # or times. + class Validator + attr_accessor :configuration + + cattr_accessor :ignore_datetime_restriction_errors + cattr_accessor :date_time_error_value_formats + cattr_accessor :default_error_messages + + @@ignore_datetime_restriction_errors = false + + @@date_time_error_value_formats = { + :time => '%H:%M:%S', + :date => '%Y-%m-%d', + :datetime => '%Y-%m-%d %H:%M:%S' + } + + @@default_error_messages = { + :empty => "cannot be empty", + :blank => "cannot be blank", + :invalid_date => "is not a valid date", + :invalid_time => "is not a valid time", + :invalid_datetime => "is not a valid datetime", + :before => "must be before %s", + :on_or_before => "must be on or before %s", + :after => "must be after %s", + :on_or_after => "must be on or after %s" + } + + def initialize(configuration) + defaults = { :on => :save, :type => :datetime, :allow_nil => false, :allow_blank => false } + defaults.update(self.class.mapped_default_error_messages) + @configuration = defaults.merge(configuration) + end + + # The main validation method which can be used directly or called through + # the other specific type validation methods. + def evaluate(record, attr_name, value) + return if (value.nil? && configuration[:allow_nil]) || (value.blank? && configuration[:allow_blank]) + + record.errors.add(attr_name, configuration[:blank_message]) and return if value.blank? + + begin + unless time = record.class.parse_date_time(value, configuration[:type]) + record.errors.add(attr_name, configuration["invalid_#{configuration[:type]}_message".to_sym]) + return + end + + validate_restrictions(record, attr_name, time) + rescue Exception => e + record.errors.add(attr_name, configuration["invalid_#{configuration[:type]}_message".to_sym]) + end + end + + private + + def self.restriction_value(restriction, record, type) + case restriction + when Time, Date, DateTime + restriction + when Symbol + restriction_value(record.send(restriction), record, type) + when Proc + restriction_value(restriction.call(record), record, type) + else + record.class.parse_date_time(restriction, type, false) + end + end + + def self.restriction_type_cast_method(type) + case type + when :time then :to_dummy_time + when :date then :to_date + when :datetime then :to_time + end + end + + # Validate value against the temporal restrictions. Restriction values + # maybe of mixed type, so they are evaluated as a common type, which may + # require conversion. The type used is defined by validation type. + def validate_restrictions(record, attr_name, value) + restriction_methods = {:before => '<', :after => '>', :on_or_before => '<=', :on_or_after => '>='} + + type_cast_method = self.class.restriction_type_cast_method(configuration[:type]) + + display = @@date_time_error_value_formats[configuration[:type]] + + value = value.send(type_cast_method) + + restriction_methods.each do |option, method| + next unless restriction = configuration[option] + begin + compare = self.class.restriction_value(restriction, record, configuration[:type]) + + next if compare.nil? + + compare = compare.send(type_cast_method) + record.errors.add(attr_name, configuration["#{option}_message".to_sym] % compare.strftime(display)) unless value.send(method, compare) + rescue + record.errors.add(attr_name, "restriction '#{option}' value was invalid") unless self.ignore_datetime_restriction_errors + end + end + end + + # Map error message keys to *_message to merge with validation options + def self.mapped_default_error_messages + returning({}) do |messages| + @@default_error_messages.each {|k, v| messages["#{k}_message".to_sym] = v } + end + end + + end +end diff --git a/spec/active_record/attribute_methods_spec.rb b/spec/active_record/attribute_methods_spec.rb index d6d582c..52e7413 100644 --- a/spec/active_record/attribute_methods_spec.rb +++ b/spec/active_record/attribute_methods_spec.rb @@ -2,7 +2,7 @@ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper') describe ValidatesTimeliness::ActiveRecord::AttributeMethods do include ValidatesTimeliness::ActiveRecord::AttributeMethods - include ValidatesTimeliness::Validations + include ValidatesTimeliness::ValidationMethods before do @person = Person.new diff --git a/spec/validations_spec.rb b/spec/validation_methods_spec.rb similarity index 90% rename from spec/validations_spec.rb rename to spec/validation_methods_spec.rb index bb1666f..781a8f9 100644 --- a/spec/validations_spec.rb +++ b/spec/validation_methods_spec.rb @@ -1,6 +1,6 @@ require File.expand_path(File.dirname(__FILE__) + '/spec_helper') -describe ValidatesTimeliness::Validations do +describe ValidatesTimeliness::ValidationMethods do attr_accessor :person before :all do @@ -46,37 +46,37 @@ describe ValidatesTimeliness::Validations do end end - describe "timeliness_restriction_value" do - it "should return Time object when restriction is Time object" do - restriction_value(Time.now, person, :datetime).should be_kind_of(Time) - end - - it "should return Time object when restriction is string" do - restriction_value("2007-01-01 12:00", person, :datetime).should be_kind_of(Time) - end - - it "should return Time object when restriction is method symbol which returns Time object" do - person.stub!(:datetime_attr).and_return(Time.now) - restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) - end - - it "should return Time object when restriction is method symbol which returns string" do - person.stub!(:datetime_attr).and_return("2007-01-01 12:00") - restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) - end - - it "should return Time object when restriction is proc which returns Time object" do - restriction_value(lambda { Time.now }, person, :datetime).should be_kind_of(Time) - end - - it "should return Time object when restriction is proc which returns string" do - restriction_value(lambda {"2007-01-01 12:00"}, person, :datetime).should be_kind_of(Time) - end - - def restriction_value(*args) - ActiveRecord::Base.send(:timeliness_restriction_value, *args) - end - end + # describe "timeliness_restriction_value" do + # it "should return Time object when restriction is Time object" do + # restriction_value(Time.now, person, :datetime).should be_kind_of(Time) + # end + # + # it "should return Time object when restriction is string" do + # restriction_value("2007-01-01 12:00", person, :datetime).should be_kind_of(Time) + # end + # + # it "should return Time object when restriction is method symbol which returns Time object" do + # person.stub!(:datetime_attr).and_return(Time.now) + # restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) + # end + # + # it "should return Time object when restriction is method symbol which returns string" do + # person.stub!(:datetime_attr).and_return("2007-01-01 12:00") + # restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) + # end + # + # it "should return Time object when restriction is proc which returns Time object" do + # restriction_value(lambda { Time.now }, person, :datetime).should be_kind_of(Time) + # end + # + # it "should return Time object when restriction is proc which returns string" do + # restriction_value(lambda {"2007-01-01 12:00"}, person, :datetime).should be_kind_of(Time) + # end + # + # def restriction_value(*args) + # ActiveRecord::Base.send(:timeliness_restriction_value, *args) + # end + # end describe "with no restrictions" do before :all do @@ -416,9 +416,9 @@ describe ValidatesTimeliness::Validations do describe "ignoring restriction errors" do before :all do + ValidatesTimeliness::Validator.ignore_datetime_restriction_errors = true class BadRestriction < Person validates_date :birth_date, :before => Proc.new { raise } - self.ignore_datetime_restriction_errors = true end end @@ -473,7 +473,7 @@ describe ValidatesTimeliness::Validations do validates_time :birth_time, :allow_blank => true, :after => '23:59:59' end - ActiveRecord::Errors.date_time_error_value_formats = { + ValidatesTimeliness::Validator.date_time_error_value_formats = { :time => '%H:%M %p', :date => '%d-%m-%Y', :datetime => '%d-%m-%Y %H:%M %p' diff --git a/spec/validator_spec.rb b/spec/validator_spec.rb new file mode 100644 index 0000000..54bcb9b --- /dev/null +++ b/spec/validator_spec.rb @@ -0,0 +1,42 @@ +require File.expand_path(File.dirname(__FILE__) + '/spec_helper') + +describe ValidatesTimeliness::Validator do + attr_accessor :person + + before :each do + @person = Person.new + end + + describe "timeliness_restriction_value" do + it "should return Time object when restriction is Time object" do + restriction_value(Time.now, person, :datetime).should be_kind_of(Time) + end + + it "should return Time object when restriction is string" do + restriction_value("2007-01-01 12:00", person, :datetime).should be_kind_of(Time) + end + + it "should return Time object when restriction is method symbol which returns Time object" do + person.stub!(:datetime_attr).and_return(Time.now) + restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) + end + + it "should return Time object when restriction is method symbol which returns string" do + person.stub!(:datetime_attr).and_return("2007-01-01 12:00") + restriction_value(:datetime_attr, person, :datetime).should be_kind_of(Time) + end + + it "should return Time object when restriction is proc which returns Time object" do + restriction_value(lambda { Time.now }, person, :datetime).should be_kind_of(Time) + end + + it "should return Time object when restriction is proc which returns string" do + restriction_value(lambda {"2007-01-01 12:00"}, person, :datetime).should be_kind_of(Time) + end + + def restriction_value(*args) + ValidatesTimeliness::Validator.send(:restriction_value, *args) + end + end + +end