diff --git a/lib/validates_timeliness.rb b/lib/validates_timeliness.rb index fd6ef54..48ccb44 100644 --- a/lib/validates_timeliness.rb +++ b/lib/validates_timeliness.rb @@ -45,6 +45,7 @@ module ValidatesTimeliness def setup_for_rails self.default_timezone = ::ActiveRecord::Base.default_timezone self.use_time_zones = ::ActiveRecord::Base.time_zone_aware_attributes rescue false + self.enable_active_record_datetime_parser! load_error_messages end end diff --git a/lib/validates_timeliness/active_record/attribute_methods.rb b/lib/validates_timeliness/active_record/attribute_methods.rb index a115066..8034115 100644 --- a/lib/validates_timeliness/active_record/attribute_methods.rb +++ b/lib/validates_timeliness/active_record/attribute_methods.rb @@ -1,51 +1,31 @@ module ValidatesTimeliness + + def self.enable_active_record_datetime_parser! + ::ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::AttributeMethods) + end + module ActiveRecord - # Rails 2.1 removed the ability to retrieve the raw value of a time or datetime - # attribute. The raw value is necessary to properly validate a string time or - # datetime value instead of the internal Rails type casting which is very limited - # and does not allow custom formats. These methods restore that ability while - # respecting the automatic timezone handling. - # - # The automatic timezone handling sets the assigned attribute value to the current - # zone in Time.zone. To preserve this localised value and capture the raw value - # we cache the localised value on write and store the raw value in the attributes - # hash for later retrieval and possibly validation. Any value from the database - # will not be in the attribute cache on first read so will be considered in default - # timezone and converted to local time. It is then stored back in the attributes - # hash and cached to avoid the need for any subsequent differentiation. module AttributeMethods + # Overrides write method for date, time and datetime columns + # to use plugin parser. Also adds mechanism to store value + # before type cast. + # def self.included(base) base.extend ClassMethods base.class_eval do - alias_method_chain :read_attribute, :timeliness + alias_method_chain :reload, :timeliness + alias_method_chain :read_attribute_before_type_cast, :timeliness class << self alias_method_chain :define_attribute_methods, :timeliness end end end - # Adds check for cached date/time attributes which have been type cast already - # and value can be used from cache. This prevents the raw date/time value from - # being type cast using default Rails type casting when writing values - # to the database. - def read_attribute_with_timeliness(attr_name) - attr_name = attr_name.to_s - if !(value = @attributes[attr_name]).nil? - column = column_for_attribute(attr_name) - if column && [:date, :time, :datetime].include?(column.type) && @attributes_cache.has_key?(attr_name) - return @attributes_cache[attr_name] - end - end - read_attribute_without_timeliness(attr_name) - end - - # If Rails dirty attributes is enabled then the value is added to - # changed attributes if changed. Can't use the default dirty checking - # implementation as it chains the write_attribute method which deletes - # the attribute from the cache. def write_date_time_attribute(attr_name, value, type, time_zone_aware) + @attributes["_#{attr_name}_before_type_cast"] = value + new = ValidatesTimeliness::Parser.parse(value, type) if new && type != :date @@ -53,32 +33,17 @@ module ValidatesTimeliness new = new.in_time_zone if time_zone_aware end - if defined?(::ActiveRecord::Dirty) && !changed_attributes.include?(attr_name) - old = read_attribute(attr_name) - if old != new - changed_attributes[attr_name] = (old.duplicable? ? old.clone : old) - end - end - @attributes_cache[attr_name] = new - @attributes[attr_name] = value + write_attribute(attr_name.to_sym, new) end - # If reloading then check if cached, which means its in local time. - # If local, convert with parser as local timezone, otherwise use - # read_attribute method for quick default type cast of values from - # database using default timezone. - def read_date_time_attribute(attr_name, type, time_zone_aware, reload = false) - cached = @attributes_cache[attr_name] - return cached if @attributes_cache.has_key?(attr_name) && !reload + def read_attribute_before_type_cast_with_timeliness(attr_name) + return @attributes["_#{attr_name}_before_type_cast"] if @attributes.has_key?("_#{attr_name}_before_type_cast") + read_attribute_before_type_cast_without_timeliness(attr_name) + end - if @attributes_cache.has_key?(attr_name) - time = read_attribute_before_type_cast(attr_name) - time = ValidatesTimeliness::Parser.parse(time, type) - else - time = read_attribute(attr_name) - @attributes[attr_name] = (time && time_zone_aware ? time.in_time_zone : time) unless frozen? - end - @attributes_cache[attr_name] = time && time_zone_aware ? time.in_time_zone : time + def reload_with_timeliness + @attributes.keys.grep(/^_.*_before_type_cast$/).each { |key| @attributes.delete(key) } + reload_without_timeliness end module ClassMethods @@ -86,46 +51,24 @@ module ValidatesTimeliness def define_attribute_methods_with_timeliness return if generated_methods? columns_hash.each do |name, column| - unless instance_method_already_implemented?(name) - if [:date, :time, :datetime].include?(column.type) - time_zone_aware = create_time_zone_conversion_attribute?(name, column) rescue false - define_read_method_for_dates_and_times(name, column.type, time_zone_aware) - end + + if [:date, :time, :datetime].include?(column.type) + time_zone_aware = create_time_zone_conversion_attribute?(name, column) rescue false + + class_eval <<-EOV + def #{name}=(value) + write_date_time_attribute('#{name}', value, #{column.type.inspect}, #{time_zone_aware}) + end + EOV end - unless instance_method_already_implemented?("#{name}=") - if [:date, :time, :datetime].include?(column.type) - time_zone_aware = create_time_zone_conversion_attribute?(name, column) rescue false - define_write_method_for_dates_and_times(name, column.type, time_zone_aware) - end - end end define_attribute_methods_without_timeliness end - def define_write_method_for_dates_and_times(attr_name, type, time_zone_aware) - method_body = <<-EOV - def #{attr_name}=(value) - write_date_time_attribute('#{attr_name}', value, #{type.inspect}, #{time_zone_aware}) - end - EOV - evaluate_attribute_method attr_name, method_body, "#{attr_name}=" - end - - def define_read_method_for_dates_and_times(attr_name, type, time_zone_aware) - method_body = <<-EOV - def #{attr_name}(reload = false) - read_date_time_attribute('#{attr_name}', #{type.inspect}, #{time_zone_aware}, reload) - end - EOV - evaluate_attribute_method attr_name, method_body - end - end end end end - -ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::AttributeMethods) diff --git a/spec/active_record/attribute_methods_spec.rb b/spec/active_record/attribute_methods_spec.rb index 18e267a..e450e77 100644 --- a/spec/active_record/attribute_methods_spec.rb +++ b/spec/active_record/attribute_methods_spec.rb @@ -20,24 +20,6 @@ describe ValidatesTimeliness::ActiveRecord::AttributeMethods do @person.birth_date_and_time = "2000-01-01 12:00" end - it "should call read_date_time_attribute when date attribute is retrieved" do - @person.should_receive(:read_date_time_attribute) - @person.birth_date = "2000-01-01" - @person.birth_date - end - - it "should call read_date_time_attribute when time attribute is retrieved" do - @person.should_receive(:read_date_time_attribute) - @person.birth_time = "12:00" - @person.birth_time - end - - it "should call read_date_time_attribute when datetime attribute is retrieved" do - @person.should_receive(:read_date_time_attribute) - @person.birth_date_and_time = "2000-01-01 12:00" - @person.birth_date_and_time - end - it "should call parser on write for datetime attribute" do ValidatesTimeliness::Parser.should_receive(:parse).once @person.birth_date_and_time = "2000-01-01 02:03:04" @@ -103,7 +85,20 @@ describe ValidatesTimeliness::ActiveRecord::AttributeMethods do @person.birth_date_and_time_before_type_cast.should be_nil end - unless RAILS_VER < '2.1' + if RAILS_VER < '2.1' + + it "should return time object from database in default timezone" do + ActiveRecord::Base.default_timezone = :utc + time_string = "2000-01-01 09:00:00" + @person = Person.new + @person.birth_date_and_time = time_string + @person.save + @person.reload + @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z').should == time_string + ' GMT' + end + + else + it "should return stored time string as Time with correct timezone" do Time.zone = 'Melbourne' time_string = "2000-06-01 02:03:04" @@ -121,84 +116,6 @@ describe ValidatesTimeliness::ActiveRecord::AttributeMethods do @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z %z').should == time_string + ' EST +1000' end - describe "dirty attributes" do - - it "should return true for attribute changed? when value updated" do - time_string = "2000-01-01 02:03:04" - @person.birth_date_and_time = time_string - @person.birth_date_and_time_changed?.should be_true - end - - it "should show changes when time attribute changed from nil to Time object" do - time_string = "2000-01-01 02:03:04" - @person.birth_date_and_time = time_string - time = @person.birth_date_and_time - @person.changes.should == {"birth_date_and_time" => [nil, time]} - end - - it "should show changes when time attribute changed from Time object to nil" do - time_string = "2020-01-01 02:03:04" - @person.birth_date_and_time = time_string - @person.save false - @person.reload - time = @person.birth_date_and_time - @person.birth_date_and_time = nil - @person.changes.should == {"birth_date_and_time" => [time, nil]} - end - - it "should show no changes when assigned same value as Time object" do - time_string = "2020-01-01 02:03:04" - @person.birth_date_and_time = time_string - @person.save false - @person.reload - time = @person.birth_date_and_time - @person.birth_date_and_time = time - @person.changes.should == {} - end - - it "should show no changes when assigned same value as time string" do - time_string = "2020-01-01 02:03:04" - @person.birth_date_and_time = time_string - @person.save false - @person.reload - @person.birth_date_and_time = time_string - @person.changes.should == {} - end - - end - else - - it "should return time object from database in default timezone" do - ActiveRecord::Base.default_timezone = :utc - time_string = "2000-01-01 09:00:00" - @person = Person.new - @person.birth_date_and_time = time_string - @person.save - @person.reload - @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S %Z').should == time_string + ' GMT' - end - - end - - it "should return same time object on repeat reads on existing object" do - Time.zone = 'Melbourne' unless RAILS_VER < '2.1' - time_string = "2000-01-01 09:00:00" - @person = Person.new - @person.birth_date_and_time = time_string - @person.save! - @person.reload - time = @person.birth_date_and_time - @person.birth_date_and_time.should == time - end - - it "should return same date object on repeat reads on existing object" do - date_string = Date.today - @person = Person.new - @person.birth_date = date_string - @person.save! - @person.reload - date = @person.birth_date - @person.birth_date.should == date end it "should return correct date value after new value assigned" do @@ -220,15 +137,5 @@ describe ValidatesTimeliness::ActiveRecord::AttributeMethods do @person.reload @person.birth_date.should == tomorrow end - - it "should skip storing value in attributes hash on read if record frozen" do - @person = Person.new - @person.birth_date = Date.today - @person.save! - @person.reload - @person.freeze - @person.frozen?.should be_true - lambda { @person.birth_date }.should_not raise_error - @person.birth_date.should == Date.today - end + end