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,11 @@
1
+ en:
2
+ activerecord:
3
+ errors:
4
+ messages:
5
+ invalid_date: "is not a valid date"
6
+ invalid_time: "is not a valid time"
7
+ invalid_datetime: "is not a valid datetime"
8
+ before: "must be before {{restriction}}"
9
+ on_or_before: "must be on or before {{restriction}}"
10
+ after: "must be after {{restriction}}"
11
+ on_or_after: "must be on or after {{restriction}}"
@@ -0,0 +1,121 @@
1
+ module Spec
2
+ module Rails
3
+ module Matchers
4
+ class ValidateTimeliness
5
+ cattr_accessor :test_values
6
+
7
+ @@test_values = {
8
+ :date => {:pass => '2000-01-01', :fail => '2000-01-32'},
9
+ :time => {:pass => '12:00', :fail => '25:00'},
10
+ :datetime => {:pass => '2000-01-01 00:00:00', :fail => '2000-01-32 00:00:00'}
11
+ }
12
+
13
+ def initialize(attribute, options)
14
+ @expected, @options = attribute, options
15
+ @validator = ValidatesTimeliness::Validator.new(options)
16
+ compile_error_messages
17
+ end
18
+
19
+ def compile_error_messages
20
+ messages = validator.send(:error_messages)
21
+ @messages = messages.inject({}) {|h, (k, v)| h[k] = v.gsub(/ (\%s|\{\{\w*\}\})/, ''); h }
22
+ end
23
+
24
+ def matches?(record)
25
+ @record = record
26
+ type = options[:type]
27
+
28
+ invalid_value = @@test_values[type][:fail]
29
+ valid_value = parse_and_cast(@@test_values[type][:pass])
30
+ valid = error_matching(invalid_value, /#{messages["invalid_#{type}".to_sym]}/) &&
31
+ no_error_matching(valid_value, /#{messages["invalid_#{type}".to_sym]}/)
32
+
33
+ valid = test_option(:before, :-) if options[:before] && valid
34
+ valid = test_option(:after, :+) if options[:after] && valid
35
+
36
+ valid = test_option(:on_or_before, :+, :modify_on => :invalid) if options[:on_or_before] && valid
37
+ valid = test_option(:on_or_after, :-, :modify_on => :invalid) if options[:on_or_after] && valid
38
+
39
+ return valid
40
+ end
41
+
42
+ def failure_message
43
+ "expected model to validate #{options[:type]} attribute #{expected.inspect} with #{last_failure}"
44
+ end
45
+
46
+ def negative_failure_message
47
+ "expected not to validate #{options[:type]} attribute #{expected.inspect}"
48
+ end
49
+
50
+ def description
51
+ "have validated #{options[:type]} attribute #{expected.inspect}"
52
+ end
53
+
54
+ private
55
+ attr_reader :actual, :expected, :record, :options, :messages, :last_failure, :validator
56
+
57
+ def test_option(option, modifier, settings={})
58
+ settings.reverse_merge!(:modify_on => :valid)
59
+ boundary = parse_and_cast(options[option])
60
+
61
+ valid_value, invalid_value = if settings[:modify_on] == :valid
62
+ [ boundary.send(modifier, 1), boundary ]
63
+ else
64
+ [ boundary, boundary.send(modifier, 1) ]
65
+ end
66
+
67
+ message = messages[option]
68
+ error_matching(invalid_value, /#{message}/) &&
69
+ no_error_matching(valid_value, /#{message}/)
70
+ end
71
+
72
+ def parse_and_cast(value)
73
+ value = validator.send(:restriction_value, value, record)
74
+ validator.send(:type_cast_value, value)
75
+ end
76
+
77
+ def error_matching(value, match)
78
+ record.send("#{expected}=", value)
79
+ record.valid?
80
+ errors = record.errors.on(expected)
81
+ pass = [ errors ].flatten.any? {|error| match === error }
82
+ @last_failure = "error matching #{match.inspect} when value is #{format_value(value)}" unless pass
83
+ pass
84
+ end
85
+
86
+ def no_error_matching(value, match)
87
+ pass = !error_matching(value, match)
88
+ @last_failure = "no error matching #{match.inspect} when value is #{format_value(value)}" unless pass
89
+ pass
90
+ end
91
+
92
+ def format_value(value)
93
+ return value if value.is_a?(String)
94
+ value.strftime(ValidatesTimeliness::Validator.error_value_formats[options[:type]])
95
+ end
96
+ end
97
+
98
+ def validate_date(attribute, options={})
99
+ options[:type] = :date
100
+ validate_timeliness_of(attribute, options)
101
+ end
102
+
103
+ def validate_time(attribute, options={})
104
+ options[:type] = :time
105
+ validate_timeliness_of(attribute, options)
106
+ end
107
+
108
+ def validate_datetime(attribute, options={})
109
+ options[:type] = :datetime
110
+ validate_timeliness_of(attribute, options)
111
+ end
112
+
113
+ private
114
+
115
+ def validate_timeliness_of(attribute, options={})
116
+ ValidateTimeliness.new(attribute, options)
117
+ end
118
+
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,82 @@
1
+ module ValidatesTimeliness
2
+ module ValidationMethods
3
+
4
+ def self.included(base)
5
+ base.extend ClassMethods
6
+ end
7
+
8
+ module ClassMethods
9
+
10
+ def parse_date_time(raw_value, type, strict=true)
11
+ return nil if raw_value.blank?
12
+ return raw_value if raw_value.acts_like?(:time) || raw_value.is_a?(Date)
13
+
14
+ time_array = ValidatesTimeliness::Formats.parse(raw_value, type, strict)
15
+ raise if time_array.nil?
16
+
17
+ # Rails dummy time date part is defined as 2000-01-01
18
+ time_array[0..2] = 2000, 1, 1 if type == :time
19
+
20
+ # Date.new enforces days per month, unlike Time
21
+ date = Date.new(*time_array[0..2]) unless type == :time
22
+
23
+ return date if type == :date
24
+
25
+ # Create time object which checks time part, and return time object
26
+ make_time(time_array)
27
+ rescue
28
+ nil
29
+ end
30
+
31
+ def validates_time(*attr_names)
32
+ configuration = attr_names.extract_options!
33
+ configuration[:type] = :time
34
+ validates_timeliness_of(attr_names, configuration)
35
+ end
36
+
37
+ def validates_date(*attr_names)
38
+ configuration = attr_names.extract_options!
39
+ configuration[:type] = :date
40
+ validates_timeliness_of(attr_names, configuration)
41
+ end
42
+
43
+ def validates_datetime(*attr_names)
44
+ configuration = attr_names.extract_options!
45
+ configuration[:type] = :datetime
46
+ validates_timeliness_of(attr_names, configuration)
47
+ end
48
+
49
+ private
50
+
51
+ def validates_timeliness_of(attr_names, configuration)
52
+ validator = ValidatesTimeliness::Validator.new(configuration)
53
+
54
+ # bypass handling of allow_nil and allow_blank to validate raw value
55
+ configuration.delete(:allow_nil)
56
+ configuration.delete(:allow_blank)
57
+ validates_each(attr_names, configuration) do |record, attr_name, value|
58
+ validator.call(record, attr_name)
59
+ end
60
+ end
61
+
62
+ # Time.zone. Rails 2.0 should be default_timezone.
63
+ def make_time(time_array)
64
+ if Time.respond_to?(:zone) && time_zone_aware_attributes
65
+ Time.zone.local(*time_array)
66
+ else
67
+ begin
68
+ Time.send(::ActiveRecord::Base.default_timezone, *time_array)
69
+ rescue ArgumentError, TypeError
70
+ zone_offset = ::ActiveRecord::Base.default_timezone == :local ? DateTime.local_offset : 0
71
+ time_array.pop # remove microseconds
72
+ DateTime.civil(*(time_array << zone_offset))
73
+ end
74
+ end
75
+ end
76
+
77
+ end
78
+
79
+ end
80
+ end
81
+
82
+ ActiveRecord::Base.send(:include, ValidatesTimeliness::ValidationMethods)
@@ -0,0 +1,120 @@
1
+ module ValidatesTimeliness
2
+
3
+ class Validator
4
+ cattr_accessor :ignore_restriction_errors
5
+ cattr_accessor :error_value_formats
6
+
7
+ self.ignore_restriction_errors = false
8
+ self.error_value_formats = {
9
+ :time => '%H:%M:%S',
10
+ :date => '%Y-%m-%d',
11
+ :datetime => '%Y-%m-%d %H:%M:%S'
12
+ }
13
+
14
+ attr_reader :configuration, :type
15
+
16
+ def initialize(configuration)
17
+ defaults = { :on => :save, :type => :datetime, :allow_nil => false, :allow_blank => false }
18
+ @configuration = defaults.merge(configuration)
19
+ @type = @configuration.delete(:type)
20
+ end
21
+
22
+ def call(record, attr_name)
23
+ value = record.send(attr_name)
24
+ value = record.class.parse_date_time(value, type, false) if value.is_a?(String)
25
+ raw_value = raw_value(record, attr_name)
26
+
27
+ return if (raw_value.nil? && configuration[:allow_nil]) || (raw_value.blank? && configuration[:allow_blank])
28
+
29
+ add_error(record, attr_name, :blank) and return if raw_value.blank?
30
+
31
+ add_error(record, attr_name, "invalid_#{type}".to_sym) and return unless value
32
+
33
+ validate_restrictions(record, attr_name, value)
34
+ end
35
+
36
+ private
37
+
38
+ def raw_value(record, attr_name)
39
+ record.send("#{attr_name}_before_type_cast")
40
+ end
41
+
42
+ def validate_restrictions(record, attr_name, value)
43
+ restriction_methods = {:before => '<', :after => '>', :on_or_before => '<=', :on_or_after => '>='}
44
+
45
+ display = self.class.error_value_formats[type]
46
+
47
+ value = type_cast_value(value)
48
+
49
+ restriction_methods.each do |option, method|
50
+ next unless restriction = configuration[option]
51
+ begin
52
+ compare = restriction_value(restriction, record)
53
+ next if compare.nil?
54
+ compare = type_cast_value(compare)
55
+
56
+ unless value.send(method, compare)
57
+ add_error(record, attr_name, option, :restriction => compare.strftime(display))
58
+ end
59
+ rescue
60
+ unless self.class.ignore_restriction_errors
61
+ add_error(record, attr_name, "restriction '#{option}' value was invalid")
62
+ end
63
+ end
64
+ end
65
+ end
66
+
67
+ def add_error(record, attr_name, message, interpolate={})
68
+ if Rails::VERSION::STRING < '2.2'
69
+ message = error_messages[message] if message.is_a?(Symbol)
70
+ message = message % interpolate.values unless interpolate.empty?
71
+ record.errors.add(attr_name, message)
72
+ else
73
+ # use i18n support in AR for message or use custom message passed to validation method
74
+ custom = custom_error_messages[message]
75
+ record.errors.add(attr_name, custom || message, interpolate)
76
+ end
77
+ end
78
+
79
+ def error_messages
80
+ return @error_messages if defined?(@error_messages)
81
+ @error_messages = ValidatesTimeliness.default_error_messages.merge(custom_error_messages)
82
+ end
83
+
84
+ def custom_error_messages
85
+ return @custom_error_messages if defined?(@custom_error_messages)
86
+ @custom_error_messages = configuration.inject({}) {|h, (k, v)| h[$1.to_sym] = v if k.to_s =~ /(.*)_message$/;h }
87
+ end
88
+
89
+ def restriction_value(restriction, record)
90
+ case restriction
91
+ when Time, Date, DateTime
92
+ restriction
93
+ when Symbol
94
+ restriction_value(record.send(restriction), record)
95
+ when Proc
96
+ restriction_value(restriction.call(record), record)
97
+ else
98
+ record.class.parse_date_time(restriction, type, false)
99
+ end
100
+ end
101
+
102
+ def type_cast_value(value)
103
+ case type
104
+ when :time
105
+ value.to_dummy_time
106
+ when :date
107
+ value.to_date
108
+ when :datetime
109
+ if value.is_a?(DateTime) || value.is_a?(Time)
110
+ value.to_time
111
+ else
112
+ value.to_time(ValidatesTimelines.default_timezone)
113
+ end
114
+ else
115
+ nil
116
+ end
117
+ end
118
+
119
+ end
120
+ end
@@ -0,0 +1,38 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe ValidatesTimeliness::ActionView::InstanceTag, :type => :helper do
4
+
5
+ before do
6
+ @person = Person.new
7
+ end
8
+
9
+ it "should display invalid datetime as datetime_select values" do
10
+ @person.birth_date_and_time = "2008-02-30 12:00:22"
11
+ output = datetime_select(:person, :birth_date_and_time, :include_blank => true, :include_seconds => true)
12
+
13
+ output.should have_tag('select[id=person_birth_date_and_time_1i]') do
14
+ with_tag('option[selected=selected]', '2008')
15
+ end
16
+ output.should have_tag('select[id=person_birth_date_and_time_2i]') do
17
+ with_tag('option[selected=selected]', 'February')
18
+ end
19
+ output.should have_tag('select[id=person_birth_date_and_time_3i]') do
20
+ with_tag('option[selected=selected]', '30')
21
+ end
22
+ output.should have_tag('select[id=person_birth_date_and_time_4i]') do
23
+ with_tag('option[selected=selected]', '12')
24
+ end
25
+ output.should have_tag('select[id=person_birth_date_and_time_5i]') do
26
+ with_tag('option[selected=selected]', '00')
27
+ end
28
+ output.should have_tag('select[id=person_birth_date_and_time_6i]') do
29
+ with_tag('option[selected=selected]', '22')
30
+ end
31
+ end
32
+
33
+ it "should display datetime_select when datetime value is nil" do
34
+ @person.birth_date_and_time = nil
35
+ output = datetime_select(:person, :birth_date_and_time, :include_blank => true, :include_seconds => true)
36
+ output.should have_tag('select', 6)
37
+ end
38
+ end
@@ -0,0 +1,204 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe ValidatesTimeliness::ActiveRecord::AttributeMethods do
4
+ include ValidatesTimeliness::ActiveRecord::AttributeMethods
5
+ include ValidatesTimeliness::ValidationMethods
6
+
7
+ before do
8
+ @person = Person.new
9
+ end
10
+
11
+ it "should call write_date_time_attribute when date attribute assigned value" do
12
+ @person.should_receive(:write_date_time_attribute)
13
+ @person.birth_date = "2000-01-01"
14
+ end
15
+
16
+ it "should call write_date_time_attribute when time attribute assigned value" do
17
+ @person.should_receive(:write_date_time_attribute)
18
+ @person.birth_time = "12:00"
19
+ end
20
+
21
+ it "should call write_date_time_attribute when datetime attribute assigned value" do
22
+ @person.should_receive(:write_date_time_attribute)
23
+ @person.birth_date_and_time = "2000-01-01 12:00"
24
+ end
25
+
26
+ it "should call parser on write for datetime attribute" do
27
+ @person.class.should_receive(:parse_date_time).once
28
+ @person.birth_date_and_time = "2000-01-01 02:03:04"
29
+ end
30
+
31
+ it "should call parser on write for date attribute" do
32
+ @person.class.should_receive(:parse_date_time).once
33
+ @person.birth_date = "2000-01-01"
34
+ end
35
+
36
+ it "should call parser on write for time attribute" do
37
+ @person.class.should_receive(:parse_date_time).once
38
+ @person.birth_time = "12:00"
39
+ end
40
+
41
+ it "should return raw string value for attribute_before_type_cast when written as string" do
42
+ time_string = "2000-01-01 02:03:04"
43
+ @person.birth_date_and_time = time_string
44
+ @person.birth_date_and_time_before_type_cast.should == time_string
45
+ end
46
+
47
+ it "should return Time object for attribute_before_type_cast when written as Time" do
48
+ @person.birth_date_and_time = Time.mktime(2000, 1, 1, 2, 3, 4)
49
+ @person.birth_date_and_time_before_type_cast.should be_kind_of(Time)
50
+ end
51
+
52
+ it "should return Time object for datetime attribute read method when assigned Time object" do
53
+ @person.birth_date_and_time = Time.now
54
+ @person.birth_date_and_time.should be_kind_of(Time)
55
+ end
56
+
57
+ it "should return Time object for datetime attribute read method when assigned string" do
58
+ @person.birth_date_and_time = "2000-01-01 02:03:04"
59
+ @person.birth_date_and_time.should be_kind_of(Time)
60
+ end
61
+
62
+ it "should return Date object for date attribute read method when assigned Date object" do
63
+ @person.birth_date = Date.today
64
+ @person.birth_date.should be_kind_of(Date)
65
+ end
66
+
67
+ it "should return Date object for date attribute read method when assigned string" do
68
+ @person.birth_date = '2000-01-01'
69
+ @person.birth_date.should be_kind_of(Date)
70
+ end
71
+
72
+ it "should return nil when time is invalid" do
73
+ @person.birth_date_and_time = "2000-01-32 02:03:04"
74
+ @person.birth_date_and_time.should be_nil
75
+ end
76
+
77
+ it "should not save invalid date value to database" do
78
+ time_string = "2000-01-32 02:03:04"
79
+ @person = Person.new
80
+ @person.birth_date_and_time = time_string
81
+ @person.save
82
+ @person.reload
83
+ @person.birth_date_and_time_before_type_cast.should be_nil
84
+ end
85
+
86
+ unless RAILS_VER < '2.1'
87
+ it "should return stored time string as Time with correct timezone" do
88
+ Time.zone = 'Melbourne'
89
+ time_string = "2000-06-01 02:03:04"
90
+ @person.birth_date_and_time = time_string
91
+ @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z %z').should == time_string + ' EST +1000'
92
+ end
93
+
94
+ it "should return time object from database in correct timezone" do
95
+ Time.zone = 'Melbourne'
96
+ time_string = "2000-06-01 09:00:00"
97
+ @person = Person.new
98
+ @person.birth_date_and_time = time_string
99
+ @person.save
100
+ @person.reload
101
+ @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z %z').should == time_string + ' EST +1000'
102
+ end
103
+
104
+ describe "dirty attributes" do
105
+
106
+ it "should return true for attribute changed? when value updated" do
107
+ time_string = "2000-01-01 02:03:04"
108
+ @person.birth_date_and_time = time_string
109
+ @person.birth_date_and_time_changed?.should be_true
110
+ end
111
+
112
+ it "should show changes when time attribute changed from nil to Time object" do
113
+ time_string = "2000-01-01 02:03:04"
114
+ @person.birth_date_and_time = time_string
115
+ time = @person.birth_date_and_time
116
+ @person.changes.should == {"birth_date_and_time" => [nil, time]}
117
+ end
118
+
119
+ it "should show changes when time attribute changed from Time object to nil" do
120
+ time_string = "2020-01-01 02:03:04"
121
+ @person.birth_date_and_time = time_string
122
+ @person.save false
123
+ @person.reload
124
+ time = @person.birth_date_and_time
125
+ @person.birth_date_and_time = nil
126
+ @person.changes.should == {"birth_date_and_time" => [time, nil]}
127
+ end
128
+
129
+ it "should show no changes when assigned same value as Time object" do
130
+ time_string = "2020-01-01 02:03:04"
131
+ @person.birth_date_and_time = time_string
132
+ @person.save false
133
+ @person.reload
134
+ time = @person.birth_date_and_time
135
+ @person.birth_date_and_time = time
136
+ @person.changes.should == {}
137
+ end
138
+
139
+ it "should show no changes when assigned same value as time string" do
140
+ time_string = "2020-01-01 02:03:04"
141
+ @person.birth_date_and_time = time_string
142
+ @person.save false
143
+ @person.reload
144
+ @person.birth_date_and_time = time_string
145
+ @person.changes.should == {}
146
+ end
147
+
148
+ end
149
+ else
150
+
151
+ it "should return time object from database in default timezone" do
152
+ ActiveRecord::Base.default_timezone = :utc
153
+ time_string = "2000-01-01 09:00:00"
154
+ @person = Person.new
155
+ @person.birth_date_and_time = time_string
156
+ @person.save
157
+ @person.reload
158
+ @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z').should == time_string + ' GMT'
159
+ end
160
+
161
+ end
162
+
163
+ it "should return same time object on repeat reads on existing object" do
164
+ Time.zone = 'Melbourne' unless RAILS_VER < '2.1'
165
+ time_string = "2000-01-01 09:00:00"
166
+ @person = Person.new
167
+ @person.birth_date_and_time = time_string
168
+ @person.save!
169
+ @person.reload
170
+ time = @person.birth_date_and_time
171
+ @person.birth_date_and_time.should == time
172
+ end
173
+
174
+ it "should return same date object on repeat reads on existing object" do
175
+ date_string = Date.today
176
+ @person = Person.new
177
+ @person.birth_date = date_string
178
+ @person.save!
179
+ @person.reload
180
+ date = @person.birth_date
181
+ @person.birth_date.should == date
182
+ end
183
+
184
+ it "should return correct date value after new value assigned" do
185
+ today = Date.today
186
+ tomorrow = Date.today + 1.day
187
+ @person = Person.new
188
+ @person.birth_date = today
189
+ @person.birth_date.should == today
190
+ @person.birth_date = tomorrow
191
+ @person.birth_date.should == tomorrow
192
+ end
193
+
194
+ it "should update date attribute on existing object" do
195
+ today = Date.today
196
+ tomorrow = Date.today + 1.day
197
+ @person = Person.create(:birth_date => today)
198
+ @person.birth_date = tomorrow
199
+ @person.save!
200
+ @person.reload
201
+ @person.birth_date.should == tomorrow
202
+ end
203
+
204
+ end