validates_timeliness 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/CHANGELOG +43 -0
- data/LICENSE +20 -0
- data/README.rdoc +320 -0
- data/Rakefile +58 -0
- data/TODO +8 -0
- data/lib/validates_timeliness.rb +66 -0
- data/lib/validates_timeliness/action_view/instance_tag.rb +45 -0
- data/lib/validates_timeliness/active_record/attribute_methods.rb +157 -0
- data/lib/validates_timeliness/active_record/multiparameter_attributes.rb +64 -0
- data/lib/validates_timeliness/core_ext/date.rb +13 -0
- data/lib/validates_timeliness/core_ext/date_time.rb +13 -0
- data/lib/validates_timeliness/core_ext/time.rb +13 -0
- data/lib/validates_timeliness/formats.rb +310 -0
- data/lib/validates_timeliness/locale/en.yml +11 -0
- data/lib/validates_timeliness/spec/rails/matchers/validate_timeliness.rb +121 -0
- data/lib/validates_timeliness/validation_methods.rb +82 -0
- data/lib/validates_timeliness/validator.rb +120 -0
- data/spec/action_view/instance_tag_spec.rb +38 -0
- data/spec/active_record/attribute_methods_spec.rb +204 -0
- data/spec/active_record/multiparameter_attributes_spec.rb +48 -0
- data/spec/core_ext/dummy_time_spec.rb +31 -0
- data/spec/formats_spec.rb +274 -0
- data/spec/ginger_scenarios.rb +19 -0
- data/spec/resources/application.rb +2 -0
- data/spec/resources/person.rb +3 -0
- data/spec/resources/schema.rb +10 -0
- data/spec/resources/sqlite_patch.rb +19 -0
- data/spec/spec/rails/matchers/validate_timeliness_spec.rb +178 -0
- data/spec/spec_helper.rb +54 -0
- data/spec/time_travel/MIT-LICENSE +20 -0
- data/spec/time_travel/time_extensions.rb +33 -0
- data/spec/time_travel/time_travel.rb +12 -0
- data/spec/validation_methods_spec.rb +61 -0
- data/spec/validator_spec.rb +438 -0
- metadata +105 -0
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'validates_timeliness/formats'
|
2
|
+
require 'validates_timeliness/validator'
|
3
|
+
require 'validates_timeliness/validation_methods'
|
4
|
+
require 'validates_timeliness/spec/rails/matchers/validate_timeliness' if ENV['RAILS_ENV'] == 'test'
|
5
|
+
|
6
|
+
require 'validates_timeliness/active_record/attribute_methods'
|
7
|
+
require 'validates_timeliness/active_record/multiparameter_attributes'
|
8
|
+
require 'validates_timeliness/action_view/instance_tag'
|
9
|
+
|
10
|
+
require 'validates_timeliness/core_ext/time'
|
11
|
+
require 'validates_timeliness/core_ext/date'
|
12
|
+
require 'validates_timeliness/core_ext/date_time'
|
13
|
+
|
14
|
+
module ValidatesTimeliness
|
15
|
+
|
16
|
+
mattr_accessor :default_timezone
|
17
|
+
|
18
|
+
self.default_timezone = :utc
|
19
|
+
|
20
|
+
LOCALE_PATH = File.expand_path(File.dirname(__FILE__) + '/validates_timeliness/locale/en.yml')
|
21
|
+
|
22
|
+
class << self
|
23
|
+
|
24
|
+
def load_error_messages_with_i18n
|
25
|
+
I18n.load_path += [ LOCALE_PATH ]
|
26
|
+
end
|
27
|
+
|
28
|
+
def load_error_messages_without_i18n
|
29
|
+
messages = YAML::load(IO.read(LOCALE_PATH))
|
30
|
+
errors = messages['en']['activerecord']['errors']['messages'].inject({}) {|h,(k,v)| h[k.to_sym] = v.gsub(/\{\{\w*\}\}/, '%s');h }
|
31
|
+
::ActiveRecord::Errors.default_error_messages.update(errors)
|
32
|
+
end
|
33
|
+
|
34
|
+
def default_error_messages
|
35
|
+
if Rails::VERSION::STRING < '2.2'
|
36
|
+
::ActiveRecord::Errors.default_error_messages
|
37
|
+
else
|
38
|
+
I18n.translate('activerecord.errors.messages')
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def setup_for_rails_2_0
|
43
|
+
load_error_messages_without_i18n
|
44
|
+
end
|
45
|
+
|
46
|
+
def setup_for_rails_2_1
|
47
|
+
load_error_messages_without_i18n
|
48
|
+
end
|
49
|
+
|
50
|
+
def setup_for_rails_2_2
|
51
|
+
load_error_messages_with_i18n
|
52
|
+
end
|
53
|
+
|
54
|
+
def setup_for_rails
|
55
|
+
major, minor = Rails::VERSION::MAJOR, Rails::VERSION::MINOR
|
56
|
+
self.send("setup_for_rails_#{major}_#{minor}")
|
57
|
+
self.default_timezone = ::ActiveRecord::Base.default_timezone
|
58
|
+
rescue
|
59
|
+
raise "Rails version #{Rails::VERSION::STRING} not yet supported by validates_timeliness plugin"
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
ValidatesTimeliness.setup_for_rails
|
65
|
+
|
66
|
+
ValidatesTimeliness::Formats.compile_format_expressions
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module ActionView
|
3
|
+
|
4
|
+
# Intercepts the date and time select helpers to allow the
|
5
|
+
# attribute value before type cast to be used as in the select helpers.
|
6
|
+
# This means that an invalid date or time will be redisplayed rather than the
|
7
|
+
# type cast value which would be nil if invalid.
|
8
|
+
module InstanceTag
|
9
|
+
|
10
|
+
def self.included(base)
|
11
|
+
selector_method = Rails::VERSION::STRING < '2.2' ? :date_or_time_select : :datetime_selector
|
12
|
+
base.class_eval do
|
13
|
+
alias_method :datetime_selector_without_timeliness, selector_method
|
14
|
+
alias_method selector_method, :datetime_selector_with_timeliness
|
15
|
+
end
|
16
|
+
base.alias_method_chain :value, :timeliness
|
17
|
+
end
|
18
|
+
|
19
|
+
TimelinessDateTime = Struct.new(:year, :month, :day, :hour, :min, :sec)
|
20
|
+
|
21
|
+
def datetime_selector_with_timeliness(*args)
|
22
|
+
@timeliness_date_or_time_tag = true
|
23
|
+
datetime_selector_without_timeliness(*args)
|
24
|
+
end
|
25
|
+
|
26
|
+
def value_with_timeliness(object)
|
27
|
+
return value_without_timeliness(object) unless @timeliness_date_or_time_tag
|
28
|
+
|
29
|
+
raw_value = value_before_type_cast(object)
|
30
|
+
|
31
|
+
if raw_value.nil? || raw_value.acts_like?(:time) || raw_value.is_a?(Date)
|
32
|
+
return value_without_timeliness(object)
|
33
|
+
end
|
34
|
+
|
35
|
+
time_array = ParseDate.parsedate(raw_value)
|
36
|
+
|
37
|
+
TimelinessDateTime.new(*time_array[0..5])
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::ActionView::InstanceTag)
|
@@ -0,0 +1,157 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module ActiveRecord
|
3
|
+
|
4
|
+
# Rails 2.1 removed the ability to retrieve the raw value of a time or datetime
|
5
|
+
# attribute. The raw value is necessary to properly validate a string time or
|
6
|
+
# datetime value instead of the internal Rails type casting which is very limited
|
7
|
+
# and does not allow custom formats. These methods restore that ability while
|
8
|
+
# respecting the automatic timezone handling.
|
9
|
+
#
|
10
|
+
# The automatic timezone handling sets the assigned attribute value to the current
|
11
|
+
# zone in Time.zone. To preserve this localised value and capture the raw value
|
12
|
+
# we cache the localised value on write and store the raw value in the attributes
|
13
|
+
# hash for later retrieval and possibly validation. Any value from the database
|
14
|
+
# will not be in the attribute cache on first read so will be considered in default
|
15
|
+
# timezone and converted to local time. It is then stored back in the attributes
|
16
|
+
# hash and cached to avoid the need for any subsequent differentiation.
|
17
|
+
#
|
18
|
+
# The wholesale replacement of the Rails time type casting is not done to
|
19
|
+
# preserve the quickest conversion for timestamp columns and also any value
|
20
|
+
# which is never changed during the life of the record object.
|
21
|
+
module AttributeMethods
|
22
|
+
|
23
|
+
def self.included(base)
|
24
|
+
base.extend ClassMethods
|
25
|
+
|
26
|
+
if Rails::VERSION::STRING < '2.1'
|
27
|
+
base.class_eval do
|
28
|
+
class << self
|
29
|
+
def create_time_zone_conversion_attribute?(name, column)
|
30
|
+
false
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Adds check for cached date/time attributes which have been type cast already
|
38
|
+
# and value can be used from cache. This prevents the raw date/time value from
|
39
|
+
# being type cast using default Rails type casting when writing values
|
40
|
+
# to the database.
|
41
|
+
def read_attribute(attr_name)
|
42
|
+
attr_name = attr_name.to_s
|
43
|
+
if !(value = @attributes[attr_name]).nil?
|
44
|
+
if column = column_for_attribute(attr_name)
|
45
|
+
if unserializable_attribute?(attr_name, column)
|
46
|
+
unserialize_attribute(attr_name)
|
47
|
+
elsif [:date, :time, :datetime].include?(column.type) && @attributes_cache.has_key?(attr_name)
|
48
|
+
@attributes_cache[attr_name]
|
49
|
+
else
|
50
|
+
column.type_cast(value)
|
51
|
+
end
|
52
|
+
else
|
53
|
+
value
|
54
|
+
end
|
55
|
+
else
|
56
|
+
nil
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Writes attribute value by storing raw value in attributes hash,
|
61
|
+
# then convert it with parser and cache it.
|
62
|
+
#
|
63
|
+
# If Rails dirty attributes is enabled then the value is added to
|
64
|
+
# changed attributes if changed. Can't use the default dirty checking
|
65
|
+
# implementation as it chains the write_attribute method which deletes
|
66
|
+
# the attribute from the cache.
|
67
|
+
def write_date_time_attribute(attr_name, value)
|
68
|
+
column = column_for_attribute(attr_name)
|
69
|
+
old = read_attribute(attr_name) if defined?(::ActiveRecord::Dirty)
|
70
|
+
new = self.class.parse_date_time(value, column.type)
|
71
|
+
|
72
|
+
unless column.type == :date || new.nil?
|
73
|
+
new = new.to_time rescue new
|
74
|
+
end
|
75
|
+
|
76
|
+
if self.class.send(:create_time_zone_conversion_attribute?, attr_name, column)
|
77
|
+
new = new.in_time_zone rescue nil
|
78
|
+
end
|
79
|
+
@attributes_cache[attr_name] = new
|
80
|
+
|
81
|
+
if defined?(::ActiveRecord::Dirty) && !changed_attributes.include?(attr_name) && old != new
|
82
|
+
changed_attributes[attr_name] = (old.duplicable? ? old.clone : old)
|
83
|
+
end
|
84
|
+
@attributes[attr_name] = value
|
85
|
+
end
|
86
|
+
|
87
|
+
module ClassMethods
|
88
|
+
|
89
|
+
# Override AR method to define attribute reader and writer method for
|
90
|
+
# date, time and datetime attributes to use plugin parser.
|
91
|
+
def define_attribute_methods
|
92
|
+
return if generated_methods?
|
93
|
+
columns_hash.each do |name, column|
|
94
|
+
unless instance_method_already_implemented?(name)
|
95
|
+
if self.serialized_attributes[name]
|
96
|
+
define_read_method_for_serialized_attribute(name)
|
97
|
+
elsif create_time_zone_conversion_attribute?(name, column)
|
98
|
+
define_read_method_for_time_zone_conversion(name)
|
99
|
+
else
|
100
|
+
define_read_method(name.to_sym, name, column)
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
unless instance_method_already_implemented?("#{name}=")
|
105
|
+
if [:date, :time, :datetime].include?(column.type)
|
106
|
+
define_write_method_for_dates_and_times(name)
|
107
|
+
else
|
108
|
+
define_write_method(name.to_sym)
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
unless instance_method_already_implemented?("#{name}?")
|
113
|
+
define_question_method(name)
|
114
|
+
end
|
115
|
+
end
|
116
|
+
end
|
117
|
+
|
118
|
+
# Define write method for date, time and datetime columns
|
119
|
+
def define_write_method_for_dates_and_times(attr_name)
|
120
|
+
method_body = <<-EOV
|
121
|
+
def #{attr_name}=(value)
|
122
|
+
write_date_time_attribute('#{attr_name}', value)
|
123
|
+
end
|
124
|
+
EOV
|
125
|
+
evaluate_attribute_method attr_name, method_body, "#{attr_name}="
|
126
|
+
end
|
127
|
+
|
128
|
+
# Define time attribute reader. If reloading then check if cached,
|
129
|
+
# which means its in local time. If local, convert with parser as local
|
130
|
+
# timezone, otherwise use read_attribute method for quick default type
|
131
|
+
# cast of values from database using default timezone.
|
132
|
+
def define_read_method_for_time_zone_conversion(attr_name)
|
133
|
+
method_body = <<-EOV
|
134
|
+
def #{attr_name}(reload = false)
|
135
|
+
cached = @attributes_cache['#{attr_name}']
|
136
|
+
return cached if @attributes_cache.has_key?('#{attr_name}') && !reload
|
137
|
+
if @attributes_cache.has_key?('#{attr_name}')
|
138
|
+
time = read_attribute_before_type_cast('#{attr_name}')
|
139
|
+
time = self.class.parse_date_time(date, :datetime)
|
140
|
+
else
|
141
|
+
time = read_attribute('#{attr_name}')
|
142
|
+
@attributes['#{attr_name}'] = time.in_time_zone rescue nil
|
143
|
+
end
|
144
|
+
@attributes_cache['#{attr_name}'] = time.in_time_zone rescue nil
|
145
|
+
end
|
146
|
+
EOV
|
147
|
+
evaluate_attribute_method attr_name, method_body
|
148
|
+
end
|
149
|
+
|
150
|
+
end
|
151
|
+
|
152
|
+
end
|
153
|
+
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::AttributeMethods)
|
@@ -0,0 +1,64 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module ActiveRecord
|
3
|
+
|
4
|
+
module MultiparameterAttributes
|
5
|
+
|
6
|
+
def self.included(base)
|
7
|
+
base.alias_method_chain :execute_callstack_for_multiparameter_attributes, :timeliness
|
8
|
+
end
|
9
|
+
|
10
|
+
# Overrides AR method to store multiparameter time and dates as string
|
11
|
+
# allowing validation later.
|
12
|
+
def execute_callstack_for_multiparameter_attributes_with_timeliness(callstack)
|
13
|
+
errors = []
|
14
|
+
callstack.each do |name, values|
|
15
|
+
klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
|
16
|
+
if values.empty?
|
17
|
+
send(name + "=", nil)
|
18
|
+
else
|
19
|
+
column = column_for_attribute(name)
|
20
|
+
begin
|
21
|
+
value = if [:date, :time, :datetime].include?(column.type)
|
22
|
+
time_array_to_string(values, column.type)
|
23
|
+
else
|
24
|
+
klass.new(*values)
|
25
|
+
end
|
26
|
+
send(name + "=", value)
|
27
|
+
rescue => ex
|
28
|
+
errors << ::ActiveRecord::AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
unless errors.empty?
|
33
|
+
raise ::ActiveRecord::MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def time_array_to_string(values, type)
|
38
|
+
values = values.map(&:to_s)
|
39
|
+
|
40
|
+
case type
|
41
|
+
when :date
|
42
|
+
extract_date_from_multiparameter_attributes(values)
|
43
|
+
when :time
|
44
|
+
extract_time_from_multiparameter_attributes(values)
|
45
|
+
when :datetime
|
46
|
+
date_values, time_values = values.slice!(0, 3), values
|
47
|
+
extract_date_from_multiparameter_attributes(date_values) + " " + extract_time_from_multiparameter_attributes(time_values)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def extract_date_from_multiparameter_attributes(values)
|
52
|
+
[values[0], *values.slice(1, 2).map { |s| s.rjust(2, "0") }].join("-")
|
53
|
+
end
|
54
|
+
|
55
|
+
def extract_time_from_multiparameter_attributes(values)
|
56
|
+
values.last(3).map { |s| s.rjust(2, "0") }.join(":")
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
60
|
+
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::MultiparameterAttributes)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module CoreExtensions
|
3
|
+
module Date
|
4
|
+
|
5
|
+
def to_dummy_time
|
6
|
+
::Time.send(ValidatesTimeliness.default_timezone, 2000, 1, 1, 0, 0, 0)
|
7
|
+
end
|
8
|
+
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
Date.send(:include, ValidatesTimeliness::CoreExtensions::Date)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module CoreExtensions
|
3
|
+
module DateTime
|
4
|
+
|
5
|
+
def to_dummy_time
|
6
|
+
::Time.send(ValidatesTimeliness.default_timezone, 2000, 1, 1, hour, min, sec)
|
7
|
+
end
|
8
|
+
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
DateTime.send(:include, ValidatesTimeliness::CoreExtensions::DateTime)
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
module CoreExtensions
|
3
|
+
module Time
|
4
|
+
|
5
|
+
def to_dummy_time
|
6
|
+
self.class.send(ValidatesTimeliness.default_timezone, 2000, 1, 1, hour, min, sec)
|
7
|
+
end
|
8
|
+
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
Time.send(:include, ValidatesTimeliness::CoreExtensions::Time)
|
@@ -0,0 +1,310 @@
|
|
1
|
+
module ValidatesTimeliness
|
2
|
+
|
3
|
+
# A date and time format regular expression generator. Allows you to
|
4
|
+
# construct a date, time or datetime format using predefined tokens in
|
5
|
+
# a string. This makes it much easier to catalogue and customize the formats
|
6
|
+
# rather than dealing directly with regular expressions. The formats are then
|
7
|
+
# compiled into regular expressions for use validating date or time strings.
|
8
|
+
#
|
9
|
+
# Formats can be added or removed to customize the set of valid date or time
|
10
|
+
# string values.
|
11
|
+
#
|
12
|
+
class Formats
|
13
|
+
cattr_accessor :time_formats
|
14
|
+
cattr_accessor :date_formats
|
15
|
+
cattr_accessor :datetime_formats
|
16
|
+
|
17
|
+
cattr_accessor :time_expressions
|
18
|
+
cattr_accessor :date_expressions
|
19
|
+
cattr_accessor :datetime_expressions
|
20
|
+
|
21
|
+
cattr_accessor :format_tokens
|
22
|
+
cattr_accessor :format_proc_args
|
23
|
+
|
24
|
+
# Format tokens:
|
25
|
+
# y = year
|
26
|
+
# m = month
|
27
|
+
# d = day
|
28
|
+
# h = hour
|
29
|
+
# n = minute
|
30
|
+
# s = second
|
31
|
+
# u = micro-seconds
|
32
|
+
# ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
|
33
|
+
# _ = optional space
|
34
|
+
# tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
|
35
|
+
# zo = Timezone offset (e.g. +10:00, -08:00, +1000)
|
36
|
+
#
|
37
|
+
# All other characters are considered literal. You can embed regexp in the
|
38
|
+
# format but no gurantees that it will remain intact. If you avoid the use
|
39
|
+
# of any token characters and regexp dots or backslashes as special characters
|
40
|
+
# in the regexp, it may well work as expected. For special characters use
|
41
|
+
# POSIX character clsses for safety.
|
42
|
+
#
|
43
|
+
# Repeating tokens:
|
44
|
+
# x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
|
45
|
+
# xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
|
46
|
+
#
|
47
|
+
# Special Cases:
|
48
|
+
# yy = 2 or 4 digit year
|
49
|
+
# yyyyy = exactly 4 digit year
|
50
|
+
# mmm = month long name (e.g. 'Jul' or 'July')
|
51
|
+
# ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
|
52
|
+
# u = microseconds matches 1 to 6 digits
|
53
|
+
#
|
54
|
+
# Any other invalid combination of repeating tokens will be swallowed up
|
55
|
+
# by the next lowest length valid repeating token (e.g. yyy will be
|
56
|
+
# replaced with yy)
|
57
|
+
|
58
|
+
@@time_formats = [
|
59
|
+
'hh:nn:ss',
|
60
|
+
'hh-nn-ss',
|
61
|
+
'h:nn',
|
62
|
+
'h.nn',
|
63
|
+
'h nn',
|
64
|
+
'h-nn',
|
65
|
+
'h:nn_ampm',
|
66
|
+
'h.nn_ampm',
|
67
|
+
'h nn_ampm',
|
68
|
+
'h-nn_ampm',
|
69
|
+
'h_ampm'
|
70
|
+
]
|
71
|
+
|
72
|
+
@@date_formats = [
|
73
|
+
'yyyy-mm-dd',
|
74
|
+
'yyyy/mm/dd',
|
75
|
+
'yyyy.mm.dd',
|
76
|
+
'm/d/yy',
|
77
|
+
'd/m/yy',
|
78
|
+
'm\d\yy',
|
79
|
+
'd\m\yy',
|
80
|
+
'd-m-yy',
|
81
|
+
'd.m.yy',
|
82
|
+
'd mmm yy'
|
83
|
+
]
|
84
|
+
|
85
|
+
@@datetime_formats = [
|
86
|
+
'yyyy-mm-dd hh:nn:ss',
|
87
|
+
'yyyy-mm-dd h:nn',
|
88
|
+
'yyyy-mm-dd hh:nn:ss.u',
|
89
|
+
'm/d/yy h:nn:ss',
|
90
|
+
'm/d/yy h:nn_ampm',
|
91
|
+
'm/d/yy h:nn',
|
92
|
+
'd/m/yy hh:nn:ss',
|
93
|
+
'd/m/yy h:nn_ampm',
|
94
|
+
'd/m/yy h:nn',
|
95
|
+
'ddd, dd mmm yyyy hh:nn:ss (zo|tz)', # RFC 822
|
96
|
+
'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
|
97
|
+
'yyyy-mm-ddThh:nn:ss(?:Z|zo)' # iso 8601
|
98
|
+
]
|
99
|
+
|
100
|
+
|
101
|
+
# All tokens available for format construction. The token array is made of
|
102
|
+
# token regexp, validation regexp and key for format proc mapping if any.
|
103
|
+
# If the token needs no format proc arg then the validation regexp should
|
104
|
+
# not have a capturing group, as all captured groups are passed to the
|
105
|
+
# format proc.
|
106
|
+
#
|
107
|
+
# The token regexp should only use a capture group if 'look-behind' anchor
|
108
|
+
# is required. The first capture group will be considered a literal and put
|
109
|
+
# into the validation regexp string as-is. This is a hack.
|
110
|
+
@@format_tokens = [
|
111
|
+
{ 'd' => [ /(\A|[^d])d{1}(?=[^d])/, '(\d{1,2})', :day ] }, #/
|
112
|
+
{ 'ddd' => [ /d{3,}/, '(\w{3,9})' ] },
|
113
|
+
{ 'dd' => [ /d{2,}/, '(\d{2})', :day ] },
|
114
|
+
{ 'mmm' => [ /m{3,}/, '(\w{3,9})', :month ] },
|
115
|
+
{ 'mm' => [ /m{2}/, '(\d{2})', :month ] },
|
116
|
+
{ 'm' => [ /(\A|[^ap])m{1}/, '(\d{1,2})', :month ] },
|
117
|
+
{ 'yyyy' => [ /y{4,}/, '(\d{4})', :year ] },
|
118
|
+
{ 'yy' => [ /y{2,}/, '(\d{2}|\d{4})', :year ] },
|
119
|
+
{ 'hh' => [ /h{2,}/, '(\d{2})', :hour ] },
|
120
|
+
{ 'h' => [ /h{1}/, '(\d{1,2})', :hour ] },
|
121
|
+
{ 'nn' => [ /n{2,}/, '(\d{2})', :min ] },
|
122
|
+
{ 'n' => [ /n{1}/, '(\d{1,2})', :min ] },
|
123
|
+
{ 'ss' => [ /s{2,}/, '(\d{2})', :sec ] },
|
124
|
+
{ 's' => [ /s{1}/, '(\d{1,2})', :sec ] },
|
125
|
+
{ 'u' => [ /u{1,}/, '(\d{1,6})', :usec ] },
|
126
|
+
{ 'ampm' => [ /ampm/, '((?:[aApP])\.?[mM]\.?)', :meridian ] },
|
127
|
+
{ 'zo' => [ /zo/, '(?:[+-]\d{2}:?\d{2})'] },
|
128
|
+
{ 'tz' => [ /tz/, '(?:[A-Z]{1,4})' ] },
|
129
|
+
{ '_' => [ /_/, '\s?' ] }
|
130
|
+
]
|
131
|
+
|
132
|
+
# Arguments whichs will be passed to the format proc if matched in the
|
133
|
+
# time string. The key must should the key from the format tokens. The array
|
134
|
+
# consists of the arry position of the arg, the arg name, and the code to
|
135
|
+
# place in the time array slot. The position can be nil which means the arg
|
136
|
+
# won't be placed in the array.
|
137
|
+
#
|
138
|
+
# The code can be used to manipulate the arg value if required, otherwise
|
139
|
+
# should just be the arg name.
|
140
|
+
#
|
141
|
+
@@format_proc_args = {
|
142
|
+
:year => [0, 'y', 'unambiguous_year(y)'],
|
143
|
+
:month => [1, 'm', 'month_index(m)'],
|
144
|
+
:day => [2, 'd', 'd'],
|
145
|
+
:hour => [3, 'h', 'full_hour(h,md)'],
|
146
|
+
:min => [4, 'n', 'n'],
|
147
|
+
:sec => [5, 's', 's'],
|
148
|
+
:usec => [6, 'u', 'microseconds(u)'],
|
149
|
+
:meridian => [nil, 'md', nil]
|
150
|
+
}
|
151
|
+
|
152
|
+
class << self
|
153
|
+
|
154
|
+
def compile_format_expressions
|
155
|
+
@@time_expressions = compile_formats(@@time_formats)
|
156
|
+
@@date_expressions = compile_formats(@@date_formats)
|
157
|
+
@@datetime_expressions = compile_formats(@@datetime_formats)
|
158
|
+
end
|
159
|
+
|
160
|
+
# Loop through format expressions for type and call proc on matches. Allow
|
161
|
+
# pre or post match strings to exist if strict is false. Otherwise wrap
|
162
|
+
# regexp in start and end anchors.
|
163
|
+
# Returns 7 part time array.
|
164
|
+
def parse(string, type, strict=true)
|
165
|
+
return string unless string.is_a?(String)
|
166
|
+
|
167
|
+
expressions = expression_set(type, string)
|
168
|
+
time_array = nil
|
169
|
+
expressions.each do |(regexp, processor)|
|
170
|
+
regexp = strict || type == :datetime ? /\A#{regexp}\Z/ : (type == :date ? /\A#{regexp}/ : /#{regexp}\Z/)
|
171
|
+
if matches = regexp.match(string.strip)
|
172
|
+
time_array = processor.call(*matches[1..7])
|
173
|
+
break
|
174
|
+
end
|
175
|
+
end
|
176
|
+
return time_array
|
177
|
+
end
|
178
|
+
|
179
|
+
# Delete formats of specified type. Error raised if format not found.
|
180
|
+
def remove_formats(type, *remove_formats)
|
181
|
+
remove_formats.each do |format|
|
182
|
+
unless self.send("#{type}_formats").delete(format)
|
183
|
+
raise "Format #{format} not found in #{type} formats"
|
184
|
+
end
|
185
|
+
end
|
186
|
+
compile_format_expressions
|
187
|
+
end
|
188
|
+
|
189
|
+
# Adds new formats. Must specify format type and can specify a :before
|
190
|
+
# option to nominate which format the new formats should be inserted in
|
191
|
+
# front on to take higher precedence.
|
192
|
+
# Error is raised if format already exists or if :before format is not found.
|
193
|
+
def add_formats(type, *add_formats)
|
194
|
+
formats = self.send("#{type}_formats")
|
195
|
+
options = {}
|
196
|
+
options = add_formats.pop if add_formats.last.is_a?(Hash)
|
197
|
+
before = options[:before]
|
198
|
+
raise "Format for :before option #{format} was not found." if before && !formats.include?(before)
|
199
|
+
|
200
|
+
add_formats.each do |format|
|
201
|
+
raise "Format #{format} is already included in #{type} formats" if formats.include?(format)
|
202
|
+
|
203
|
+
index = before ? formats.index(before) : -1
|
204
|
+
formats.insert(index, format)
|
205
|
+
end
|
206
|
+
compile_format_expressions
|
207
|
+
end
|
208
|
+
|
209
|
+
|
210
|
+
# Removes formats where the 1 or 2 digit month comes first, to eliminate
|
211
|
+
# formats which are ambiguous with the European style of day then month.
|
212
|
+
# The mmm token is ignored as its not ambigous.
|
213
|
+
def remove_us_formats
|
214
|
+
us_format_regexp = /\Am{1,2}[^m]/
|
215
|
+
date_formats.reject! { |format| us_format_regexp =~ format }
|
216
|
+
datetime_formats.reject! { |format| us_format_regexp =~ format }
|
217
|
+
compile_format_expressions
|
218
|
+
end
|
219
|
+
|
220
|
+
private
|
221
|
+
|
222
|
+
# Compile formats into validation regexps and format procs
|
223
|
+
def format_expression_generator(string_format)
|
224
|
+
regexp = string_format.dup
|
225
|
+
order = {}
|
226
|
+
regexp.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes ]/
|
227
|
+
|
228
|
+
format_tokens.each do |token|
|
229
|
+
token_name = token.keys.first
|
230
|
+
token_regexp, regexp_str, arg_key = *token.values.first
|
231
|
+
|
232
|
+
# hack for lack of look-behinds. If has a capture group then is
|
233
|
+
# considered an anchor to put straight back in the regexp string.
|
234
|
+
regexp.gsub!(token_regexp) {|m| "#{$1}" + regexp_str }
|
235
|
+
order[arg_key] = $~.begin(0) if $~ && !arg_key.nil?
|
236
|
+
end
|
237
|
+
|
238
|
+
return Regexp.new(regexp), format_proc(order)
|
239
|
+
rescue
|
240
|
+
puts "The following format regular expression failed to compile: #{regexp}\n from format #{string_format}."
|
241
|
+
raise
|
242
|
+
end
|
243
|
+
|
244
|
+
# Generates a proc which when executed maps the regexp capture groups to a
|
245
|
+
# proc argument based on order captured. A time array is built using the proc
|
246
|
+
# argument in the position indicated by the first element of the proc arg
|
247
|
+
# array.
|
248
|
+
#
|
249
|
+
# Examples:
|
250
|
+
#
|
251
|
+
# '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 {|i| i.to_i } }
|
252
|
+
# '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 {|i| i.to_i } }
|
253
|
+
#
|
254
|
+
def format_proc(order)
|
255
|
+
arg_map = format_proc_args
|
256
|
+
args = order.invert.sort.map {|p| arg_map[p[1]][1] }
|
257
|
+
arr = [nil] * 7
|
258
|
+
order.keys.each {|k| i = arg_map[k][0]; arr[i] = arg_map[k][2] unless i.nil? }
|
259
|
+
proc_string = "lambda {|#{args.join(',')}| md||=nil; [#{arr.map {|i| i.nil? ? 'nil' : i }.join(',')}].map {|i| i.to_i } }"
|
260
|
+
eval proc_string
|
261
|
+
end
|
262
|
+
|
263
|
+
def compile_formats(formats)
|
264
|
+
formats.collect { |format| regexp, format_proc = format_expression_generator(format) }
|
265
|
+
end
|
266
|
+
|
267
|
+
# Pick expression set and combine date and datetimes for
|
268
|
+
# datetime attributes to allow date string as datetime
|
269
|
+
def expression_set(type, string)
|
270
|
+
case type
|
271
|
+
when :date
|
272
|
+
date_expressions
|
273
|
+
when :time
|
274
|
+
time_expressions
|
275
|
+
when :datetime
|
276
|
+
# gives a speed-up for date string as datetime attributes
|
277
|
+
if string.length < 11
|
278
|
+
date_expressions + datetime_expressions
|
279
|
+
else
|
280
|
+
datetime_expressions + date_expressions
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def full_hour(hour, meridian)
|
286
|
+
hour = hour.to_i
|
287
|
+
return hour if meridian.nil?
|
288
|
+
if meridian.delete('.').downcase == 'am'
|
289
|
+
hour == 12 ? 0 : hour
|
290
|
+
else
|
291
|
+
hour == 12 ? hour : hour + 12
|
292
|
+
end
|
293
|
+
end
|
294
|
+
|
295
|
+
def unambiguous_year(year, threshold=30)
|
296
|
+
year = "#{year.to_i < threshold ? '20' : '19'}#{year}" if year.length == 2
|
297
|
+
year.to_i
|
298
|
+
end
|
299
|
+
|
300
|
+
def month_index(month)
|
301
|
+
return month.to_i if month.to_i.nonzero?
|
302
|
+
Date::ABBR_MONTHNAMES.index(month.capitalize) || Date::MONTHNAMES.index(month.capitalize)
|
303
|
+
end
|
304
|
+
|
305
|
+
def microseconds(usec)
|
306
|
+
(".#{usec}".to_f * 1_000_000).to_i
|
307
|
+
end
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|