proper timezone awareness and plugin parser hooks

Parser uses method compilation technique namely to a method not a proc
This commit is contained in:
Adam Meehan 2010-09-16 22:35:38 +10:00
parent 9ddd150b2f
commit 5d495505d9
7 changed files with 813 additions and 60 deletions

View File

@ -4,13 +4,10 @@ require 'active_support/core_ext/hash/except'
require 'active_support/core_ext/string/conversions'
require 'active_support/core_ext/date/acts_like'
require 'active_support/core_ext/date/conversions'
require 'active_support/core_ext/date/zones'
require 'active_support/core_ext/time/acts_like'
require 'active_support/core_ext/time/conversions'
require 'active_support/core_ext/time/zones'
require 'active_support/core_ext/date_time/acts_like'
require 'active_support/core_ext/date_time/conversions'
require 'active_support/core_ext/date_time/zones'
module ValidatesTimeliness
autoload :VERSION, 'validates_timeliness/version'
@ -19,6 +16,14 @@ module ValidatesTimeliness
mattr_accessor :extend_orms
@@extend_orms = [ defined?(ActiveRecord) && :active_record ].compact
# User the plugin date/time parser which is stricter and extendable
mattr_accessor :use_plugin_parser
@@use_plugin_parser = false
# Default timezone
mattr_accessor :default_timezone
@@default_timezone = defined?(ActiveRecord) ? ActiveRecord::Base.default_timezone : :utc
# Set the dummy date part for a time type values.
mattr_accessor :dummy_date_for_time_type
@@dummy_date_for_time_type = [ 2000, 1, 1 ]
@ -41,6 +46,7 @@ module ValidatesTimeliness
end
end
require 'validates_timeliness/parser'
require 'validates_timeliness/conversion'
require 'validates_timeliness/validator'
require 'validates_timeliness/helper_methods'

View File

@ -8,7 +8,7 @@ module ValidatesTimeliness
when :date
value.to_date
when :datetime
value.to_time.in_time_zone
value.to_time
end
rescue
nil
@ -16,29 +16,34 @@ module ValidatesTimeliness
def dummy_time(value)
time = if value.acts_like?(:time)
value = value.in_time_zone
[value.hour, value.min, value.sec]
else
[0,0,0]
end
Time.local(*(ValidatesTimeliness.dummy_date_for_time_type + time))
Time.send(ValidatesTimeliness.default_timezone, *(ValidatesTimeliness.dummy_date_for_time_type + time))
end
def evaluate_option_value(value, record)
def evaluate_option_value(value, record, timezone_aware=false)
case value
when Time, Date
when Time
timezone_aware ? value.in_time_zone : value
when Date
value
when String
value.to_time(:local)
if ValidatesTimeliness.use_plugin_parser
ValidatesTimeliness::Parser.parse(value, :datetime, :timezone_aware => timezone_aware, :strict => false)
else
timezone_aware ? Time.zone.parse(value) : value.to_time(ValidatesTimeliness.default_timezone)
end
when Symbol
if !record.respond_to?(value) && restriction_shorthand?(value)
ValidatesTimeliness.restriction_shorthand_symbols[value].call
else
evaluate_option_value(record.send(value), record)
evaluate_option_value(record.send(value), record, timezone_aware)
end
when Proc
result = value.arity > 0 ? value.call(record) : value.call
evaluate_option_value(result, record)
evaluate_option_value(result, record, timezone_aware)
else
value
end

View File

@ -0,0 +1,407 @@
require 'date'
module ValidatesTimeliness
# A date and time parsing library which allows you to add custom formats using
# simple predefined tokens. This makes it much easier to catalogue and customize
# the formats rather than dealing directly with regular expressions.
#
# Formats can be added or removed to customize the set of valid date or time
# string values.
#
class Parser
cattr_accessor :time_formats,
:date_formats,
:datetime_formats,
:time_expressions,
:date_expressions,
:datetime_expressions,
:format_tokens,
:format_proc_args
# Set the threshold value for a two digit year to be considered last century
#
# Default: 30
#
# Example:
# year = '29' is considered 2029
# year = '30' is considered 1930
#
cattr_accessor :ambiguous_year_threshold
self.ambiguous_year_threshold = 30
# Set the dummy date part for a time type value. Should be an array of 3 values
# being year, month and day in that order.
#
# Default: [ 2000, 1, 1 ] same as ActiveRecord
#
cattr_accessor :dummy_date_for_time_type
self.dummy_date_for_time_type = [ 2000, 1, 1 ]
# 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 regexp 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.
#
# 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
# yyyy = 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 6 digits
#
# Any other invalid combination of repeating tokens will be swallowed up
# by the next lowest length valid repeating token (e.g. yyy will be
# replaced with yy)
@@time_formats = [
'hh:nn:ss',
'hh-nn-ss',
'h:nn',
'h.nn',
'h nn',
'h-nn',
'h:nn_ampm',
'h.nn_ampm',
'h nn_ampm',
'h-nn_ampm',
'h_ampm'
]
@@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 = [
'yyyy-mm-dd hh:nn:ss',
'yyyy-mm-dd h:nn',
'yyyy-mm-dd h:nn_ampm',
'yyyy-mm-dd hh:nn:ss.u',
'm/d/yy h:nn:ss',
'm/d/yy h:nn_ampm',
'm/d/yy h:nn',
'd/m/yy hh:nn:ss',
'd/m/yy h:nn_ampm',
'd/m/yy h:nn',
'ddd, dd mmm yyyy hh:nn:ss (zo|tz)', # RFC 822
'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
'yyyy-mm-ddThh:nn:ssZ', # iso 8601 without zone offset
'yyyy-mm-ddThh:nn:sszo' # iso 8601 with zone offset
]
# All tokens available for format construction. The token array is made of
# token regexp, validation regexp and key for format proc mapping if any.
# If the token needs no format proc arg then the validation regexp should
# not have a capturing group, as all captured groups are passed to the
# format proc.
#
# The token regexp should only use a capture group if 'look-behind' anchor
# is required. The first capture group will be considered a literal and put
# into the validation regexp string as-is. This is a hack.
@@format_tokens = [
{ 'ddd' => [ /d{3}/, '\w{3,9}' ] },
{ 'dd' => [ /d{2}/, '\d{2}', :day ] },
{ 'd' => [ /d/, '\d{1,2}', :day ] },
{ 'ampm' => [ /ampm/, '[aApP]\.?[mM]\.?', :meridian ] },
{ 'mmm' => [ /m{3}/, '\w{3,9}', :month ] },
{ 'mm' => [ /m{2}/, '\d{2}', :month ] },
{ 'm' => [ /m{1}/, '\d{1,2}', :month ] },
{ 'yyyy' => [ /y{4}/, '\d{4}', :year ] },
{ 'yy' => [ /y{2}/, '\d{4}|\d{2}', :year ] },
{ 'hh' => [ /h{2}/, '\d{2}', :hour ] },
{ 'h' => [ /h{1}/, '\d{1,2}', :hour ] },
{ 'nn' => [ /n{2}/, '\d{2}', :min ] },
{ 'n' => [ /n{1}/, '\d{1,2}', :min ] },
{ 'ss' => [ /s{2}/, '\d{2}', :sec ] },
{ 's' => [ /s{1}/, '\d{1,2}', :sec ] },
{ 'u' => [ /u{1}/, '\d{1,6}', :usec ] },
{ 'zo' => [ /zo/, '[+-]\d{2}:?\d{2}', :offset ] },
{ 'tz' => [ /tz/, '[A-Z]{1,4}' ] },
{ '_' => [ /_/, '\s?' ] }
]
# Arguments which will be passed to the format proc if matched in the
# time string. The key must be the key from the format tokens. The array
# consists of the arry position of the arg, the arg name, and the code to
# place in the time array slot. The position can be nil which means the arg
# won't be placed in the array.
#
# The code can be used to manipulate the arg value if required, otherwise
# should just be the arg name.
#
@@format_proc_args = {
:year => [0, 'y', 'unambiguous_year(y)'],
:month => [1, 'm', 'month_index(m)'],
:day => [2, 'd', 'd'],
:hour => [3, 'h', 'full_hour(h, md ||= nil)'],
:min => [4, 'n', 'n'],
:sec => [5, 's', 's'],
:usec => [6, 'u', 'microseconds(u)'],
:offset => [7, 'z', 'offset_in_seconds(z)'],
:meridian => [nil, 'md', nil]
}
@@type_wrapper = {
:date => [/\A/, nil],
:time => [nil , /\Z/],
:datetime => [/\A/, /\Z/]
}
class << self
def compile_format_expressions
@@time_expressions = compile_formats(@@time_formats)
@@date_expressions = compile_formats(@@date_formats)
@@datetime_expressions = compile_formats(@@datetime_formats)
end
def parse(raw_value, type, options={})
return nil if raw_value.blank?
return raw_value if raw_value.acts_like?(:time) || raw_value.acts_like?(:date)
time_array = _parse(raw_value, type, options.reverse_merge(:strict => true))
return nil if time_array.nil?
if type == :date
Date.new(*time_array[0..2]) rescue nil
else
make_time(time_array[0..7], options[:timezone_aware])
end
end
def make_time(time_array, timezone_aware=false)
# Enforce strict date part validity which Time class does not
return nil unless Date.valid_civil?(*time_array[0..2])
if timezone_aware
Time.zone.local(*time_array)
else
Time.time_with_datetime_fallback(ValidatesTimeliness.default_timezone, *time_array)
end
rescue ArgumentError, TypeError
nil
end
# Loop through format expressions for type and call the format method on a match.
# Allow pre or post match strings to exist if strict is false. Otherwise wrap
# regexp in start and end anchors.
#
# Returns time array if matches a format, nil otherwise.
#
def _parse(string, type, options={})
options.reverse_merge!(:strict => true)
sets = if options[:format]
options[:strict] = true
[ send("#{type}_expressions").assoc(options[:format]) ]
else
expression_set(type, string)
end
set = sets.find do |format, regexp|
string =~ wrap_regexp(regexp, type, options[:strict])
end
if set
last = options[:include_offset] ? 8 : 7
values = send(:"format_#{set[0]}", *$~[1..last])
values[0..2] = ValidatesTimeliness.dummy_date_for_time_type if type == :time
return values
end
end
# Delete formats of specified type. Error raised if format not found.
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
# Adds new formats. Must specify format type and can specify a :before
# option to nominate which format the new formats should be inserted in
# front on to take higher precedence.
# Error is raised if format already exists or if :before format is not found.
def add_formats(type, *add_formats)
formats = self.send("#{type}_formats")
options = {}
options = add_formats.pop if add_formats.last.is_a?(Hash)
before = options[:before]
raise "Format for :before option #{format} was not found." if before && !formats.include?(before)
add_formats.each do |format|
raise "Format #{format} is already included in #{type} formats" if formats.include?(format)
index = before ? formats.index(before) : -1
formats.insert(index, format)
end
compile_format_expressions
end
# Removes formats where the 1 or 2 digit month comes first, to eliminate
# formats which are ambiguous with the European style of day then month.
# The mmm token is ignored as its not ambigous.
def remove_us_formats
us_format_regexp = /\Am{1,2}[^m]/
date_formats.reject! { |format| us_format_regexp =~ format }
datetime_formats.reject! { |format| us_format_regexp =~ format }
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)
if year.length <= 2
century = Time.now.year.to_s[0..1].to_i
century -= 1 if year.to_i >= ambiguous_year_threshold
year = "#{century}#{year.rjust(2,'0')}"
end
year.to_i
end
def month_index(month)
return month.to_i if month.to_i.nonzero?
abbr_month_names.index(month.capitalize) || month_names.index(month.capitalize)
end
def month_names
I18n.t('date.month_names')
end
def abbr_month_names
I18n.t('date.abbr_month_names')
end
def microseconds(usec)
(".#{usec}".to_f * 1_000_000).to_i
end
def offset_in_seconds(offset)
sign = offset =~ /^-/ ? -1 : 1
parts = offset.scan(/\d\d/).map {|p| p.to_f }
parts[1] = parts[1].to_f / 60
(parts[0] + parts[1]) * sign * 3600
end
private
# Generate regular expression from format string
def generate_format_expression(string_format)
format = string_format.dup
format.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes
found_tokens, token_order = [], []
format_tokens.each do |token|
token_regexp, regexp_str, arg_key = *token.values.first
if format.gsub!(token_regexp, "%<#{found_tokens.size}>")
regexp_str = "(#{regexp_str})" if arg_key
found_tokens << [regexp_str, arg_key]
end
end
format.scan(/%<(\d)>/).each {|token_index|
token_index = token_index.first
token = found_tokens[token_index.to_i]
format.gsub!("%<#{token_index}>", token[0])
token_order << token[1]
}
compile_format_method(token_order.compact, string_format)
Regexp.new(format)
rescue
raise "The following format regular expression failed to compile: #{format}\n from format #{string_format}."
end
# Compiles a format method which maps the regexp capture groups to method
# arguments based on order captured. A time array is built using the values
# in the position indicated by the first element of the proc arg array.
#
def compile_format_method(order, name)
values = [nil] * 7
args = []
order.each do |part|
proc_arg = format_proc_args[part]
args << proc_arg[1]
values[proc_arg[0]] = proc_arg[2] if proc_arg[0]
end
class_eval <<-DEF
class << self
define_method(:"format_#{name}") do |#{args.join(',')}|
[#{values.map {|i| i || 'nil' }.join(',')}].map {|i| i.is_a?(Float) ? i : i.to_i }
end
end
DEF
end
def compile_formats(formats)
formats.map { |format| [ format, generate_format_expression(format) ] }
end
# Pick expression set and combine date and datetimes for
# datetime attributes to allow date string as datetime
def expression_set(type, string)
case type
when :date
date_expressions
when :time
time_expressions
when :datetime
# gives a speed-up for date string as datetime attributes
if string.length < 11
date_expressions + datetime_expressions
else
datetime_expressions + date_expressions
end
end
end
def wrap_regexp(regexp, type, strict=false)
type = strict ? :datetime : type
/#{@@type_wrapper[type][0]}#{regexp}#{@@type_wrapper[type][1]}/
end
end
end
end
ValidatesTimeliness::Parser.compile_format_expressions

View File

@ -41,13 +41,14 @@ module ValidatesTimeliness
raw_value = attribute_raw_value(record, attr_name) || value
return if (@allow_nil && raw_value.nil?) || (@allow_blank && raw_value.blank?)
timezone_aware = record.class.timeliness_attribute_timezone_aware?(attr_name)
value = type_cast(value)
return record.errors.add(attr_name, :"invalid_#{@type}") if value.blank?
@restrictions_to_check.each do |restriction|
begin
restriction_value = type_cast(evaluate_option_value(options[restriction], record))
restriction_value = type_cast(evaluate_option_value(options[restriction], record, timezone_aware))
unless value.send(RESTRICTIONS[restriction], restriction_value)
return record.errors.add(attr_name, restriction, :message => options[:"#{restriction}_message"], :restriction => format_error_value(restriction_value))
end

View File

@ -1,6 +1,3 @@
$:.unshift(File.dirname(__FILE__) + '/../lib')
$:.unshift(File.dirname(__FILE__))
require 'rspec'
require 'rspec/autorun'
@ -19,6 +16,7 @@ ValidatesTimeliness.setup do |c|
c.extend_orms = [ :active_record ]
c.enable_date_time_select_extension!
c.enable_multiparameter_extension!
c.default_timezone = :utc
end
Time.zone = 'Australia/Melbourne'

View File

@ -24,64 +24,49 @@ describe ValidatesTimeliness::Conversion do
describe "for time type" do
it "should return same value for time value matching dummy date part" do
type_cast_value(Time.mktime(2000, 1, 1, 0, 0, 0), :time).should == Time.mktime(2000, 1, 1, 0, 0, 0)
type_cast_value(Time.utc(2000, 1, 1, 0, 0, 0), :time).should == Time.utc(2000, 1, 1, 0, 0, 0)
end
it "should return dummy time value with same time part for time value with different date" do
type_cast_value(Time.mktime(2010, 1, 1, 0, 0, 0), :time).should == Time.mktime(2000, 1, 1, 0, 0, 0)
type_cast_value(Time.utc(2010, 1, 1, 0, 0, 0), :time).should == Time.utc(2000, 1, 1, 0, 0, 0)
end
it "should return dummy time only for date value" do
type_cast_value(Date.new(2010, 1, 1), :time).should == Time.mktime(2000, 1, 1, 0, 0, 0)
type_cast_value(Date.new(2010, 1, 1), :time).should == Time.utc(2000, 1, 1, 0, 0, 0)
end
it "should return dummy date with shifted local time for UTC datetime value" do
type_cast_value(DateTime.new(2010, 1, 1, 12, 34, 56), :time).should == Time.mktime(2000, 1, 1, 23, 34, 56)
end
it "should return dummy date with time part for local datetime value" do
type_cast_value(DateTime.civil_from_format(:local, 2010, 1, 1, 12, 34, 56), :time).should == Time.mktime(2000, 1, 1, 12, 34, 56)
it "should return dummy date with time part for datetime value" do
type_cast_value(DateTime.civil_from_format(:utc, 2010, 1, 1, 12, 34, 56), :time).should == Time.utc(2000, 1, 1, 12, 34, 56)
end
end
describe "for datetime type" do
it "should return time in local offset for date value" do
it "should return Date as Time value" do
type_cast_value(Date.new(2010, 1, 1), :datetime).should == Time.local_time(2010, 1, 1, 0, 0, 0)
end
it "should return same value for same time value in local offset" do
type_cast_value(Time.local_time(2010, 1, 1, 12, 34, 56), :datetime).should == Time.local_time(2010, 1, 1, 12, 34, 56)
it "should return same Time value" do
value = Time.utc(2010, 1, 1, 12, 34, 56)
type_cast_value(Time.utc(2010, 1, 1, 12, 34, 56), :datetime).should == value
end
it "should return shifted local time value for UTC time value" do
type_cast_value(Time.utc(2010, 1, 1, 12, 34, 56), :datetime).should == Time.local_time(2010, 1, 1, 23, 34, 56)
end
it "should return shifted local time value for UTC datetime value" do
type_cast_value(DateTime.new(2010, 1, 1, 12, 34, 56), :datetime).should == Time.local_time(2010, 1, 1, 23, 34, 56)
end
it "should return time value with same component values for local datetime value" do
type_cast_value(DateTime.civil_from_format(:local, 2010, 1, 1, 12, 34, 56), :datetime).should == Time.local_time(2010, 1, 1, 12, 34, 56)
it "should return as Time with same component values" do
type_cast_value(DateTime.civil_from_format(:utc, 2010, 1, 1, 12, 34, 56), :datetime).should == Time.utc(2010, 1, 1, 12, 34, 56)
end
end
end
describe "#dummy_time" do
it 'should return dummy date with shifted local time part for UTC time value' do
dummy_time(Time.utc(2010, 11, 22, 12, 34, 56)).should == Time.local_time(2000, 1, 1, 23, 34, 56)
it 'should return Time with dummy date values but same time components' do
dummy_time(Time.utc(2010, 11, 22, 12, 34, 56)).should == Time.utc(2000, 1, 1, 12, 34, 56)
end
it 'should return dummy date with same time part part for local time value with non-dummy date' do
dummy_time(Time.local_time(2010, 11, 22, 12, 34, 56)).should == Time.local_time(2000, 1, 1, 12, 34, 56)
it 'should return same value for Time which already has dummy date values' do
dummy_time(Time.utc(2000, 1, 1, 12, 34, 56)).should == Time.utc(2000, 1, 1, 12, 34, 56)
end
it 'should return same value for local time with dummy date' do
dummy_time(Time.local_time(2000, 1, 1, 12, 34, 56)).should == Time.local_time(2000, 1, 1, 12, 34, 56)
end
it 'should return exact dummy time value for date value' do
dummy_time(Date.new(2010, 11, 22)).should == Time.mktime(2000, 1, 1, 0, 0, 0)
it 'should return base dummy time value for Date value' do
dummy_time(Date.new(2010, 11, 22)).should == Time.utc(2000, 1, 1, 0, 0, 0)
end
describe "with custom dummy date" do
@ -91,7 +76,7 @@ describe ValidatesTimeliness::Conversion do
end
it 'should return dummy time with custom dummy date' do
dummy_time(Time.local_time(1999, 11, 22, 12, 34, 56)).should == Time.local_time(2010, 1, 1, 12, 34, 56)
dummy_time(Time.utc(1999, 11, 22, 12, 34, 56)).should == Time.utc(2010, 1, 1, 12, 34, 56)
end
after(:all) do
@ -118,11 +103,6 @@ describe ValidatesTimeliness::Conversion do
evaluate_option_value(value, person).should == value
end
it 'should return local Time value from string time value' do
value = '2010-01-01 12:00:00'
evaluate_option_value(value, person).should == Time.mktime(2010,1,1,12,0,0)
end
it 'should return Time value returned from proc with 0 arity' do
value = Time.mktime(2010,1,1)
evaluate_option_value(lambda { value }, person).should == value
@ -134,21 +114,37 @@ describe ValidatesTimeliness::Conversion do
evaluate_option_value(lambda {|r| r.birth_time }, person).should == value
end
it 'should return Time value from proc which returns string time' do
value = '2010-01-01 12:00:00'
evaluate_option_value(lambda { value }, person).should == Time.mktime(2010,1,1,12,0,0)
end
it 'should return Time value for attribute method symbol which returns Time' do
value = Time.mktime(2010,1,1)
person.birth_time = value
evaluate_option_value(:birth_time, person).should == value
end
it 'should return Time value for attribute method symbol which returns string time value' do
it 'should return Time value is default zone from string time value' do
value = '2010-01-01 12:00:00'
evaluate_option_value(value, person).should == Time.utc(2010,1,1,12,0,0)
end
it 'should return Time value is current zone from string time value if timezone aware' do
value = '2010-01-01 12:00:00'
evaluate_option_value(value, person, true).should == Time.zone.local(2010,1,1,12,0,0)
end
it 'should return Time value in default zone from proc which returns string time' do
value = '2010-01-01 12:00:00'
evaluate_option_value(lambda { value }, person).should == Time.utc(2010,1,1,12,0,0)
end
it 'should return Time value in default zone for attribute method symbol which returns string time value' do
value = '2010-01-01 12:00:00'
person.birth_time = value
evaluate_option_value(:birth_time, person).should == Time.mktime(2010,1,1,12,0,0)
evaluate_option_value(:birth_time, person).should == Time.utc(2010,1,1,12,0,0)
end
it 'should return Time value in current zone attribute method symbol which returns string time value if timezone aware' do
value = '2010-01-01 12:00:00'
person.birth_time = value
evaluate_option_value(:birth_time, person, true).should == Time.zone.local(2010,1,1,12,0,0)
end
context "restriction shorthand" do

View File

@ -0,0 +1,340 @@
require 'spec_helper'
describe ValidatesTimeliness::Parser do
describe "format proc generator" do
it "should generate proc which outputs date array with values in correct order" do
generate_method('yyyy-mm-dd').call('2000', '1', '2').should == [2000,1,2,0,0,0,0]
end
it "should generate proc which outputs date array from format with different order" do
generate_method('dd/mm/yyyy').call('2', '1', '2000').should == [2000,1,2,0,0,0,0]
end
it "should generate proc which outputs time array" do
generate_method('hh:nn:ss').call('01', '02', '03').should == [0,0,0,1,2,3,0]
end
it "should generate proc which outputs time array with meridian 'pm' adjusted hour" do
generate_method('hh:nn:ss ampm').call('01', '02', '03', 'pm').should == [0,0,0,13,2,3,0]
end
it "should generate proc which outputs time array with meridian 'am' unadjusted hour" do
generate_method('hh:nn:ss ampm').call('01', '02', '03', 'am').should == [0,0,0,1,2,3,0]
end
it "should generate proc which outputs time array with microseconds" do
generate_method('hh:nn:ss.u').call('01', '02', '03', '99').should == [0,0,0,1,2,3,990000]
end
it "should generate proc which outputs datetime array with zone offset" do
generate_method('yyyy-mm-dd hh:nn:ss.u zo').call('2001', '02', '03', '04', '05', '06', '99', '+10:00').should == [2001,2,3,4,5,6,990000,36000]
end
end
describe "validate regexps" do
describe "for time formats" do
format_tests = {
'hh:nn:ss' => {:pass => ['12:12:12', '01:01:01'], :fail => ['1:12:12', '12:1:12', '12:12:1', '12-12-12']},
'hh-nn-ss' => {:pass => ['12-12-12', '01-01-01'], :fail => ['1-12-12', '12-1-12', '12-12-1', '12:12:12']},
'h:nn' => {:pass => ['12:12', '1:01'], :fail => ['12:2', '12-12']},
'h.nn' => {:pass => ['2.12', '12.12'], :fail => ['2.1', '12:12']},
'h nn' => {:pass => ['2 12', '12 12'], :fail => ['2 1', '2.12', '12:12']},
'h-nn' => {:pass => ['2-12', '12-12'], :fail => ['2-1', '2.12', '12:12']},
'h:nn_ampm' => {:pass => ['2:12am', '2:12 pm', '2:12 AM', '2:12PM'], :fail => ['1:2am', '1:12 pm', '2.12am']},
'h.nn_ampm' => {:pass => ['2.12am', '2.12 pm'], :fail => ['1:2am', '1:12 pm', '2:12am']},
'h nn_ampm' => {:pass => ['2 12am', '2 12 pm'], :fail => ['1 2am', '1 12 pm', '2:12am']},
'h-nn_ampm' => {:pass => ['2-12am', '2-12 pm'], :fail => ['1-2am', '1-12 pm', '2:12am']},
'h_ampm' => {:pass => ['2am', '2 am', '12 pm'], :fail => ['1.am', '12 pm', '2:12am']},
}
format_tests.each do |format, values|
it "should correctly validate times in format '#{format}'" do
regexp = generate_regexp(format)
values[:pass].each {|value| value.should match(regexp)}
values[:fail].each {|value| value.should_not match(regexp)}
end
end
end
describe "for date formats" do
format_tests = {
'yyyy/mm/dd' => {:pass => ['2000/02/01'], :fail => ['2000\02\01', '2000/2/1', '00/02/01']},
'yyyy-mm-dd' => {:pass => ['2000-02-01'], :fail => ['2000\02\01', '2000-2-1', '00-02-01']},
'yyyy.mm.dd' => {:pass => ['2000.02.01'], :fail => ['2000\02\01', '2000.2.1', '00.02.01']},
'm/d/yy' => {:pass => ['2/1/01', '02/01/00', '02/01/2000'], :fail => ['2/1/0', '2.1.01']},
'd/m/yy' => {:pass => ['1/2/01', '01/02/00', '01/02/2000'], :fail => ['1/2/0', '1.2.01']},
'm\d\yy' => {:pass => ['2\1\01', '2\01\00', '02\01\2000'], :fail => ['2\1\0', '2/1/01']},
'd\m\yy' => {:pass => ['1\2\01', '1\02\00', '01\02\2000'], :fail => ['1\2\0', '1/2/01']},
'd-m-yy' => {:pass => ['1-2-01', '1-02-00', '01-02-2000'], :fail => ['1-2-0', '1/2/01']},
'd.m.yy' => {:pass => ['1.2.01', '1.02.00', '01.02.2000'], :fail => ['1.2.0', '1/2/01']},
'd mmm yy' => {:pass => ['1 Feb 00', '1 Feb 2000', '1 February 00', '01 February 2000'],
:fail => ['1 Fe 00', 'Feb 1 2000', '1 Feb 0']}
}
format_tests.each do |format, values|
it "should correctly validate dates in format '#{format}'" do
regexp = generate_regexp(format)
values[:pass].each {|value| value.should match(regexp)}
values[:fail].each {|value| value.should_not match(regexp)}
end
end
end
describe "for datetime formats" do
format_tests = {
'ddd mmm d hh:nn:ss zo yyyy' => {:pass => ['Sat Jul 19 12:00:00 +1000 2008'], :fail => []},
'yyyy-mm-ddThh:nn:ss(?:Z|zo)' => {:pass => ['2008-07-19T12:00:00+10:00', '2008-07-19T12:00:00Z'], :fail => ['2008-07-19T12:00:00Z+10:00']},
}
format_tests.each do |format, values|
it "should correctly validate datetimes in format '#{format}'" do
regexp = generate_regexp(format)
values[:pass].each {|value| value.should match(regexp)}
values[:fail].each {|value| value.should_not match(regexp)}
end
end
end
end
describe "_parse" do
it "should return time array from date string" do
time_array = formats._parse('12:13:14', :time, :strict => true)
time_array.should == [2000,1,1,12,13,14,0]
end
it "should return date array from time string" do
time_array = formats._parse('2000-02-01', :date, :strict => true)
time_array.should == [2000,2,1,0,0,0,0]
end
it "should return datetime array from string value" do
time_array = formats._parse('2000-02-01 12:13:14', :datetime, :strict => true)
time_array.should == [2000,2,1,12,13,14,0]
end
it "should parse date string when type is datetime" do
time_array = formats._parse('2000-02-01', :datetime, :strict => false)
time_array.should == [2000,2,1,0,0,0,0]
end
it "should ignore time when extracting date and strict is false" do
time_array = formats._parse('2000-02-01 12:13', :date, :strict => false)
time_array.should == [2000,2,1,0,0,0,0]
end
it "should ignore time when extracting date from format with trailing year and strict is false" do
time_array = formats._parse('01-02-2000 12:13', :date, :strict => false)
time_array.should == [2000,2,1,0,0,0,0]
end
it "should ignore date when extracting time and strict is false" do
time_array = formats._parse('2000-02-01 12:13', :time, :strict => false)
time_array.should == [2000,1,1,12,13,0,0]
end
it "should return zone offset when :include_offset option is true" do
time_array = formats._parse('2000-02-01T12:13:14-10:30', :datetime, :include_offset => true)
time_array.should == [2000,2,1,12,13,14,0,-37800]
end
context "with format option" do
it "should return values if string matches specified format" do
time_array = formats._parse('2000-02-01 12:13:14', :datetime, :format => 'yyyy-mm-dd hh:nn:ss')
time_array.should == [2000,2,1,12,13,14,0]
end
it "should return nil if string does not match specified format" do
time_array = formats._parse('2000-02-01 12:13', :datetime, :format => 'yyyy-mm-dd hh:nn:ss')
time_array.should be_nil
end
end
context "date with ambiguous year" do
it "should return year in current century if year below threshold" do
time_array = formats._parse('01-02-29', :date)
time_array.should == [2029,2,1,0,0,0,0]
end
it "should return year in last century if year at or above threshold" do
time_array = formats._parse('01-02-30', :date)
time_array.should == [1930,2,1,0,0,0,0]
end
it "should allow custom threshold" do
default = ValidatesTimeliness::Parser.ambiguous_year_threshold
ValidatesTimeliness::Parser.ambiguous_year_threshold = 40
time_array = formats._parse('01-02-39', :date)
time_array.should == [2039,2,1,0,0,0,0]
time_array = formats._parse('01-02-40', :date)
time_array.should == [1940,2,1,0,0,0,0]
ValidatesTimeliness::Parser.ambiguous_year_threshold = default
end
end
context "with custom dummy date values" do
before(:all) do
@old_dummy_date = ValidatesTimeliness.dummy_date_for_time_type
ValidatesTimeliness.dummy_date_for_time_type = [2009,1,1]
end
it "should return time array with custom dummy date" do
time_array = formats._parse('12:13:14', :time, :strict => true)
time_array.should == [2009,1,1,12,13,14,0]
end
after(:all) do
ValidatesTimeliness.dummy_date_for_time_type = @old_dummy_date
end
end
end
describe "parse" do
it "should return time object for valid time string" do
parse("2000-01-01 12:13:14", :datetime).should be_kind_of(Time)
end
it "should return nil for time string with invalid date part" do
parse("2000-02-30 12:13:14", :datetime).should be_nil
end
it "should return nil for time string with invalid time part" do
parse("2000-02-01 25:13:14", :datetime).should be_nil
end
it "should return Time object when passed a Time object" do
parse(Time.now, :datetime).should be_kind_of(Time)
end
it "should convert time string into current timezone" do
Time.zone = 'Melbourne'
time = parse("2000-01-01 12:13:14", :datetime, :timezone_aware => true)
Time.zone.utc_offset.should == 10.hours
end
it "should return nil for invalid date string" do
parse("2000-02-30", :date).should be_nil
end
def parse(*args)
ValidatesTimeliness::Parser.parse(*args)
end
end
describe "make_time" do
it "should create time using current timezone" do
time = ValidatesTimeliness::Parser.make_time([2000,1,1,12,0,0])
time.zone.should == "UTC"
end
it "should create time using current timezone" do
Time.zone = 'Melbourne'
time = ValidatesTimeliness::Parser.make_time([2000,1,1,12,0,0], true)
time.zone.should == "EST"
end
end
describe "removing formats" do
it "should remove format from format array" do
formats.remove_formats(:time, 'h.nn_ampm')
formats.time_formats.should_not include("h o'clock")
end
it "should not match time after its format is removed" do
validate('2.12am', :time).should be_true
formats.remove_formats(:time, 'h.nn_ampm')
validate('2.12am', :time).should be_false
end
it "should raise error if format does not exist" do
lambda { formats.remove_formats(:time, "ss:hh:nn") }.should raise_error()
end
after do
formats.time_formats << 'h.nn_ampm'
formats.compile_format_expressions
end
end
describe "adding formats" do
before do
formats.compile_format_expressions
end
it "should add format to format array" do
formats.add_formats(:time, "h o'clock")
formats.time_formats.should include("h o'clock")
end
it "should match new format after its added" do
validate("12 o'clock", :time).should be_false
formats.add_formats(:time, "h o'clock")
validate("12 o'clock", :time).should be_true
end
it "should add format before specified format and be higher precedence" do
formats.add_formats(:time, "ss:hh:nn", :before => 'hh:nn:ss')
validate("59:23:58", :time).should be_true
time_array = formats._parse('59:23:58', :time)
time_array.should == [2000,1,1,23,58,59,0]
end
it "should raise error if format exists" do
lambda { formats.add_formats(:time, "hh:nn:ss") }.should raise_error()
end
it "should raise error if format exists" do
lambda { formats.add_formats(:time, "ss:hh:nn", :before => 'nn:hh:ss') }.should raise_error()
end
after do
formats.time_formats.delete("h o'clock")
formats.time_formats.delete("ss:hh:nn")
# reload class instead
end
end
describe "removing US formats" do
it "should validate a date as European format when US formats removed" do
time_array = formats._parse('01/02/2000', :date)
time_array.should == [2000, 1, 2,0,0,0,0]
formats.remove_us_formats
time_array = formats._parse('01/02/2000', :date)
time_array.should == [2000, 2, 1,0,0,0,0]
end
after do
# reload class
end
end
def formats
ValidatesTimeliness::Parser
end
def validate(time_string, type)
valid = false
formats.send("#{type}_expressions").each do |format, regexp, processor|
valid = true and break if /\A#{regexp}\Z/ =~ time_string
end
valid
end
def generate_regexp(format)
# wrap in line start and end anchors to emulate extract values method
/\A#{formats.send(:generate_format_expression, format)}\Z/
end
def generate_regexp_str(format)
formats.send(:generate_format_expression, format).inspect
end
def generate_method(format)
formats.send(:generate_format_expression, format)
ValidatesTimeliness::Parser.method(:"format_#{format}")
end
def delete_format(type, format)
formats.send("#{type}_formats").delete(format)
end
end