From 5983622ac84df91edd8ea348c0c0403e0f268eb1 Mon Sep 17 00:00:00 2001 From: Adam Meehan Date: Sat, 19 Jul 2008 19:47:03 +1000 Subject: [PATCH] moved extact_date_time_values method in to Formats and added specs changed Formats to a class --- README | 223 ++++++++++++++++++++---- lib/validates_timeliness.rb | 2 + lib/validates_timeliness/formats.rb | 208 ++++++++++++---------- lib/validates_timeliness/validations.rb | 20 +-- spec/formats_spec.rb | 28 +++ 5 files changed, 337 insertions(+), 144 deletions(-) diff --git a/README b/README index ead6f63..3ed51cd 100644 --- a/README +++ b/README @@ -3,72 +3,231 @@ * Source: http://github.com/adzap/validates_timeliness * Tickets: http://adzap.lighthouseapp.com/projects/14111-validates_timeliness - -== DESCRIPTION +== DESCRIPTION: Validate dates, times and datetimes for Rails 2.x. Plays nicely with new features such as automatic timezone handling and dirty attributes. Allows date/time atttributes to behave like other attribute types by allowing you to review the raw entered value before it is converted. +Allows you add custom formats or remove defaults easily. You can also just +another date parser altogther in conjuction with the plugin. -== FEATURES/PROBLEMS + +== FEATURES: * Adds ActiveRecord validation for dates, times and datetimes -* Adds better transparency of date/time attributes restoring ability to view - raw value before type casting, which was lost in Rails 2.1 to get the better - timezone features. +* Add or remove date/time formats to customize validation +* Create new formats using very simple date/time format patterns + +* Adds better transparency of date/time attributes restoring ability to view + raw value before type casting, which was lost in Rails 2.1. + * Allows pluggable date and time parsing with other parsers of your choosing (eg Chronic) -* Respects timezone features of both Rails 2.0 and 2.1. +* Respects new timezone features of Rails 2.1. -== INSTALLATION + +== INSTALLATION: Rails 2.1 ./script/plugin git://github.com/adzap/validates_timeliness Rails 2.0 - + # TODO: best practice for git with Rails 2.0? -== USAGE + +== USAGE: To validate a model with a date, time or datetime attribute you just use the validation method class Person < ActiveRecord::Base - validates_timeliness_of :date_of_birth + validates_date :date_of_birth end -But for a more semantic validation method you can use the specific validation -for the attribute type. The type based validation methods are +The list of validation methods available are as follows: - * validates_date + * validates_date - validate value as date - * validates_time + * validates_time - validate value as time only i.e. '12:20pm' - * validates_datetime + * validates_datetime - validate value as a full date and time -If the value for a date attribute contains a time, it will be ignored when -validating. The same is true to time attributes which ignore the date. +The validation method take the usual options plus some specific ones to restrict +the valid range of dates or times allowed -The default valid formats for a date or datetime field are those supported by -the Ruby ParseDate library -(http://www.ruby-doc.org/docs/ProgrammingRuby/html/lib_standard.html#ParseDate.parsedate). + Temporal options: + + :before - Attribute must be before this value to be valid + :on_or_before - Attribute must be equal to or before this value to be valid + :after - Attribute must be after this value to be valid + :on_or_after - Attribute must be equal to or after this value to be valid + + Regular validation options + :allow_nil - Allow a nil value to be valid + :allow_blank - Allows a nil or empty string value to be valid -The validation method has the usual options plus some specific ones to restrict -the valid range +The temporal options can 4 different value types: - Options: - - allow_nil - - allow_blank - - before - - on_or_before - - after - - on_or_after - - type + * String date or time value + * Date, Time, or DateTime object value + * Proc or lambda object + * A symbol matching the method name in the model + +If a Time object value is compared to a date attribute using a temporal option, +both values are compared as dates. The rule is that the values are compared as +the same type as the validation method type. So validates_date means all values +are compared as dates. + +== EXAMPLES: + + validates_date :date_of_birth, :after => '1900-01-01' + + validates_date :date_of_birth, :on_or_after => '1900-01-01' + + validates_date :date_of_birth, :before => Proc.new { Time.now } # only part is used + + validates_time :breakfast_time, :before => '12:00pm' + + validates_time :breakfast_time, :on_or_after => '6:00am' + + validates_datetime :appointment_date, :before => Proc.new { 1.week.from_now } + + validates_datetime :appointment_date, :after => :last_appointment_date -MUCH MORE DOCS TO COME +== DATE/TIME FORMATS: + +So what formats does the plugin allow. Well there are default formats which can +be added to easily using the plugins format rules. Also formats can be easily +removed without hacking the plugin at all. + +Below are the default formats. If you think they are easy to read then you will +be happy to know that is exactly the format you can use to define your own if +you want. No regular expressions or hacking plugin methods. + + Time formats: + hh:nn:ss => 01:23:59 + hh-nn-ss => 01:23:59 + h:nn => 1:23 or 01:23 + h.nn => 1.23 or 01.23 + h nn => 1 23 or 01 23 + h-nn => 1-23 or 01-23 + h:nn_ampm => 1:23am or 1:23 am or 01:23am + h.nn_ampm + h nn_ampm + h-nn_ampm + h_ampm + + NOTE: Any time format without a ampm token or meridian is considered in 24 hour time. + + Date formats: + yyyy/mm/dd + yyyy-mm-dd + yyyy.mm.dd + m/d/yy + d/m/yy + m\d\yy + d\m\yy + d-m-yy + d.m.yy + d mmm yy + + Datetime formats: + m/d/yy h:nn:ss + m/d/yy h:nn + m/d/yy h:nn_ampm + d/m/yy hh:nn:ss + d/m/yy h:nn + d/m/yy h:nn_ampm + yyyy-mm-dd hh:nn:ss + yyyy-mm-dd h:nn + ddd mmm d hh:nn:ss zo yyyy # Ruby time string + yyyy-mm-ddThh:nn:ss(?:Z|zo) # ISO 8601 + +Here is what each format token means: + + Format tokens: + y = year + m = month + d = day + h = hour + n = minute + s = second + u = micro-seconds + ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.) + _ = optional space + tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST) + zo = Timezone offset (e.g. +10:00, -08:00, +1000) + +All other characters are considered literal. You can embed regular expressions +in the format but no gurantees that it will remain intact. If you avoid the use +of any token characters and regexp dots or backslashes as special characters +in the regexp, it may well work as expected. For special characters use +POSIX character clsses for safety. See the ISO 8601 datetime for en example of +of an embedded regular expression. + + Repeating tokens: + x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09') + xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09') + + Special Cases: + yy = 2 or 4 digit year + yyyyy = exactly 4 digit year + mmm = month long name (e.g. 'Jul' or 'July') + ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday) + u = microseconds matches 1 to 3 digits + +For the technically minded (well you are developers), these formats are compiled +into regular expressions at runtime so don't add any extra overhead than using +regular expressions directly. So, no, it won't make your app slow! + + +== CUSTOMISING FORMATS: + +I hear you say "Thats greats but I don't want X format to be valid". Well to +remove a format stick this in an initializer file or environment.rb + + ValidatesTimeliness::Formats.remove_formats(:date, 'm\d\yy') + +Done! That format is no longer considered valid. Easy! + +Ok, now I hear you say "Well I have format that I want to use but you don't have it". +Ahh, then add it yourself. Again stick this in an initializer file or environment.rb. + + ValidatesTimeliness::Formats.add_formats(:time, "d o'clock") + +Now '10 o'clock' will be a valid format. So easy, no more whingeing! + + +== EXTERNAL PARSER: + +I mentioned earlier that you could use a pluggable or alternative parser such +as Chronic instead the in built one. So you need some super fancy stuff that the +custom formats can't handle then be my guest and override it. This is an example +of using Chronis instead. Put this into a file in the lib directory. + + class ActiveRecord::Base + + def self.timeliness_date_time_parse(raw_value, type) + + end + + end + +== CREDITS: + +* Adam Meehan (http://duckpunching.com/) + +* Jonathan Viney (http://workingwithrails.com/person/4985-jonathan-viney) + For his validates_date_time plugin which I have used up till now and which + influenced some of the design and I borrowed a small amount of code from it. + + +== LICENSE: + +Copyright (c) 2008 Adam Meehan, released under the MIT license diff --git a/lib/validates_timeliness.rb b/lib/validates_timeliness.rb index 1912c92..ffcb573 100644 --- a/lib/validates_timeliness.rb +++ b/lib/validates_timeliness.rb @@ -17,3 +17,5 @@ ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::InstanceTag Time.send(:include, ValidatesTimeliness::CoreExtensions::Time) Date.send(:include, ValidatesTimeliness::CoreExtensions::Date) DateTime.send(:include, ValidatesTimeliness::CoreExtensions::DateTime) + +ValidatesTimeliness::Formats.compile_format_expressions diff --git a/lib/validates_timeliness/formats.rb b/lib/validates_timeliness/formats.rb index 62ba1f8..60c5987 100644 --- a/lib/validates_timeliness/formats.rb +++ b/lib/validates_timeliness/formats.rb @@ -10,17 +10,17 @@ module ValidatesTimeliness # Formats can be added or removed to customize the set of valid date or time # string values. # - module Formats - mattr_accessor :time_formats - mattr_accessor :date_formats - mattr_accessor :datetime_formats + class Formats + cattr_accessor :time_formats + cattr_accessor :date_formats + cattr_accessor :datetime_formats - mattr_accessor :time_expressions - mattr_accessor :date_expressions - mattr_accessor :datetime_expressions + cattr_accessor :time_expressions + cattr_accessor :date_expressions + cattr_accessor :datetime_expressions - mattr_accessor :format_tokens - mattr_accessor :format_proc_args + cattr_accessor :format_tokens + cattr_accessor :format_proc_args # Format tokens: # y = year @@ -33,7 +33,7 @@ module ValidatesTimeliness # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.) # _ = optional space # tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST) - # zo = Timezone offset (e.g. +10:00, -08:00) + # zo = Timezone offset (e.g. +10:00, -08:00, +1000) # # All other characters are considered literal. You can embed regexp in the # format but no gurantees that it will remain intact. If you avoid the use @@ -148,98 +148,118 @@ module ValidatesTimeliness :meridian => [nil, 'md', nil] } - # Compile formats into validation regexps and format procs - def self.format_expression_generator(string_format) - regexp = string_format.dup - order = {} - regexp.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes ]/ - - format_tokens.each do |token| - token_name = token.keys.first - token_regexp, regexp_str, arg_key = *token.values.first + class << self + + # Compile formats into validation regexps and format procs + def format_expression_generator(string_format) + regexp = string_format.dup + order = {} + regexp.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes ]/ - # hack for lack of look-behinds. If has a capture group then is - # considered an anchor to put straight back in the regexp string. - regexp.gsub!(token_regexp) {|m| "#{$1}" + regexp_str } - order[arg_key] = $~.begin(0) if $~ && !arg_key.nil? - end + format_tokens.each do |token| + token_name = token.keys.first + token_regexp, regexp_str, arg_key = *token.values.first + + # hack for lack of look-behinds. If has a capture group then is + # considered an anchor to put straight back in the regexp string. + regexp.gsub!(token_regexp) {|m| "#{$1}" + regexp_str } + order[arg_key] = $~.begin(0) if $~ && !arg_key.nil? + end - return Regexp.new(regexp), format_proc(order) - rescue - puts "The following format regular expression failed to compile: #{regexp}\n from format #{string_format}." - raise - end - - # Generates a proc which when executed maps the regexp capture groups to a - # proc argument based on order captured. A time array is built using the proc - # argument in the position indicated by the first element of the proc arg - # array. - # - # Examples: - # - # 'yyyy-mm-dd hh:nn' => lambda {|y,m,d,h,n| md||=0; [unambiguous_year(y),month_index(m),d,full_hour(h,md),n,nil,nil].map {|t| t.to_i unless t.nil? } } - # 'dd/mm/yyyy h:nn_ampm' => lambda {|d,m,y,h,n,md| md||=0; [unambiguous_year(y),month_index(m),d,full_hour(h,md),n,nil,nil].map {|t| t.to_i unless t.nil? } } - # - def self.format_proc(order) - arg_map = format_proc_args - args = order.invert.sort.map {|p| arg_map[p[1]][1] } - arr = [nil] * 7 - order.keys.each {|k| i = arg_map[k][0]; arr[i] = arg_map[k][2] unless i.nil? } - proc_string = "lambda {|#{args.join(',')}| md||=nil; [#{arr.map {|i| i.nil? ? 'nil' : i }.join(',')}].map {|t| t.to_i unless t.nil? } }" - eval proc_string - end - - def self.compile_formats(formats) - formats.collect { |format| - regexp, format_proc = format_expression_generator(format) - } - end - - def self.compile_format_expressions - @@time_expressions = compile_formats(@@time_formats) - @@date_expressions = compile_formats(@@date_formats) - @@datetime_expressions = compile_formats(@@datetime_formats) - end - - def self.remove_formats(type, *remove_formats) - remove_formats.each do |format| - unless self.send("#{type}_formats").delete(format) - raise "Format #{format} not found in #{type} formats" - end + return Regexp.new(regexp), format_proc(order) + rescue + puts "The following format regular expression failed to compile: #{regexp}\n from format #{string_format}." + raise end - compile_format_expressions - end - - def self.add_formats(type, *add_formats) - formats = self.send("#{type}_formats") - add_formats.each do |format| - if formats.include?(format) - raise "Format #{format} is already included in #{type} formats" + # Generates a proc which when executed maps the regexp capture groups to a + # proc argument based on order captured. A time array is built using the proc + # argument in the position indicated by the first element of the proc arg + # array. + # + # Examples: + # + # 'yyyy-mm-dd hh:nn' => lambda {|y,m,d,h,n| md||=0; [unambiguous_year(y),month_index(m),d,full_hour(h,md),n,nil,nil].map {|t| t.to_i unless t.nil? } } + # 'dd/mm/yyyy h:nn_ampm' => lambda {|d,m,y,h,n,md| md||=0; [unambiguous_year(y),month_index(m),d,full_hour(h,md),n,nil,nil].map {|t| t.to_i unless t.nil? } } + # + def format_proc(order) + arg_map = format_proc_args + args = order.invert.sort.map {|p| arg_map[p[1]][1] } + arr = [nil] * 7 + order.keys.each {|k| i = arg_map[k][0]; arr[i] = arg_map[k][2] unless i.nil? } + proc_string = "lambda {|#{args.join(',')}| md||=nil; [#{arr.map {|i| i.nil? ? 'nil' : i }.join(',')}].map {|t| t.to_i unless t.nil? } }" + eval proc_string + end + + def compile_formats(formats) + formats.collect { |format| + regexp, format_proc = format_expression_generator(format) + } + end + + def compile_format_expressions + @@time_expressions = compile_formats(@@time_formats) + @@date_expressions = compile_formats(@@date_formats) + @@datetime_expressions = compile_formats(@@datetime_formats) + end + + # Loop through format expressions for type and call proc on matches. Allow + # pre or post match strings to exist if strict is false. + # Returns 7 part datetime array. + def extract_date_time_values(time_string, type, strict=true) + expressions = self.send("#{type}_expressions") + time_array = nil + expressions.each do |(regexp, processor)| + regexp = strict || type == :datetime ? /\A#{regexp}\Z/ : (type == :date ? /\A#{regexp}\s?/ : /\s?#{regexp}\Z/) + if matches = regexp.match(time_string.strip) + time_array = processor.call(*matches[1..7]) + break + end end - formats << format + return time_array end - compile_format_expressions - end - - def self.full_hour(hour, meridian) - hour = hour.to_i - return hour if meridian.nil? - if meridian.delete('.').downcase == 'am' - hour == 12 ? 0 : hour - else - hour == 12 ? hour : hour + 12 + + def remove_formats(type, *remove_formats) + remove_formats.each do |format| + unless self.send("#{type}_formats").delete(format) + raise "Format #{format} not found in #{type} formats" + end + end + compile_format_expressions + end + + def add_formats(type, *add_formats) + formats = self.send("#{type}_formats") + + add_formats.each do |format| + if formats.include?(format) + raise "Format #{format} is already included in #{type} formats" + end + formats << format + end + compile_format_expressions + end + + def full_hour(hour, meridian) + hour = hour.to_i + return hour if meridian.nil? + if meridian.delete('.').downcase == 'am' + hour == 12 ? 0 : hour + else + hour == 12 ? hour : hour + 12 + end + end + + def unambiguous_year(year, threshold=30) + year = "#{year.to_i < threshold ? '20' : '19'}#{year}" if year.length == 2 + year.to_i + end + + def month_index(month) + return month.to_i if month.to_i.nonzero? + Date::ABBR_MONTHNAMES.index(month.capitalize) || Date::MONTHNAMES.index(month.capitalize) end - end - def self.unambiguous_year(year, threshold=30) - year = "#{year.to_i < threshold ? '20' : '19'}#{year}" if year.length == 2 - year.to_i - end - - def self.month_index(month) - return month.to_i if month.to_i.nonzero? - Date::ABBR_MONTHNAMES.index(month.capitalize) || Date::MONTHNAMES.index(month.capitalize) end end end diff --git a/lib/validates_timeliness/validations.rb b/lib/validates_timeliness/validations.rb index e216556..bdde360 100644 --- a/lib/validates_timeliness/validations.rb +++ b/lib/validates_timeliness/validations.rb @@ -4,7 +4,7 @@ module ValidatesTimeliness # or times. module Validations - # Error messages added to AR defaults to allow global override if you need. + # Error messages added to AR defaults to allow global override. def self.included(base) base.extend ClassMethods @@ -16,25 +16,9 @@ module ValidatesTimeliness :on_or_after => "must be on or after %s" } ActiveRecord::Errors.default_error_messages.update(error_messages) - ValidatesTimeliness::Formats.compile_format_expressions end module ClassMethods - # loop through format regexps and call proc on matches if available. Allow - # pre or post match strings if bounded is false. Lastly fills out - # time_array to full 7 part datetime array. - def extract_date_time_values(time_string, type, bounded=true) - expressions = ValidatesTimeliness::Formats.send("#{type}_expressions") - time_array = nil - expressions.each do |(regexp, processor)| - matches = regexp.match(time_string.strip) - if !matches.nil? && (!bounded || (matches.pre_match == "" && matches.post_match == "")) - time_array = processor.call(*matches[1..7]) - break - end - end - return time_array - end # Override this method to use any date parsing algorithm you like such as # Chronic. Just return nil for an invalid value and a Time object for a @@ -45,7 +29,7 @@ module ValidatesTimeliness def timeliness_date_time_parse(raw_value, type, strict=true) return raw_value.to_time if raw_value.acts_like?(:time) || raw_value.is_a?(Date) - time_array = extract_date_time_values(raw_value, type, strict) + time_array = ValidatesTimeliness::Formats.extract_date_time_values(raw_value, type, strict) raise if time_array.nil? if type == :time diff --git a/spec/formats_spec.rb b/spec/formats_spec.rb index ee26d32..6c79d7d 100644 --- a/spec/formats_spec.rb +++ b/spec/formats_spec.rb @@ -136,6 +136,34 @@ describe ValidatesTimeliness::Formats do end end + describe "extracting values" do + + it "should return time array from date string" do + time_array = formats.extract_date_time_values('12:13:14', :time, true) + time_array.should == [nil,nil,nil,12,13,14,nil] + end + + it "should return date array from time string" do + time_array = formats.extract_date_time_values('2000-02-01', :date, true) + time_array.should == [2000,2,1,nil,nil,nil,nil] + end + + it "should return datetime array from string value" do + time_array = formats.extract_date_time_values('2000-02-01 12:13:14', :datetime, true) + time_array.should == [2000,2,1,12,13,14,nil] + end + + it "should ignore time when extracting date and strict is false" do + time_array = formats.extract_date_time_values('2000-02-01 12:12', :date, false) + time_array.should == [2000,2,1,nil,nil,nil,nil] + end + + it "should ignore date when extracting time and strict is false" do + time_array = formats.extract_date_time_values('2000-02-01 12:12', :time, false) + time_array.should == [nil,nil,nil,12,12,nil,nil] + end + end + describe "removing formats" do before do formats.compile_format_expressions