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.
Files changed (35) hide show
  1. data/CHANGELOG +43 -0
  2. data/LICENSE +20 -0
  3. data/README.rdoc +320 -0
  4. data/Rakefile +58 -0
  5. data/TODO +8 -0
  6. data/lib/validates_timeliness.rb +66 -0
  7. data/lib/validates_timeliness/action_view/instance_tag.rb +45 -0
  8. data/lib/validates_timeliness/active_record/attribute_methods.rb +157 -0
  9. data/lib/validates_timeliness/active_record/multiparameter_attributes.rb +64 -0
  10. data/lib/validates_timeliness/core_ext/date.rb +13 -0
  11. data/lib/validates_timeliness/core_ext/date_time.rb +13 -0
  12. data/lib/validates_timeliness/core_ext/time.rb +13 -0
  13. data/lib/validates_timeliness/formats.rb +310 -0
  14. data/lib/validates_timeliness/locale/en.yml +11 -0
  15. data/lib/validates_timeliness/spec/rails/matchers/validate_timeliness.rb +121 -0
  16. data/lib/validates_timeliness/validation_methods.rb +82 -0
  17. data/lib/validates_timeliness/validator.rb +120 -0
  18. data/spec/action_view/instance_tag_spec.rb +38 -0
  19. data/spec/active_record/attribute_methods_spec.rb +204 -0
  20. data/spec/active_record/multiparameter_attributes_spec.rb +48 -0
  21. data/spec/core_ext/dummy_time_spec.rb +31 -0
  22. data/spec/formats_spec.rb +274 -0
  23. data/spec/ginger_scenarios.rb +19 -0
  24. data/spec/resources/application.rb +2 -0
  25. data/spec/resources/person.rb +3 -0
  26. data/spec/resources/schema.rb +10 -0
  27. data/spec/resources/sqlite_patch.rb +19 -0
  28. data/spec/spec/rails/matchers/validate_timeliness_spec.rb +178 -0
  29. data/spec/spec_helper.rb +54 -0
  30. data/spec/time_travel/MIT-LICENSE +20 -0
  31. data/spec/time_travel/time_extensions.rb +33 -0
  32. data/spec/time_travel/time_travel.rb +12 -0
  33. data/spec/validation_methods_spec.rb +61 -0
  34. data/spec/validator_spec.rb +438 -0
  35. 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