namespaced ActiveRecord and ActionView specifc modules and specs with a mind to making the plugin framework agnostic in the future

This commit is contained in:
Adam Meehan 2008-11-29 18:32:32 +11:00
parent 21d26ee2b1
commit 412ff22dd9
15 changed files with 283 additions and 272 deletions

View File

@ -1,18 +1,19 @@
require 'validates_timeliness/attribute_methods'
require 'validates_timeliness/validations' require 'validates_timeliness/validations'
require 'validates_timeliness/formats' require 'validates_timeliness/formats'
require 'validates_timeliness/multiparameter_attributes'
require 'validates_timeliness/instance_tag'
require 'validates_timeliness/validate_timeliness_matcher' if ENV['RAILS_ENV'] == 'test' require 'validates_timeliness/validate_timeliness_matcher' if ENV['RAILS_ENV'] == 'test'
require 'validates_timeliness/active_record/attribute_methods'
require 'validates_timeliness/active_record/multiparameter_attributes'
require 'validates_timeliness/action_view/instance_tag'
require 'validates_timeliness/core_ext/time' require 'validates_timeliness/core_ext/time'
require 'validates_timeliness/core_ext/date' require 'validates_timeliness/core_ext/date'
require 'validates_timeliness/core_ext/date_time' require 'validates_timeliness/core_ext/date_time'
ActiveRecord::Base.send(:include, ValidatesTimeliness::AttributeMethods)
ActiveRecord::Base.send(:include, ValidatesTimeliness::Validations) ActiveRecord::Base.send(:include, ValidatesTimeliness::Validations)
ActiveRecord::Base.send(:include, ValidatesTimeliness::MultiparameterAttributes) ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::AttributeMethods)
ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::InstanceTag) ActiveRecord::Base.send(:include, ValidatesTimeliness::ActiveRecord::MultiparameterAttributes)
ActionView::Helpers::InstanceTag.send(:include, ValidatesTimeliness::ActionView::InstanceTag)
Time.send(:include, ValidatesTimeliness::CoreExtensions::Time) Time.send(:include, ValidatesTimeliness::CoreExtensions::Time)
Date.send(:include, ValidatesTimeliness::CoreExtensions::Date) Date.send(:include, ValidatesTimeliness::CoreExtensions::Date)

View File

@ -0,0 +1,43 @@
module ValidatesTimeliness
module ActionView
# Intercepts the date and time select helpers to allow the
# attribute value before type cast to be used as in the select helpers.
# This means that an invalid date or time will be redisplayed rather than the
# type cast value which would be nil if invalid.
module InstanceTag
def self.included(base)
selector_method = Rails::VERSION::STRING < '2.2' ? :date_or_time_select : :datetime_selector
base.class_eval do
alias_method :datetime_selector_without_timeliness, selector_method
alias_method selector_method, :datetime_selector_with_timeliness
end
base.alias_method_chain :value, :timeliness
end
TimelinessDateTime = Struct.new(:year, :month, :day, :hour, :min, :sec)
def datetime_selector_with_timeliness(*args)
@timeliness_date_or_time_tag = true
datetime_selector_without_timeliness(*args)
end
def value_with_timeliness(object)
return value_without_timeliness(object) unless @timeliness_date_or_time_tag
raw_value = value_before_type_cast(object)
if raw_value.nil? || raw_value.acts_like?(:time) || raw_value.is_a?(Date)
return value_without_timeliness(object)
end
time_array = ParseDate.parsedate(raw_value)
TimelinessDateTime.new(*time_array[0..5])
end
end
end
end

View File

@ -0,0 +1,153 @@
module ValidatesTimeliness
module ActiveRecord
# The crux of the plugin is being able to store raw user entered values
# while not interfering with the Rails 2.1 automatic timezone handling. This
# requires us to distinguish a user entered value from a value read from the
# database. Both maybe in string form, but only the database value should be
# interpreted as being in the default timezone which is normally UTC. The user
# entered value should be interpreted as being in the current zone as indicated
# by Time.zone.
#
# To do this we must cache the user entered values 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.
#
# The wholesale replacement of the Rails time type casting is not done to
# preserve the quickest conversion for timestamp columns and also any value
# which is never changed during the life of the record object.
module AttributeMethods
def self.included(base)
base.extend ClassMethods
if Rails::VERSION::STRING < '2.1'
base.class_eval do
class << self
def create_time_zone_conversion_attribute?(name, column)
false
end
end
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(attr_name)
attr_name = attr_name.to_s
if !(value = @attributes[attr_name]).nil?
if column = column_for_attribute(attr_name)
if unserializable_attribute?(attr_name, column)
unserialize_attribute(attr_name)
elsif [:date, :time, :datetime].include?(column.type) && @attributes_cache.has_key?(attr_name)
@attributes_cache[attr_name]
else
column.type_cast(value)
end
else
value
end
else
nil
end
end
# Writes attribute value by storing raw value in attributes hash,
# then convert it with parser and cache it.
#
# 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)
attr_name = attr_name.to_s
column = column_for_attribute(attr_name)
old = read_attribute(attr_name) if defined?(::ActiveRecord::Dirty)
new = self.class.parse_date_time(value, column.type)
if self.class.send(:create_time_zone_conversion_attribute?, attr_name, column)
new = new.in_time_zone rescue nil
end
@attributes_cache[attr_name] = new
if defined?(::ActiveRecord::Dirty) && !changed_attributes.include?(attr_name) && old != new
changed_attributes[attr_name] = (old.duplicable? ? old.clone : old)
end
@attributes[attr_name] = value
end
module ClassMethods
# Override AR method to define attribute reader and writer method for
# date, time and datetime attributes to use plugin parser.
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 create_time_zone_conversion_attribute?(name, column)
define_read_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 [:date, :time, :datetime].include?(column.type)
define_write_method_for_dates_and_times(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
# Define write method for date, time and datetime columns
def define_write_method_for_dates_and_times(attr_name)
method_body = <<-EOV
def #{attr_name}=(value)
write_date_time_attribute('#{attr_name}', value)
end
EOV
evaluate_attribute_method attr_name, method_body
end
# Define time attribute reader. 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 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 = self.class.parse_date_time(date, :datetime)
else
time = read_attribute('#{attr_name}')
@attributes['#{attr_name}'] = time.in_time_zone rescue nil
end
@attributes_cache['#{attr_name}'] = time.in_time_zone rescue nil
end
EOV
evaluate_attribute_method attr_name, method_body
end
end
end
end
end

View File

@ -0,0 +1,62 @@
module ValidatesTimeliness
module ActiveRecord
module MultiparameterAttributes
def self.included(base)
base.alias_method_chain :execute_callstack_for_multiparameter_attributes, :timeliness
end
# Overrides AR method to store multiparameter time and dates as string
# allowing validation later.
def execute_callstack_for_multiparameter_attributes_with_timeliness(callstack)
errors = []
callstack.each do |name, values|
klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
if values.empty?
send(name + "=", nil)
else
column = column_for_attribute(name)
begin
value = if [:date, :time, :datetime].include?(column.type)
time_array_to_string(values, column.type)
else
klass.new(*values)
end
send(name + "=", value)
rescue => ex
errors << ::ActiveRecord::AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
end
end
end
unless errors.empty?
raise ::ActiveRecord::MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
end
end
def time_array_to_string(values, type)
values = values.map(&:to_s)
case type
when :date
extract_date_from_multiparameter_attributes(values)
when :time
extract_time_from_multiparameter_attributes(values)
when :datetime
date_values, time_values = values.slice!(0, 3), values
extract_date_from_multiparameter_attributes(date_values) + " " + extract_time_from_multiparameter_attributes(time_values)
end
end
def extract_date_from_multiparameter_attributes(values)
[values[0], *values.slice(1, 2).map { |s| s.rjust(2, "0") }].join("-")
end
def extract_time_from_multiparameter_attributes(values)
values.last(3).map { |s| s.rjust(2, "0") }.join(":")
end
end
end
end

View File

@ -1,150 +0,0 @@
module ValidatesTimeliness
# The crux of the plugin is being able to store raw user entered values
# while not interfering with the Rails 2.1 automatic timezone handling. This
# requires us to distinguish a user entered value from a value read from the
# database. Both maybe in string form, but only the database value should be
# interpreted as being in the default timezone which is normally UTC. The user
# entered value should be interpreted as being in the current zone as indicated
# by Time.zone.
#
# To do this we must cache the user entered values 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.
#
# The wholesale replacement of the Rails time type casting is not done to
# preserve the quickest conversion for timestamp columns and also any value
# which is never changed during the life of the record object.
module AttributeMethods
def self.included(base)
base.extend ClassMethods
if Rails::VERSION::STRING < '2.1'
base.class_eval do
class << self
def create_time_zone_conversion_attribute?(name, column)
false
end
end
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(attr_name)
attr_name = attr_name.to_s
if !(value = @attributes[attr_name]).nil?
if column = column_for_attribute(attr_name)
if unserializable_attribute?(attr_name, column)
unserialize_attribute(attr_name)
elsif [:date, :time, :datetime].include?(column.type) && @attributes_cache.has_key?(attr_name)
@attributes_cache[attr_name]
else
column.type_cast(value)
end
else
value
end
else
nil
end
end
# Writes attribute value by storing raw value in attributes hash,
# then convert it with parser and cache it.
#
# 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)
attr_name = attr_name.to_s
column = column_for_attribute(attr_name)
old = read_attribute(attr_name) if defined?(ActiveRecord::Dirty)
new = self.class.parse_date_time(value, column.type)
if self.class.send(:create_time_zone_conversion_attribute?, attr_name, column)
new = new.in_time_zone rescue nil
end
@attributes_cache[attr_name] = new
if defined?(ActiveRecord::Dirty) && !changed_attributes.include?(attr_name) && old != new
changed_attributes[attr_name] = (old.duplicable? ? old.clone : old)
end
@attributes[attr_name] = value
end
module ClassMethods
# Override AR method to define attribute reader and writer method for
# date, time and datetime attributes to use plugin parser.
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 create_time_zone_conversion_attribute?(name, column)
define_read_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 [:date, :time, :datetime].include?(column.type)
define_write_method_for_dates_and_times(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
# Define write method for date, time and datetime columns
def define_write_method_for_dates_and_times(attr_name)
method_body = <<-EOV
def #{attr_name}=(value)
write_date_time_attribute('#{attr_name}', value)
end
EOV
evaluate_attribute_method attr_name, method_body
end
# Define time attribute reader. 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 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 = self.class.parse_date_time(date, :datetime)
else
time = read_attribute('#{attr_name}')
@attributes['#{attr_name}'] = time.in_time_zone rescue nil
end
@attributes_cache['#{attr_name}'] = time.in_time_zone rescue nil
end
EOV
evaluate_attribute_method attr_name, method_body
end
end
end
end

View File

@ -1,40 +0,0 @@
module ValidatesTimeliness
# Intercepts the date and time select helpers to allow the
# attribute value before type cast to be used as in the select helpers.
# This means that an invalid date or time will be redisplayed rather than the
# type cast value which would be nil if invalid.
module InstanceTag
def self.included(base)
selector_method = Rails::VERSION::STRING < '2.2' ? :date_or_time_select : :datetime_selector
base.class_eval do
alias_method :datetime_selector_without_timeliness, selector_method
alias_method selector_method, :datetime_selector_with_timeliness
end
base.alias_method_chain :value, :timeliness
end
TimelinessDateTime = Struct.new(:year, :month, :day, :hour, :min, :sec)
def datetime_selector_with_timeliness(*args)
@timeliness_date_or_time_tag = true
datetime_selector_without_timeliness(*args)
end
def value_with_timeliness(object)
return value_without_timeliness(object) unless @timeliness_date_or_time_tag
raw_value = value_before_type_cast(object)
if raw_value.nil? || raw_value.acts_like?(:time) || raw_value.is_a?(Date)
return value_without_timeliness(object)
end
time_array = ParseDate.parsedate(raw_value)
TimelinessDateTime.new(*time_array[0..5])
end
end
end

View File

@ -1,58 +0,0 @@
module ValidatesTimeliness
module MultiparameterAttributes
def self.included(base)
base.alias_method_chain :execute_callstack_for_multiparameter_attributes, :timeliness
end
# Overrides AR method to store multiparameter time and dates as string
# allowing validation later.
def execute_callstack_for_multiparameter_attributes_with_timeliness(callstack)
errors = []
callstack.each do |name, values|
klass = (self.class.reflect_on_aggregation(name.to_sym) || column_for_attribute(name)).klass
if values.empty?
send(name + "=", nil)
else
column = column_for_attribute(name)
begin
value = if [:date, :time, :datetime].include?(column.type)
time_array_to_string(values, column.type)
else
klass.new(*values)
end
send(name + "=", value)
rescue => ex
errors << ActiveRecord::AttributeAssignmentError.new("error on assignment #{values.inspect} to #{name}", ex, name)
end
end
end
unless errors.empty?
raise ActiveRecord::MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes"
end
end
def time_array_to_string(values, type)
values = values.map(&:to_s)
case type
when :date
extract_date_from_multiparameter_attributes(values)
when :time
extract_time_from_multiparameter_attributes(values)
when :datetime
date_values, time_values = values.slice!(0, 3), values
extract_date_from_multiparameter_attributes(date_values) + " " + extract_time_from_multiparameter_attributes(time_values)
end
end
def extract_date_from_multiparameter_attributes(values)
[values[0], *values.slice(1, 2).map { |s| s.rjust(2, "0") }].join("-")
end
def extract_time_from_multiparameter_attributes(values)
values.last(3).map { |s| s.rjust(2, "0") }.join(":")
end
end
end

View File

@ -12,14 +12,14 @@ module ValidatesTimeliness
base.class_inheritable_accessor :ignore_datetime_restriction_errors base.class_inheritable_accessor :ignore_datetime_restriction_errors
base.ignore_datetime_restriction_errors = false base.ignore_datetime_restriction_errors = false
ActiveRecord::Errors.class_inheritable_accessor :date_time_error_value_formats ::ActiveRecord::Errors.class_inheritable_accessor :date_time_error_value_formats
ActiveRecord::Errors.date_time_error_value_formats = { ::ActiveRecord::Errors.date_time_error_value_formats = {
:time => '%H:%M:%S', :time => '%H:%M:%S',
:date => '%Y-%m-%d', :date => '%Y-%m-%d',
:datetime => '%Y-%m-%d %H:%M:%S' :datetime => '%Y-%m-%d %H:%M:%S'
} }
ActiveRecord::Errors.default_error_messages.update( ::ActiveRecord::Errors.default_error_messages.update(
:invalid_date => "is not a valid date", :invalid_date => "is not a valid date",
:invalid_time => "is not a valid time", :invalid_time => "is not a valid time",
:invalid_datetime => "is not a valid datetime", :invalid_datetime => "is not a valid datetime",
@ -140,7 +140,7 @@ module ValidatesTimeliness
type_cast_method = restriction_type_cast_method(configuration[:type]) type_cast_method = restriction_type_cast_method(configuration[:type])
display = ActiveRecord::Errors.date_time_error_value_formats[configuration[:type]] display = ::ActiveRecord::Errors.date_time_error_value_formats[configuration[:type]]
value = value.send(type_cast_method) value = value.send(type_cast_method)
@ -161,7 +161,7 @@ module ValidatesTimeliness
# Map error message keys to *_message to merge with validation options # Map error message keys to *_message to merge with validation options
def timeliness_default_error_messages def timeliness_default_error_messages
defaults = ActiveRecord::Errors.default_error_messages.slice( defaults = ::ActiveRecord::Errors.default_error_messages.slice(
:blank, :invalid_date, :invalid_time, :invalid_datetime, :before, :on_or_before, :after, :on_or_after) :blank, :invalid_date, :invalid_time, :invalid_datetime, :before, :on_or_before, :after, :on_or_after)
returning({}) do |messages| returning({}) do |messages|
defaults.each {|k, v| messages["#{k}_message".to_sym] = v } defaults.each {|k, v| messages["#{k}_message".to_sym] = v }
@ -175,9 +175,9 @@ module ValidatesTimeliness
Time.zone.local(*time_array) Time.zone.local(*time_array)
else else
begin begin
Time.send(ActiveRecord::Base.default_timezone, *time_array) Time.send(::ActiveRecord::Base.default_timezone, *time_array)
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
zone_offset = ActiveRecord::Base.default_timezone == :local ? DateTime.local_offset : 0 zone_offset = ::ActiveRecord::Base.default_timezone == :local ? DateTime.local_offset : 0
time_array.pop # remove microseconds time_array.pop # remove microseconds
DateTime.civil(*(time_array << zone_offset)) DateTime.civil(*(time_array << zone_offset))
end end

View File

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe ValidatesTimeliness::InstanceTag, :type => :helper do describe ValidatesTimeliness::ActionView::InstanceTag, :type => :helper do
before do before do
@person = Person.new @person = Person.new

View File

@ -1,7 +1,7 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe ValidatesTimeliness::AttributeMethods do describe ValidatesTimeliness::ActiveRecord::AttributeMethods do
include ValidatesTimeliness::AttributeMethods include ValidatesTimeliness::ActiveRecord::AttributeMethods
include ValidatesTimeliness::Validations include ValidatesTimeliness::Validations
before do before do

View File

@ -1,6 +1,6 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
describe ValidatesTimeliness::MultiparameterAttributes do describe ValidatesTimeliness::ActiveRecord::MultiparameterAttributes do
def obj def obj
@obj ||= Person.new @obj ||= Person.new
end end

View File

@ -1,4 +1,4 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
describe ValidatesTimeliness::CoreExtensions::Date do describe ValidatesTimeliness::CoreExtensions::Date do
before do before do

View File

@ -1,4 +1,4 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
describe ValidatesTimeliness::Formats do describe ValidatesTimeliness::Formats do
attr_reader :formats attr_reader :formats

View File

@ -1,4 +1,4 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
describe "ValidateTimeliness matcher" do describe "ValidateTimeliness matcher" do
attr_accessor :no_validation, :with_validation attr_accessor :no_validation, :with_validation

View File

@ -1,4 +1,4 @@
require File.dirname(__FILE__) + '/spec_helper' require File.expand_path(File.dirname(__FILE__) + '/spec_helper')
describe ValidatesTimeliness::Validations do describe ValidatesTimeliness::Validations do
attr_accessor :person attr_accessor :person