Push multi-parameter assignement into the types

This allows us to remove `Type::Value#klass`, as it was only used for
multi-parameter assignment to reach into the types internals. The
relevant type objects now accept a hash in addition to their previous
accepted arguments to `type_cast_from_user`. This required minor
modifications to the tests, since previously they were relying on the
fact that mulit-parameter assignement was reaching into the internals of
time zone aware attributes. In reaility, changing those properties at
runtime wouldn't change the accessor methods for all other forms of
assignment.
This commit is contained in:
Sean Griffin 2015-02-07 13:52:23 -07:00
parent bdeeca84e3
commit 631707a572
12 changed files with 77 additions and 108 deletions

@ -245,7 +245,8 @@ def writer_method(name, class_name, mapping, allow_nil, converter)
define_method("#{name}=") do |part|
klass = class_name.constantize
if part.is_a?(Hash)
part = klass.new(*part.values)
raise ArgumentError unless part.size == part.keys.max
part = klass.new(*part.sort.map(&:last))
end
unless part.is_a?(klass) || converter.nil? || part.nil?

@ -50,7 +50,12 @@ def execute_callstack_for_multiparameter_attributes(callstack)
errors = []
callstack.each do |name, values_with_empty_parameters|
begin
send("#{name}=", MultiparameterAttribute.new(self, name, values_with_empty_parameters).read_value)
if values_with_empty_parameters.each_value.all?(&:nil?)
values = nil
else
values = values_with_empty_parameters
end
send("#{name}=", values)
rescue => ex
errors << AttributeAssignmentError.new("error on assignment #{values_with_empty_parameters.values.inspect} to #{name} (#{ex.message})", ex, name)
end
@ -82,100 +87,5 @@ def type_cast_attribute_value(multiparameter_name, value)
def find_parameter_position(multiparameter_name)
multiparameter_name.scan(/\(([0-9]*).*\)/).first.first.to_i
end
class MultiparameterAttribute #:nodoc:
attr_reader :object, :name, :values, :cast_type
def initialize(object, name, values)
@object = object
@name = name
@values = values
end
def read_value
return if values.values.compact.empty?
@cast_type = object.type_for_attribute(name)
klass = cast_type.klass
if klass == Time
read_time
elsif klass == Date
read_date
else
read_other
end
end
private
def instantiate_time_object(set_values)
if object.class.send(:create_time_zone_conversion_attribute?, name, cast_type)
Time.zone.local(*set_values)
else
Time.send(object.class.default_timezone, *set_values)
end
end
def read_time
# If column is a :time (and not :date or :datetime) there is no need to validate if
# there are year/month/day fields
if cast_type.type == :time
# if the column is a time set the values to their defaults as January 1, 1970, but only if they're nil
{ 1 => 1970, 2 => 1, 3 => 1 }.each do |key,value|
values[key] ||= value
end
else
# else column is a timestamp, so if Date bits were not provided, error
validate_required_parameters!([1,2,3])
# If Date bits were provided but blank, then return nil
return if blank_date_parameter?
end
max_position = extract_max_param(6)
set_values = values.values_at(*(1..max_position))
# If Time bits are not there, then default to 0
(3..5).each { |i| set_values[i] = set_values[i].presence || 0 }
instantiate_time_object(set_values)
end
def read_date
return if blank_date_parameter?
set_values = values.values_at(1,2,3)
begin
Date.new(*set_values)
rescue ArgumentError # if Date.new raises an exception on an invalid date
instantiate_time_object(set_values).to_date # we instantiate Time object and convert it back to a date thus using Time's logic in handling invalid dates
end
end
def read_other
max_position = extract_max_param
positions = (1..max_position)
validate_required_parameters!(positions)
values.slice(*positions)
end
# Checks whether some blank date parameter exists. Note that this is different
# than the validate_required_parameters! method, since it just checks for blank
# positions instead of missing ones, and does not raise in case one blank position
# exists. The caller is responsible to handle the case of this returning true.
def blank_date_parameter?
(1..3).any? { |position| values[position].blank? }
end
# If some position is not provided, it errors out a missing parameter exception.
def validate_required_parameters!(positions)
if missing_parameter = positions.detect { |position| !values.key?(position) }
raise ArgumentError.new("Missing Parameter - #{name}(#{missing_parameter})")
end
end
def extract_max_param(upper_cap = 100)
[values.keys.max, upper_cap].min
end
end
end
end

@ -11,6 +11,8 @@ def type_cast_from_database(value)
def type_cast_from_user(value)
if value.is_a?(Array)
value.map { |v| type_cast_from_user(v) }
elsif value.is_a?(Hash)
set_time_zone_without_conversion(super)
elsif value.respond_to?(:in_time_zone)
begin
user_input_in_time_zone(value) || super
@ -20,6 +22,8 @@ def type_cast_from_user(value)
end
end
private
def convert_time_to_time_zone(value)
if value.is_a?(Array)
value.map { |v| convert_time_to_time_zone(v) }
@ -29,6 +33,10 @@ def convert_time_to_time_zone(value)
value
end
end
def set_time_zone_without_conversion(value)
::Time.zone.local_to_utc(value).in_time_zone
end
end
extend ActiveSupport::Concern

@ -1,3 +1,4 @@
require 'active_record/type/helpers'
require 'active_record/type/decorator'
require 'active_record/type/mutable'
require 'active_record/type/numeric'

@ -1,14 +1,12 @@
module ActiveRecord
module Type
class Date < Value # :nodoc:
include Helpers::AcceptsMultiparameterTime.new
def type
:date
end
def klass
::Date
end
def type_cast_for_schema(value)
"'#{value.to_s(:db)}'"
end
@ -41,6 +39,11 @@ def new_date(year, mon, mday)
::Date.new(year, mon, mday) rescue nil
end
end
def value_from_multiparameter_assignment(*)
time = super
time && time.to_date
end
end
end
end

@ -2,6 +2,9 @@ module ActiveRecord
module Type
class DateTime < Value # :nodoc:
include TimeValue
include Helpers::AcceptsMultiparameterTime.new(
defaults: { 4 => 0, 5 => 0 }
)
def type
:datetime
@ -42,6 +45,14 @@ def fallback_string_to_time(string)
new_time(*time_hash.values_at(:year, :mon, :mday, :hour, :min, :sec, :sec_fraction, :offset))
end
def value_from_multiparameter_assignment(values_hash)
missing_parameter = (1..3).detect { |key| !values_hash.key?(key) }
if missing_parameter
raise ArgumentError, missing_parameter
end
super
end
end
end
end

@ -0,0 +1 @@
require 'active_record/type/helpers/accepts_multiparameter_time'

@ -0,0 +1,30 @@
module ActiveRecord
module Type
module Helpers
class AcceptsMultiparameterTime < Module
def initialize(defaults: {})
define_method(:type_cast_from_user) do |value|
if value.is_a?(Hash)
value_from_multiparameter_assignment(value)
else
super(value)
end
end
define_method(:value_from_multiparameter_assignment) do |values_hash|
defaults.each do |k, v|
values_hash[k] ||= v
end
return unless values_hash[1] && values_hash[2] && values_hash[3]
values = values_hash.sort.map(&:last)
::Time.send(
ActiveRecord::Base.default_timezone,
*values
)
end
private :value_from_multiparameter_assignment
end
end
end
end
end

@ -2,6 +2,9 @@ module ActiveRecord
module Type
class Time < Value # :nodoc:
include TimeValue
include Helpers::AcceptsMultiparameterTime.new(
defaults: { 1 => 1970, 2 => 1, 3 => 1, 4 => 0, 5 => 0 }
)
def type
:time

@ -1,10 +1,6 @@
module ActiveRecord
module Type
module TimeValue # :nodoc:
def klass
::Time
end
def type_cast_for_schema(value)
"'#{value.to_s(:db)}'"
end

@ -64,9 +64,6 @@ def binary? # :nodoc:
false
end
def klass # :nodoc:
end
# Determines whether a value has changed for dirty checking. +old_value+
# and +new_value+ will always be type-cast. Types should not need to
# override this method.

@ -199,6 +199,7 @@ def test_multiparameter_attributes_on_time_with_utc
def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes
with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
Topic.reset_column_information
attributes = {
"written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
"written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
@ -209,6 +210,8 @@ def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes
assert_equal Time.utc(2004, 6, 24, 16, 24, 0), topic.written_on.time
assert_equal Time.zone, topic.written_on.time_zone
end
ensure
Topic.reset_column_information
end
def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_false
@ -227,6 +230,7 @@ def test_multiparameter_attributes_on_time_with_time_zone_aware_attributes_false
def test_multiparameter_attributes_on_time_with_skip_time_zone_conversion_for_attributes
with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
Topic.skip_time_zone_conversion_for_attributes = [:written_on]
Topic.reset_column_information
attributes = {
"written_on(1i)" => "2004", "written_on(2i)" => "6", "written_on(3i)" => "24",
"written_on(4i)" => "16", "written_on(5i)" => "24", "written_on(6i)" => "00"
@ -238,12 +242,14 @@ def test_multiparameter_attributes_on_time_with_skip_time_zone_conversion_for_at
end
ensure
Topic.skip_time_zone_conversion_for_attributes = []
Topic.reset_column_information
end
# Oracle does not have a TIME datatype.
unless current_adapter?(:OracleAdapter)
def test_multiparameter_attributes_on_time_only_column_with_time_zone_aware_attributes_does_not_do_time_zone_conversion
with_timezone_config default: :utc, aware_attributes: true, zone: -28800 do
Topic.reset_column_information
attributes = {
"bonus_time(1i)" => "2000", "bonus_time(2i)" => "1", "bonus_time(3i)" => "1",
"bonus_time(4i)" => "16", "bonus_time(5i)" => "24"
@ -253,6 +259,8 @@ def test_multiparameter_attributes_on_time_only_column_with_time_zone_aware_attr
assert_equal Time.zone.local(2000, 1, 1, 16, 24, 0), topic.bonus_time
assert_not topic.bonus_time.utc?
end
ensure
Topic.reset_column_information
end
end