validates_timeliness 2.3.2 → 3.0.0.beta

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 (59) hide show
  1. data/CHANGELOG +12 -4
  2. data/LICENSE +1 -1
  3. data/README.rdoc +138 -280
  4. data/Rakefile +30 -16
  5. data/lib/generators/validates_timeliness/install_generator.rb +17 -0
  6. data/lib/generators/validates_timeliness/templates/en.yml +16 -0
  7. data/lib/generators/validates_timeliness/templates/validates_timeliness.rb +22 -0
  8. data/lib/validates_timeliness.rb +46 -52
  9. data/lib/validates_timeliness/attribute_methods.rb +51 -0
  10. data/lib/validates_timeliness/conversion.rb +69 -0
  11. data/lib/validates_timeliness/extensions.rb +14 -0
  12. data/lib/validates_timeliness/extensions/date_time_select.rb +45 -0
  13. data/lib/validates_timeliness/extensions/multiparameter_handler.rb +31 -0
  14. data/lib/validates_timeliness/helper_methods.rb +41 -0
  15. data/lib/validates_timeliness/orms/active_record.rb +14 -0
  16. data/lib/validates_timeliness/parser.rb +389 -17
  17. data/lib/validates_timeliness/validator.rb +37 -200
  18. data/lib/validates_timeliness/version.rb +1 -1
  19. data/spec/model_helpers.rb +27 -0
  20. data/spec/spec_helper.rb +74 -43
  21. data/spec/test_model.rb +56 -0
  22. data/spec/validates_timeliness/attribute_methods_spec.rb +36 -0
  23. data/spec/validates_timeliness/conversion_spec.rb +204 -0
  24. data/spec/validates_timeliness/extensions/date_time_select_spec.rb +178 -0
  25. data/spec/validates_timeliness/extensions/multiparameter_handler_spec.rb +21 -0
  26. data/spec/validates_timeliness/helper_methods_spec.rb +36 -0
  27. data/spec/{formats_spec.rb → validates_timeliness/parser_spec.rb} +105 -71
  28. data/spec/validates_timeliness/validator/after_spec.rb +59 -0
  29. data/spec/validates_timeliness/validator/before_spec.rb +59 -0
  30. data/spec/validates_timeliness/validator/is_at_spec.rb +63 -0
  31. data/spec/validates_timeliness/validator/on_or_after_spec.rb +59 -0
  32. data/spec/validates_timeliness/validator/on_or_before_spec.rb +59 -0
  33. data/spec/validates_timeliness/validator_spec.rb +172 -0
  34. data/validates_timeliness.gemspec +30 -0
  35. metadata +42 -40
  36. data/TODO +0 -8
  37. data/lib/validates_timeliness/action_view/instance_tag.rb +0 -52
  38. data/lib/validates_timeliness/active_record/attribute_methods.rb +0 -77
  39. data/lib/validates_timeliness/active_record/multiparameter_attributes.rb +0 -69
  40. data/lib/validates_timeliness/formats.rb +0 -368
  41. data/lib/validates_timeliness/locale/en.new.yml +0 -18
  42. data/lib/validates_timeliness/locale/en.old.yml +0 -18
  43. data/lib/validates_timeliness/matcher.rb +0 -1
  44. data/lib/validates_timeliness/spec/rails/matchers/validate_timeliness.rb +0 -162
  45. data/lib/validates_timeliness/validation_methods.rb +0 -46
  46. data/spec/action_view/instance_tag_spec.rb +0 -194
  47. data/spec/active_record/attribute_methods_spec.rb +0 -157
  48. data/spec/active_record/multiparameter_attributes_spec.rb +0 -118
  49. data/spec/ginger_scenarios.rb +0 -19
  50. data/spec/parser_spec.rb +0 -65
  51. data/spec/resources/application.rb +0 -2
  52. data/spec/resources/person.rb +0 -3
  53. data/spec/resources/schema.rb +0 -10
  54. data/spec/resources/sqlite_patch.rb +0 -19
  55. data/spec/spec/rails/matchers/validate_timeliness_spec.rb +0 -245
  56. data/spec/time_travel/MIT-LICENSE +0 -20
  57. data/spec/time_travel/time_extensions.rb +0 -33
  58. data/spec/time_travel/time_travel.rb +0 -12
  59. data/spec/validator_spec.rb +0 -723
@@ -0,0 +1,41 @@
1
+ module ValidatesTimeliness
2
+ module HelperMethods
3
+ extend ActiveSupport::Concern
4
+
5
+ included do
6
+ include ValidationMethods
7
+ extend ValidationMethods
8
+ class_inheritable_hash :timeliness_validated_attributes
9
+ self.timeliness_validated_attributes = {}
10
+ end
11
+
12
+ module ValidationMethods
13
+ def validates_timeliness_of(*attr_names)
14
+ options = _merge_attributes(attr_names)
15
+ attributes = options[:attributes].inject({}) {|validated, attr_name|
16
+ attr_name = attr_name.to_s
17
+ validated[attr_name] = options[:type]
18
+ validated
19
+ }
20
+ self.timeliness_validated_attributes = attributes
21
+ validates_with Validator, options
22
+ end
23
+
24
+ def validates_date(*attr_names)
25
+ options = attr_names.extract_options!
26
+ validates_timeliness_of *(attr_names << options.merge(:type => :date))
27
+ end
28
+
29
+ def validates_time(*attr_names)
30
+ options = attr_names.extract_options!
31
+ validates_timeliness_of *(attr_names << options.merge(:type => :time))
32
+ end
33
+
34
+ def validates_datetime(*attr_names)
35
+ options = attr_names.extract_options!
36
+ validates_timeliness_of *(attr_names << options.merge(:type => :datetime))
37
+ end
38
+
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ class ActiveRecord::Base
2
+ include ValidatesTimeliness::HelperMethods
3
+ include ValidatesTimeliness::AttributeMethods
4
+
5
+ def self.define_attribute_methods
6
+ super
7
+ # Define write method and before_type_cast method
8
+ define_timeliness_methods(true)
9
+ end
10
+
11
+ def self.timeliness_attribute_timezone_aware?(attr_name)
12
+ create_time_zone_conversion_attribute?(attr_name, columns_hash[attr_name])
13
+ end
14
+ end
@@ -1,44 +1,416 @@
1
+ require 'date'
2
+
1
3
  module ValidatesTimeliness
2
- module Parser
4
+
5
+ # A date and time parsing library which allows you to add custom formats using
6
+ # simple predefined tokens. This makes it much easier to catalogue and customize
7
+ # the formats rather than dealing directly with regular expressions.
8
+ #
9
+ # Formats can be added or removed to customize the set of valid date or time
10
+ # string values.
11
+ #
12
+ class Parser
13
+ cattr_accessor :time_formats,
14
+ :date_formats,
15
+ :datetime_formats,
16
+ :time_expressions,
17
+ :date_expressions,
18
+ :datetime_expressions,
19
+ :format_tokens,
20
+ :format_proc_args
21
+
22
+
23
+ # Set the threshold value for a two digit year to be considered last century
24
+ #
25
+ # Default: 30
26
+ #
27
+ # Example:
28
+ # year = '29' is considered 2029
29
+ # year = '30' is considered 1930
30
+ #
31
+ cattr_accessor :ambiguous_year_threshold
32
+ self.ambiguous_year_threshold = 30
33
+
34
+ # Set the dummy date part for a time type value. Should be an array of 3 values
35
+ # being year, month and day in that order.
36
+ #
37
+ # Default: [ 2000, 1, 1 ] same as ActiveRecord
38
+ #
39
+ cattr_accessor :dummy_date_for_time_type
40
+ self.dummy_date_for_time_type = [ 2000, 1, 1 ]
41
+
42
+ # Format tokens:
43
+ # y = year
44
+ # m = month
45
+ # d = day
46
+ # h = hour
47
+ # n = minute
48
+ # s = second
49
+ # u = micro-seconds
50
+ # ampm = meridian (am or pm) with or without dots (e.g. am, a.m, or a.m.)
51
+ # _ = optional space
52
+ # tz = Timezone abbreviation (e.g. UTC, GMT, PST, EST)
53
+ # zo = Timezone offset (e.g. +10:00, -08:00, +1000)
54
+ #
55
+ # All other characters are considered literal. You can embed regexp in the
56
+ # format but no gurantees that it will remain intact. If you avoid the use
57
+ # of any token characters and regexp dots or backslashes as special characters
58
+ # in the regexp, it may well work as expected. For special characters use
59
+ # POSIX character clsses for safety.
60
+ #
61
+ # Repeating tokens:
62
+ # x = 1 or 2 digits for unit (e.g. 'h' means an hour can be '9' or '09')
63
+ # xx = 2 digits exactly for unit (e.g. 'hh' means an hour can only be '09')
64
+ #
65
+ # Special Cases:
66
+ # yy = 2 or 4 digit year
67
+ # yyyy = exactly 4 digit year
68
+ # mmm = month long name (e.g. 'Jul' or 'July')
69
+ # ddd = Day name of 3 to 9 letters (e.g. Wed or Wednesday)
70
+ # u = microseconds matches 1 to 6 digits
71
+ #
72
+ # Any other invalid combination of repeating tokens will be swallowed up
73
+ # by the next lowest length valid repeating token (e.g. yyy will be
74
+ # replaced with yy)
75
+
76
+ @@time_formats = [
77
+ 'hh:nn:ss',
78
+ 'hh-nn-ss',
79
+ 'h:nn',
80
+ 'h.nn',
81
+ 'h nn',
82
+ 'h-nn',
83
+ 'h:nn_ampm',
84
+ 'h.nn_ampm',
85
+ 'h nn_ampm',
86
+ 'h-nn_ampm',
87
+ 'h_ampm'
88
+ ]
89
+
90
+ @@date_formats = [
91
+ 'yyyy-mm-dd',
92
+ 'yyyy/mm/dd',
93
+ 'yyyy.mm.dd',
94
+ 'm/d/yy',
95
+ 'd/m/yy',
96
+ 'm\d\yy',
97
+ 'd\m\yy',
98
+ 'd-m-yy',
99
+ 'dd-mm-yyyy',
100
+ 'd.m.yy',
101
+ 'd mmm yy'
102
+ ]
103
+
104
+ @@datetime_formats = [
105
+ 'yyyy-mm-dd hh:nn:ss',
106
+ 'yyyy-mm-dd h:nn',
107
+ 'yyyy-mm-dd h:nn_ampm',
108
+ 'yyyy-mm-dd hh:nn:ss.u',
109
+ 'm/d/yy h:nn:ss',
110
+ 'm/d/yy h:nn_ampm',
111
+ 'm/d/yy h:nn',
112
+ 'd/m/yy hh:nn:ss',
113
+ 'd/m/yy h:nn_ampm',
114
+ 'd/m/yy h:nn',
115
+ 'dd-mm-yyyy hh:nn:ss',
116
+ 'dd-mm-yyyy h:nn_ampm',
117
+ 'dd-mm-yyyy h:nn',
118
+ 'ddd, dd mmm yyyy hh:nn:ss (zo|tz)', # RFC 822
119
+ 'ddd mmm d hh:nn:ss zo yyyy', # Ruby time string
120
+ 'yyyy-mm-ddThh:nn:ssZ', # iso 8601 without zone offset
121
+ 'yyyy-mm-ddThh:nn:sszo' # iso 8601 with zone offset
122
+ ]
123
+
124
+
125
+ # All tokens available for format construction. The token array is made of
126
+ # validation regexp and key for format proc mapping if any.
127
+ # If the token needs no format proc arg then the validation regexp should
128
+ # not have a capturing group, as all captured groups are passed to the
129
+ # format proc.
130
+ #
131
+ # The token regexp should only use a capture group if 'look-behind' anchor
132
+ # is required. The first capture group will be considered a literal and put
133
+ # into the validation regexp string as-is. This is a hack.
134
+ #
135
+ @@format_tokens = {
136
+ 'ddd' => [ '\w{3,9}' ],
137
+ 'dd' => [ '\d{2}', :day ],
138
+ 'd' => [ '\d{1,2}', :day ],
139
+ 'ampm' => [ '[aApP]\.?[mM]\.?', :meridian ],
140
+ 'mmm' => [ '\w{3,9}', :month ],
141
+ 'mm' => [ '\d{2}', :month ],
142
+ 'm' => [ '\d{1,2}', :month ],
143
+ 'yyyy' => [ '\d{4}', :year ],
144
+ 'yy' => [ '\d{4}|\d{2}', :year ],
145
+ 'hh' => [ '\d{2}', :hour ],
146
+ 'h' => [ '\d{1,2}', :hour ],
147
+ 'nn' => [ '\d{2}', :min ],
148
+ 'n' => [ '\d{1,2}', :min ],
149
+ 'ss' => [ '\d{2}', :sec ],
150
+ 's' => [ '\d{1,2}', :sec ],
151
+ 'u' => [ '\d{1,6}', :usec ],
152
+ 'zo' => [ '[+-]\d{2}:?\d{2}', :offset ],
153
+ 'tz' => [ '[A-Z]{1,4}' ],
154
+ '_' => [ '\s?' ]
155
+ }
156
+
157
+ # Arguments which will be passed to the format proc if matched in the
158
+ # time string. The key must be the key from the format tokens. The array
159
+ # consists of the arry position of the arg, the arg name, and the code to
160
+ # place in the time array slot. The position can be nil which means the arg
161
+ # won't be placed in the array.
162
+ #
163
+ # The code can be used to manipulate the arg value if required, otherwise
164
+ # should just be the arg name.
165
+ #
166
+ @@format_proc_args = {
167
+ :year => [0, 'y', 'unambiguous_year(y)'],
168
+ :month => [1, 'm', 'month_index(m)'],
169
+ :day => [2, 'd', 'd'],
170
+ :hour => [3, 'h', 'full_hour(h, md ||= nil)'],
171
+ :min => [4, 'n', 'n'],
172
+ :sec => [5, 's', 's'],
173
+ :usec => [6, 'u', 'microseconds(u)'],
174
+ :offset => [7, 'z', 'offset_in_seconds(z)'],
175
+ :meridian => [nil, 'md', nil]
176
+ }
177
+
178
+
179
+ @@type_wrapper = {
180
+ :date => [/\A/, nil],
181
+ :time => [nil , /\Z/],
182
+ :datetime => [/\A/, /\Z/]
183
+ }
3
184
 
4
185
  class << self
5
186
 
187
+ def compile_format_expressions
188
+ @@time_expressions = compile_formats(@@time_formats)
189
+ @@date_expressions = compile_formats(@@date_formats)
190
+ @@datetime_expressions = compile_formats(@@datetime_formats)
191
+ end
192
+
6
193
  def parse(raw_value, type, options={})
7
194
  return nil if raw_value.blank?
8
- return raw_value if raw_value.acts_like?(:time) || raw_value.is_a?(Date)
195
+ return raw_value if raw_value.acts_like?(:time) || raw_value.acts_like?(:date)
9
196
 
10
- time_array = ValidatesTimeliness::Formats.parse(raw_value, type, options.reverse_merge(:strict => true))
197
+ time_array = _parse(raw_value, type, options.reverse_merge(:strict => true))
11
198
  return nil if time_array.nil?
12
199
 
13
200
  if type == :date
14
201
  Date.new(*time_array[0..2]) rescue nil
15
202
  else
16
- make_time(time_array[0..6])
203
+ make_time(time_array[0..7], options[:timezone_aware])
17
204
  end
18
205
  end
19
206
 
20
- def make_time(time_array)
21
- # Enforce date part validity which Time class does not
207
+ def make_time(time_array, timezone_aware=false)
208
+ # Enforce strict date part validity which Time class does not
22
209
  return nil unless Date.valid_civil?(*time_array[0..2])
23
210
 
24
- if Time.respond_to?(:zone) && ValidatesTimeliness.use_time_zones
211
+ if timezone_aware
25
212
  Time.zone.local(*time_array)
26
213
  else
27
- # Older AR way of handling times with datetime fallback
28
- begin
29
- time_zone = ValidatesTimeliness.default_timezone
30
- Time.send(time_zone, *time_array)
31
- rescue ArgumentError, TypeError
32
- zone_offset = time_zone == :local ? DateTime.local_offset : 0
33
- time_array.pop # remove microseconds
34
- DateTime.civil(*(time_array << zone_offset))
35
- end
214
+ Time.time_with_datetime_fallback(ValidatesTimeliness.default_timezone, *time_array)
36
215
  end
37
216
  rescue ArgumentError, TypeError
38
217
  nil
39
218
  end
40
219
 
41
- end
220
+ # Loop through format expressions for type and call the format method on a match.
221
+ # Allow pre or post match strings to exist if strict is false. Otherwise wrap
222
+ # regexp in start and end anchors.
223
+ #
224
+ # Returns time array if matches a format, nil otherwise.
225
+ #
226
+ def _parse(string, type, options={})
227
+ options.reverse_merge!(:strict => true)
42
228
 
229
+ sets = if options[:format]
230
+ options[:strict] = true
231
+ [ send("#{type}_expressions").assoc(options[:format]) ]
232
+ else
233
+ expression_set(type, string)
234
+ end
235
+
236
+ set = sets.find do |format, regexp|
237
+ string =~ wrap_regexp(regexp, type, options[:strict])
238
+ end
239
+
240
+ if set
241
+ last = options[:include_offset] ? 8 : 7
242
+ values = send(:"format_#{set[0]}", *$~[1..last])
243
+ values[0..2] = ValidatesTimeliness.dummy_date_for_time_type if type == :time
244
+ return values
245
+ end
246
+ rescue
247
+ nil
248
+ end
249
+
250
+ # Delete formats of specified type. Error raised if format not found.
251
+ def remove_formats(type, *remove_formats)
252
+ remove_formats.each do |format|
253
+ unless self.send("#{type}_formats").delete(format)
254
+ raise "Format #{format} not found in #{type} formats"
255
+ end
256
+ end
257
+ compile_format_expressions
258
+ end
259
+
260
+ # Adds new formats. Must specify format type and can specify a :before
261
+ # option to nominate which format the new formats should be inserted in
262
+ # front on to take higher precedence.
263
+ # Error is raised if format already exists or if :before format is not found.
264
+ def add_formats(type, *add_formats)
265
+ formats = self.send("#{type}_formats")
266
+ options = {}
267
+ options = add_formats.pop if add_formats.last.is_a?(Hash)
268
+ before = options[:before]
269
+ raise "Format for :before option #{format} was not found." if before && !formats.include?(before)
270
+
271
+ add_formats.each do |format|
272
+ raise "Format #{format} is already included in #{type} formats" if formats.include?(format)
273
+
274
+ index = before ? formats.index(before) : -1
275
+ formats.insert(index, format)
276
+ end
277
+ compile_format_expressions
278
+ end
279
+
280
+ # Removes formats where the 1 or 2 digit month comes first, to eliminate
281
+ # formats which are ambiguous with the European style of day then month.
282
+ # The mmm token is ignored as its not ambigous.
283
+ def remove_us_formats
284
+ us_format_regexp = /\Am{1,2}[^m]/
285
+ date_formats.reject! { |format| us_format_regexp =~ format }
286
+ datetime_formats.reject! { |format| us_format_regexp =~ format }
287
+ compile_format_expressions
288
+ end
289
+
290
+ def full_hour(hour, meridian)
291
+ hour = hour.to_i
292
+ return hour if meridian.nil?
293
+ if meridian.delete('.').downcase == 'am'
294
+ raise if hour == 0 || hour > 12
295
+ hour == 12 ? 0 : hour
296
+ else
297
+ hour == 12 ? hour : hour + 12
298
+ end
299
+ end
300
+
301
+ def unambiguous_year(year)
302
+ if year.length <= 2
303
+ century = Time.now.year.to_s[0..1].to_i
304
+ century -= 1 if year.to_i >= ambiguous_year_threshold
305
+ year = "#{century}#{year.rjust(2,'0')}"
306
+ end
307
+ year.to_i
308
+ end
309
+
310
+ def month_index(month)
311
+ return month.to_i if month.to_i.nonzero?
312
+ abbr_month_names.index(month.capitalize) || month_names.index(month.capitalize)
313
+ end
314
+
315
+ def month_names
316
+ I18n.t('date.month_names')
317
+ end
318
+
319
+ def abbr_month_names
320
+ I18n.t('date.abbr_month_names')
321
+ end
322
+
323
+ def microseconds(usec)
324
+ (".#{usec}".to_f * 1_000_000).to_i
325
+ end
326
+
327
+ def offset_in_seconds(offset)
328
+ sign = offset =~ /^-/ ? -1 : 1
329
+ parts = offset.scan(/\d\d/).map {|p| p.to_f }
330
+ parts[1] = parts[1].to_f / 60
331
+ (parts[0] + parts[1]) * sign * 3600
332
+ end
333
+
334
+ private
335
+
336
+ # Generate regular expression from format string
337
+ def generate_format_expression(string_format)
338
+ format = string_format.dup
339
+ format.gsub!(/([\.\\])/, '\\\\\1') # escapes dots and backslashes
340
+ found_tokens, token_order = [], []
341
+
342
+ tokens = format_tokens.keys.sort {|a,b| a.size <=> b.size }.reverse
343
+ tokens.each do |token|
344
+ regexp_str, arg_key = *format_tokens[token]
345
+ if format.gsub!(/#{token}/, "%<#{found_tokens.size}>")
346
+ regexp_str = "(#{regexp_str})" if arg_key
347
+ found_tokens << [regexp_str, arg_key]
348
+ end
349
+ end
350
+
351
+ format.scan(/%<(\d)>/).each {|token_index|
352
+ token_index = token_index.first
353
+ token = found_tokens[token_index.to_i]
354
+ format.gsub!("%<#{token_index}>", token[0])
355
+ token_order << token[1]
356
+ }
357
+
358
+ compile_format_method(token_order.compact, string_format)
359
+ Regexp.new(format)
360
+ rescue
361
+ raise "The following format regular expression failed to compile: #{format}\n from format #{string_format}."
362
+ end
363
+
364
+ # Compiles a format method which maps the regexp capture groups to method
365
+ # arguments based on order captured. A time array is built using the values
366
+ # in the position indicated by the first element of the proc arg array.
367
+ #
368
+ def compile_format_method(order, name)
369
+ values = [nil] * 7
370
+ args = []
371
+ order.each do |part|
372
+ proc_arg = format_proc_args[part]
373
+ args << proc_arg[1]
374
+ values[proc_arg[0]] = proc_arg[2] if proc_arg[0]
375
+ end
376
+ class_eval <<-DEF
377
+ class << self
378
+ define_method(:"format_#{name}") do |#{args.join(',')}|
379
+ [#{values.map {|i| i || 'nil' }.join(',')}].map {|i| i.is_a?(Float) ? i : i.to_i }
380
+ end
381
+ end
382
+ DEF
383
+ end
384
+
385
+ def compile_formats(formats)
386
+ formats.map { |format| [ format, generate_format_expression(format) ] }
387
+ end
388
+
389
+ # Pick expression set and combine date and datetimes for
390
+ # datetime attributes to allow date string as datetime
391
+ def expression_set(type, string)
392
+ case type
393
+ when :date
394
+ date_expressions
395
+ when :time
396
+ time_expressions
397
+ when :datetime
398
+ # gives a speed-up for date string as datetime attributes
399
+ if string.length < 11
400
+ date_expressions + datetime_expressions
401
+ else
402
+ datetime_expressions + date_expressions
403
+ end
404
+ end
405
+ end
406
+
407
+ def wrap_regexp(regexp, type, strict=false)
408
+ type = strict ? :datetime : type
409
+ /#{@@type_wrapper[type][0]}#{regexp}#{@@type_wrapper[type][1]}/
410
+ end
411
+
412
+ end
43
413
  end
44
414
  end
415
+
416
+ ValidatesTimeliness::Parser.compile_format_expressions