2
3

More than 3 years have passed since last update.

2016年2月30日が DB上に 2016年3月1日と保存される理由を調べたが...

Last updated at Posted at 2016-07-13

動機

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 日のように適当な日付に変換される。

その仕組みを調べて見る。

rails/activerecord/lib/active_record/attribute_assignment.rb
    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 メソッドに渡しているのを発見。

rails/activerecord/lib/active_record/attribute_assignment.rb
    # 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 メソッドで展開?

rails/activerecord/lib/active_record/attribute_assignment.rb
    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 を以下のメソッドで処理

rails/activerecord/lib/active_record/attribute_assignment.rb
    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 でそれっぽいのを発見。

rails/activerecord/lib/active_record/attribute_methods/write.rb
      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 のメソッドを発見。

rails/activerecord/lib/active_record/attribute_set.rb
    def write_cast_value(name, value)
      attributes[name] = self[name].with_cast_value(value)
    end

with_cast_value を探す。

rails/activerecord/lib/active_record/attribute.rb
    def with_cast_value(value)
      self.class.with_cast_value(name, value, type)
    end
rails/activerecord/lib/active_record/attribute.rb
    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 クラスのコンストラクタに与えると、自動的にそれっぽい日付に変換してくれている。

2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3