diff --git a/lib/validates_timeliness/attribute_methods.rb b/lib/validates_timeliness/attribute_methods.rb index ee6ac98..9df1e54 100644 --- a/lib/validates_timeliness/attribute_methods.rb +++ b/lib/validates_timeliness/attribute_methods.rb @@ -1,24 +1,28 @@ -# For Rails 2.0.x: -# This module adds method to create reader method for Time attributes -# to allow for invalid date checking. If date is invalid then returns nil for -# time value. -# -# For Rails >= 2.1 -# This module overrides these AR methods to allow a time value passed to a column -# write method to be stored as is and only convert to a time on read. -# This differs from the current AR behvaviour where the value is converted -# on write. -# -# This allows the before_type_cast method for the column to return the actual -# value passed to it, treating time columns like all other column types. module ValidatesTimeliness + + # The crux of the plugin is being able to store user entered values as is, + # but also support Rails 2.1 automatic timezone conversion. This requires us + # to distinguish a user entered value from a raw value from the database + # because both maybe in string form, but only the database value should be + # converted to current local time zone. This is done by caching user entered + # values on write and storing the raw value in the attributes cache for later + # retrieval. Any database read value will not be cached on first read so will + # be converted to local timezone and then stored and cached to avoid the need + # for any subsequent differentiation. + # + # One last check is made before values are persisted to the database so that + # the original raw user entered value is not improperly type cast by the default + # Rails time type casting. + # + # A wholesale replacement of the Rails time type casting is not done to preserve + # the quick conversion for timestamp columns and also any value which is never + # touched during the life of the record object. module AttributeMethods def self.included(base) + base.extend ClassMethods if RAILS_VER <= '2.1' - base.extend ClassMethods::Old - else - base.extend ClassMethods::New + base.extend ClassMethodsOld end end @@ -26,10 +30,17 @@ module ValidatesTimeliness unless time.acts_like?(:time) # check for invalid date time.to_date rescue time = nil - # convert to time if still valid - time = defined?(ActiveSupport::TimeWithZone) ? Time.zone.parse(time) : time.to_time rescue nil + # convert to time if still valid. Check for pre Rails 2.1 + time = time && defined?(ActiveSupport::TimeWithZone) ? Time.zone.parse(time) : time.to_time rescue nil end - time.respond_to?(:in_time_zone) ? time.in_time_zone : time + time.respond_to?(:in_time_zone) ? time.in_time_zone : time rescue time + end + + # checks if an attribute value has been cached as nil but has a nono-nil + # stored value which indicates the time value failed the type casting. + def failed_strict_time_type_cast?(attr_name) + attr_name = attr_name.to_s + @attributes_cache.has_key?(attr_name) && @attributes_cache[attr_name].nil? && !@attributes[attr_name].nil? end def read_attribute(attr_name) @@ -38,8 +49,8 @@ module ValidatesTimeliness if column = column_for_attribute(attr_name) if unserializable_attribute?(attr_name, column) unserialize_attribute(attr_name) - elsif column.klass == Time - strict_time_type_cast(value) + elsif column.klass == Time && failed_strict_time_type_cast?(attr_name) + nil else column.type_cast(value) end @@ -50,66 +61,79 @@ module ValidatesTimeliness nil end end - + module ClassMethods - # Rails > 2.0.2 - module New - # Store time value as is including as a string. Only convert on read - def define_write_method_for_time_zone_conversion(attr_name) - method_body = <<-EOV - def #{attr_name}=(time) - if time.acts_like?(:time) - time = time.in_time_zone rescue time - end - write_attribute(:#{attr_name}, time) - end - EOV - evaluate_attribute_method attr_name, method_body, "#{attr_name}=" - end - end # New - - # Rails <= 2.0.2 - module Old - # Copied from AR and inserted Time class check to time attribute - def define_attribute_methods - return if generated_methods? - columns_hash.each do |name, column| - unless instance_method_already_implemented?(name) - if self.serialized_attributes[name] - define_read_method_for_serialized_attribute(name) - elsif column.klass == Time - define_read_method_for_time_attribute(name.to_sym) - else - define_read_method(name.to_sym, name, column) - end - end - - unless instance_method_already_implemented?("#{name}=") - define_write_method(name.to_sym) - end - - unless instance_method_already_implemented?("#{name}?") - define_question_method(name) - end + # Define time attribute write r method to store time value as is without + # conversion and then convert time with strict conversion and cache it. + def define_write_method_for_time_zone_conversion(attr_name) + method_body = <<-EOV + def #{attr_name}=(time) + @attributes['#{attr_name}'] = time + time = strict_time_type_cast(time) + + @attributes_cache['#{attr_name}'] = time.respond_to?(:in_time_zone) ? time.in_time_zone : time end - end - - # defines time attribute reader and does strict conversion - def define_read_method_for_time_attribute(attr_name) - method_body = <<-EOV - def #{attr_name}(reload = false) - cached = @attributes_cache['#{attr_name}'] - return cached if cached && !reload + EOV + evaluate_attribute_method attr_name, method_body, "#{attr_name}=" + end + + # Define time attribute reader and do strict conversion. If reload then + # then check if cached, which means its in local time. If local, do + # strict type cast as local timezone, otherwise use read_attribute method + # for quick default type cast of values from database using default timezone. + def define_read_method_for_time_zone_conversion(attr_name) + method_body = <<-EOV + def #{attr_name}(reload = false) + cached = @attributes_cache['#{attr_name}'] + return cached if @attributes_cache.has_key?('#{attr_name}') && !reload + if @attributes_cache.has_key?('#{attr_name}') time = read_attribute_before_type_cast('#{attr_name}') time = strict_time_type_cast(time) - @attributes_cache['#{attr_name}'] = time + else + time = read_attribute('#{attr_name}') + @attributes['#{attr_name}'] = time.respond_to?(:in_time_zone) ? time.in_time_zone : time end - EOV - evaluate_attribute_method attr_name, method_body + @attributes_cache['#{attr_name}'] = time.respond_to?(:in_time_zone) ? time.in_time_zone : time end - end # Old + EOV + evaluate_attribute_method attr_name, method_body + end + + end + + # Rails < 2.1 + module ClassMethodsOld + # Modified from AR to define Time attribute reader and writer methods with + # strict time type casting. Timezone conversion is ignored for pre Rails 2.1 + def define_attribute_methods + return if generated_methods? + columns_hash.each do |name, column| + unless instance_method_already_implemented?(name) + if self.serialized_attributes[name] + define_read_method_for_serialized_attribute(name) + elsif column.klass == Time + define_read_method_for_time_zone_conversion(name.to_sym) + define_write_method_for_time_zone_conversion(name.to_sym) + else + define_read_method(name.to_sym, name, column) + end + end + + unless instance_method_already_implemented?("#{name}=") + if column.klass == Time + define_write_method_for_time_zone_conversion(name.to_sym) + else + define_write_method(name.to_sym) + end + end + + unless instance_method_already_implemented?("#{name}?") + define_question_method(name) + end + end + end + end - end # ClassMethods end end diff --git a/spec/attribute_methods_spec.rb b/spec/attribute_methods_spec.rb index 28d8513..cc1b41d 100644 --- a/spec/attribute_methods_spec.rb +++ b/spec/attribute_methods_spec.rb @@ -1,5 +1,6 @@ require File.dirname(__FILE__) + '/spec_helper' +# TODO test dirty describe ValidatesTimeliness::AttributeMethods do include ValidatesTimeliness::AttributeMethods @@ -7,26 +8,6 @@ describe ValidatesTimeliness::AttributeMethods do @person = Person.new end - describe "read_attribute" do - it "should return time object from time string" do - @attributes = {} - self.stub!(:column_for_attribute).and_return( mock('Column', :klass => Time) ) - self.stub!(:unserializable_attribute?).and_return(false) - - @attributes['birth_date_and_time'] = "1980-01-01 00:00:00" - read_attribute(:birth_date_and_time).should be_kind_of(Time) - end - - it "should return nil from invalid time string" do - @attributes = {} - self.stub!(:column_for_attribute).and_return( mock('Column', :klass => Time) ) - self.stub!(:unserializable_attribute?).and_return(false) - - @attributes['birth_date_and_time'] = "1980-02-30 00:00:00" - read_attribute(:birth_date_and_time).should be_nil - end - end - describe "strict_time_type_cast" do it "should return time object for valid time string" do strict_time_type_cast("2000-01-01 12:13:14").should be_kind_of(Time) @@ -46,9 +27,9 @@ describe ValidatesTimeliness::AttributeMethods do if RAILS_VER >= '2.1' it "should convert time string into current timezone" do + Time.zone = 'Melbourne' time = strict_time_type_cast("2000-01-01 12:13:14") - Time.zone.utc_offset.should == 0 - time.zone.should == 'UTC' + Time.zone.utc_offset.should == 10.hours end end end @@ -69,14 +50,20 @@ describe ValidatesTimeliness::AttributeMethods do @person.birth_date_and_time.should be_kind_of(Time) end - # This fails running as plugin under vendor using Rails 2.1RC - # due to write_attribute_with_dirty ignoring the write method for time zone - # method. But invalid dates do return nil when running app. it "should return nil when time is invalid" do @person.birth_date_and_time = "2000-02-30 01:02:03" @person.birth_date_and_time.should be_nil end + it "should not save invalid date value to database" do + time_string = "2000-02-30 09:00:00" + @person = Person.new + @person.birth_date_and_time = time_string + @person.save + @person.reload + @person.birth_date_and_time_before_type_cast.should be_nil + end + unless RAILS_VER < '2.1' it "should return stored time string as Time with correct timezone" do Time.zone = 'Melbourne' @@ -85,20 +72,27 @@ describe ValidatesTimeliness::AttributeMethods do @person.birth_date_and_time.utc_offset.should == 10.hours @person.birth_date_and_time.strftime('%Y-%m-%d %H:%M:%S').should == time_string end + + it "should return time object from database in correct timezone" do + Time.zone = 'Melbourne' + time_string = "2000-06-01 09:00:00" + @person = Person.new + @person.birth_date_and_time = time_string + @person.save + @person.reload + @person.birth_date_and_time.to_s(:db).should == time_string + end end - describe "time attribute persistance" do - unless RAILS_VER < '2.1' - it "should return time object from database in correct timezone" do - Time.zone = 'Melbourne' - time_string = "1980-06-01 09:00:00" - @person = Person.new - @person.birth_date_and_time = time_string - @person.save - @person.reload - @person.birth_date_and_time.to_s.should == time_string - end - end + it "should return same time object on repeat reads" do + Time.zone = 'Melbourne' unless RAILS_VER < '2.1' + time_string = "2000-06-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 end