動機
Formヘルパーの select_date とか使うと、 フォームに入力した2016年2月30日(現実に存在しない日付)という日付が DB上に 2016年3月1日と保存されるのが気に食わないのでなんとかしようと思ったのがきっかけ。
結論
先に結論を言うと
Ruby の Time クラスの挙動なのでこの挙動を変えるのはあきらめて、素直にカレンダーコントロール等で存在しない日付は入力できないようにするべき。
ソースコードリーディングの記録
日付は年,月,日が "hoge[birthday(1i)]","hoge[birthday(2i)]","hoge[birthday(3i)]" のように3つのパラメータに分割されてコントローラに渡される。
birthday カラムは migration スクリプトで :date 型とする。
その値を hoge = Hoge.new(params)
すると、前述の3つの値が Date 型に変換される。
その際、2016 年 2 月 30 日 のように存在しない日付は 3 月 1 日のように適当な日付に変換される。
その仕組みを調べて見る。
def _assign_attributes(attributes) # :nodoc:
multi_parameter_attributes = {}
nested_parameter_attributes = {}
attributes.each do |k, v|
if k.include?("(")
multi_parameter_attributes[k] = attributes.delete(k)
elsif v.is_a?(Hash)
nested_parameter_attributes[k] = attributes.delete(k)
end
end
super(attributes)
assign_nested_parameter_attributes(nested_parameter_attributes) unless nested_parameter_attributes.empty?
assign_multiparameter_attributes(multi_parameter_attributes) unless multi_parameter_attributes.empty?
end
ざっとActiveRecordのソースコードを眺めると、ActiveRecord::AttributeAssignment モジュールで _assign_attributes メソッドで"("付きの属性名を判定して、assign_multiparameter_attributes メソッドに渡しているのを発見。
# Instantiates objects for all attribute classes that needs more than one constructor parameter. This is done
# by calling new on the column type or aggregation type (through composed_of) object with these parameters.
# So having the pairs written_on(1) = "2004", written_on(2) = "6", written_on(3) = "24", will instantiate
# written_on (a date type) with Date.new("2004", "6", "24"). You can also specify a typecast character in the
# parentheses to have the parameters typecasted before they're used in the constructor. Use i for Integer and
# f for Float. If all the values for a given attribute are empty, the attribute will be set to +nil+.
def assign_multiparameter_attributes(pairs)
execute_callstack_for_multiparameter_attributes(
extract_callstack_for_multiparameter_attributes(pairs)
)
end
まず、extract_callstack_for_multiparameter_attributes メソッドで展開?
def extract_callstack_for_multiparameter_attributes(pairs)
attributes = {}
pairs.each do |(multiparameter_name, value)|
attribute_name = multiparameter_name.split("(").first
attributes[attribute_name] ||= {}
parameter_value = value.empty? ? nil : type_cast_attribute_value(multiparameter_name, value)
attributes[attribute_name][find_parameter_position(multiparameter_name)] ||= parameter_value
end
attributes
end
上記のコードで以下と等価の処理が行われ、結果の Hash が返る (コード内では callstack と呼ぶ)
attributes["birthday"][1] = params["birthday(1i)"].to_i
attributes["birthday"][2] = params["birthday(2i)"].to_i
attributes["birthday"][3] = params["birthday(3i)"].to_i
{
"birthday" => {
1 => 2016,
2 => 2,
3 => 30
}
}
その callstack を以下のメソッドで処理
def execute_callstack_for_multiparameter_attributes(callstack)
errors = []
callstack.each do |name, values_with_empty_parameters|
begin
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
end
unless errors.empty?
error_descriptions = errors.map(&:message).join(",")
raise MultiparameterAssignmentErrors.new(errors), "#{errors.size} error(s) on assignment of multiparameter attributes [#{error_descriptions}]"
end
end
展開された値は、send("#{name}=", values)
で属性に代入される。
このときの values の値は以下のような Hash
{ 1 => 2016, 2 => 2, 3 => 30 }
この値をどこかで日付に変換しているはず。
ActiveRecord::AttributeMethods::Write でそれっぽいのを発見。
def write_attribute_with_type_cast(attr_name, value, should_type_cast)
attr_name = attr_name.to_s
attr_name = self.class.primary_key if attr_name == 'id' && self.class.primary_key
if should_type_cast
@attributes.write_from_user(attr_name, value)
else
@attributes.write_cast_value(attr_name, value)
end
value
end
write_cast_value
を探す。
ActiveRecord::AttributeSet のメソッドを発見。
def write_cast_value(name, value)
attributes[name] = self[name].with_cast_value(value)
end
with_cast_value
を探す。
def with_cast_value(value)
self.class.with_cast_value(name, value, type)
end
class << self
def with_cast_value(name, value, type)
WithCastValue.new(name, value, type)
end
end
日付の変換してる箇所みつからなさそうなので一旦中断。
と、ここまでRailsまわりのソースコードを調べていたが、、、
日付が変換される現象自体は Ruby の Time クラスの挙動ということが判明。
$ irb
irb(main):001:0> Time.new(2016, 2, 30)
=> 2016-03-01 00:00:00 +0900
上記のように、存在しない日付を Time クラスのコンストラクタに与えると、自動的にそれっぽい日付に変換してくれている。