validates_timeliness 2.3.2 → 3.0.0.beta

Sign up to get free protection for your applications and to get access to all the features.
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