moved extact_date_time_values method in to Formats and added specs

changed Formats to a class
This commit is contained in:
Adam Meehan 2008-07-19 19:47:03 +10:00
parent 137ee152e2
commit 5983622ac8
5 changed files with 337 additions and 144 deletions

219
README
View File

@ -3,72 +3,231 @@
* Source: http://github.com/adzap/validates_timeliness * Source: http://github.com/adzap/validates_timeliness
* Tickets: http://adzap.lighthouseapp.com/projects/14111-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 Validate dates, times and datetimes for Rails 2.x. Plays nicely with new
features such as automatic timezone handling and dirty attributes. Allows features such as automatic timezone handling and dirty attributes. Allows
date/time atttributes to behave like other attribute types by allowing you to date/time atttributes to behave like other attribute types by allowing you to
review the raw entered value before it is converted. 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 ActiveRecord validation for dates, times and datetimes
* 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 * 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 raw value before type casting, which was lost in Rails 2.1.
timezone features.
* Allows pluggable date and time parsing with other parsers of your choosing (eg Chronic) * 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 Rails 2.1
./script/plugin git://github.com/adzap/validates_timeliness ./script/plugin git://github.com/adzap/validates_timeliness
Rails 2.0 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 To validate a model with a date, time or datetime attribute you just use the
validation method validation method
class Person < ActiveRecord::Base class Person < ActiveRecord::Base
validates_timeliness_of :date_of_birth validates_date :date_of_birth
end end
But for a more semantic validation method you can use the specific validation The list of validation methods available are as follows:
for the attribute type. The type based validation methods are
* 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 The validation method take the usual options plus some specific ones to restrict
validating. The same is true to time attributes which ignore the date. the valid range of dates or times allowed
The default valid formats for a date or datetime field are those supported by Temporal options:
the Ruby ParseDate library
(http://www.ruby-doc.org/docs/ProgrammingRuby/html/lib_standard.html#ParseDate.parsedate).
The validation method has the usual options plus some specific ones to restrict :before - Attribute must be before this value to be valid
the valid range :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
Options: Regular validation options
- allow_nil :allow_nil - Allow a nil value to be valid
- allow_blank :allow_blank - Allows a nil or empty string value to be valid
- before
- on_or_before The temporal options can 4 different value types:
- after
- on_or_after * String date or time value
- type * 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

View File

@ -17,3 +17,5 @@ ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::InstanceTag
Time.send(:include, ValidatesTimeliness::CoreExtensions::Time) Time.send(:include, ValidatesTimeliness::CoreExtensions::Time)
Date.send(:include, ValidatesTimeliness::CoreExtensions::Date) Date.send(:include, ValidatesTimeliness::CoreExtensions::Date)
DateTime.send(:include, ValidatesTimeliness::CoreExtensions::DateTime) DateTime.send(:include, ValidatesTimeliness::CoreExtensions::DateTime)
ValidatesTimeliness::Formats.compile_format_expressions

View File

@ -10,17 +10,17 @@ module ValidatesTimeliness
# Formats can be added or removed to customize the set of valid date or time # Formats can be added or removed to customize the set of valid date or time
# string values. # string values.
# #
module Formats class Formats
mattr_accessor :time_formats cattr_accessor :time_formats
mattr_accessor :date_formats cattr_accessor :date_formats
mattr_accessor :datetime_formats cattr_accessor :datetime_formats
mattr_accessor :time_expressions cattr_accessor :time_expressions
mattr_accessor :date_expressions cattr_accessor :date_expressions
mattr_accessor :datetime_expressions cattr_accessor :datetime_expressions
mattr_accessor :format_tokens cattr_accessor :format_tokens
mattr_accessor :format_proc_args cattr_accessor :format_proc_args
# Format tokens: # Format tokens:
# y = year # 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.) # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
# _ = optional space # _ = optional space
# tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST) # 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 # 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 # format but no gurantees that it will remain intact. If you avoid the use
@ -148,98 +148,118 @@ module ValidatesTimeliness
:meridian => [nil, 'md', nil] :meridian => [nil, 'md', nil]
} }
# Compile formats into validation regexps and format procs class << self
def self.format_expression_generator(string_format)
regexp = string_format.dup
order = {}
regexp.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes ]/
format_tokens.each do |token| # Compile formats into validation regexps and format procs
token_name = token.keys.first def format_expression_generator(string_format)
token_regexp, regexp_str, arg_key = *token.values.first 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 format_tokens.each do |token|
# considered an anchor to put straight back in the regexp string. token_name = token.keys.first
regexp.gsub!(token_regexp) {|m| "#{$1}" + regexp_str } token_regexp, regexp_str, arg_key = *token.values.first
order[arg_key] = $~.begin(0) if $~ && !arg_key.nil?
# 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 end
return Regexp.new(regexp), format_proc(order) # Generates a proc which when executed maps the regexp capture groups to a
rescue # proc argument based on order captured. A time array is built using the proc
puts "The following format regular expression failed to compile: #{regexp}\n from format #{string_format}." # argument in the position indicated by the first element of the proc arg
raise # array.
end #
# 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
# Generates a proc which when executed maps the regexp capture groups to a def compile_formats(formats)
# proc argument based on order captured. A time array is built using the proc formats.collect { |format|
# argument in the position indicated by the first element of the proc arg regexp, format_proc = format_expression_generator(format)
# array. }
# end
# 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) def compile_format_expressions
formats.collect { |format| @@time_expressions = compile_formats(@@time_formats)
regexp, format_proc = format_expression_generator(format) @@date_expressions = compile_formats(@@date_formats)
} @@datetime_expressions = compile_formats(@@datetime_formats)
end end
def self.compile_format_expressions # Loop through format expressions for type and call proc on matches. Allow
@@time_expressions = compile_formats(@@time_formats) # pre or post match strings to exist if strict is false.
@@date_expressions = compile_formats(@@date_formats) # Returns 7 part datetime array.
@@datetime_expressions = compile_formats(@@datetime_formats) def extract_date_time_values(time_string, type, strict=true)
end 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
return time_array
end
def self.remove_formats(type, *remove_formats) def remove_formats(type, *remove_formats)
remove_formats.each do |format| remove_formats.each do |format|
unless self.send("#{type}_formats").delete(format) unless self.send("#{type}_formats").delete(format)
raise "Format #{format} not found in #{type} formats" 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
end end
compile_format_expressions
end
def self.add_formats(type, *add_formats) def unambiguous_year(year, threshold=30)
formats = self.send("#{type}_formats") year = "#{year.to_i < threshold ? '20' : '19'}#{year}" if year.length == 2
year.to_i
add_formats.each do |format|
if formats.include?(format)
raise "Format #{format} is already included in #{type} formats"
end
formats << format
end end
compile_format_expressions
end
def self.full_hour(hour, meridian) def month_index(month)
hour = hour.to_i return month.to_i if month.to_i.nonzero?
return hour if meridian.nil? Date::ABBR_MONTHNAMES.index(month.capitalize) || Date::MONTHNAMES.index(month.capitalize)
if meridian.delete('.').downcase == 'am'
hour == 12 ? 0 : hour
else
hour == 12 ? hour : hour + 12
end 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 end
end end

View File

@ -4,7 +4,7 @@ module ValidatesTimeliness
# or times. # or times.
module Validations 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) def self.included(base)
base.extend ClassMethods base.extend ClassMethods
@ -16,25 +16,9 @@ module ValidatesTimeliness
:on_or_after => "must be on or after %s" :on_or_after => "must be on or after %s"
} }
ActiveRecord::Errors.default_error_messages.update(error_messages) ActiveRecord::Errors.default_error_messages.update(error_messages)
ValidatesTimeliness::Formats.compile_format_expressions
end end
module ClassMethods 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 # 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 # 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) 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) 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? raise if time_array.nil?
if type == :time if type == :time

View File

@ -136,6 +136,34 @@ describe ValidatesTimeliness::Formats do
end end
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 describe "removing formats" do
before do before do
formats.compile_format_expressions formats.compile_format_expressions